Merge remote-tracking branch 'origin/main' into feat/knowledge-retrieval-core

# Conflicts:
#	README.md
#	backend/app/routes.py
#	docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md
#	docs/development/Knowledge与Retrieval-Core开发说明.md
This commit is contained in:
yxx
2026-09-03 23:45:52 +08:00
57 changed files with 7669 additions and 184 deletions
+6
View File
@@ -14,6 +14,12 @@ backend/.env
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
backend/data/*.db*
backend/data/credentials/
# 阶段验收笔记(验收用,不提交)
backend/data/vault/验收/
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
backend/data/mcp/
server.json
servers.json
# Editors and operating systems
.idea/
+5 -3
View File
@@ -2,7 +2,7 @@
> 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 README。
> 当前基线:2026-08-30。第一阶段 Web 联调版的前端页面、Knowledge/Retrieval Core、AI/Agent Core、Extension Core、Provider 预设与本地加密凭据链路均已实现Tauri Host、Stronghold、真实桌面文件系统和 Sync Server 尚未接入。
> 当前基线:2026-09-03。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host、Plugin Command/Settings,以及独立 MCP Server 配置中心 C.1stdio、Streamable HTTP 与旧 SSE 兼容)。真实音频、Provider 协议增强、Benchmark、导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。
## 当前目录
@@ -118,7 +118,7 @@ cd frontend
pnpm test
```
当前回归基线为后端 157 项测试、前端 29 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
当前回归基线为后端 218 项测试、前端 32 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。
@@ -133,6 +133,8 @@ pnpm test
| [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 |
| [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
| [MCP Bridge 与 Plugin Host](docs/development/MCP-Bridge与Plugin-Host开发说明.md) | stdio MCP、隔离进程、Tool 映射、状态与错误边界 |
| [Plugin Command 与 Settings](docs/development/Plugin-Command与Settings开发说明.md) | Command Registry、Settings Schema、Secret 引用与联调边界 |
| [Plugin Command 与 Settings 复盘](docs/retrospectives/Plugin-Command与Settings问题与修复复盘.md) | 阶段 D 连续审阅发现的安全、事务、Schema 与运行时契约问题 |
| [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
| [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 |
| [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 |
@@ -146,6 +148,6 @@ pnpm test
- 后端附件目录默认是 `backend/data/attachments`,可通过 `APP_ATTACHMENTS_PATH` 覆盖;该目录由桌面 Host 管理。
- 跨模块接口发生变化时,需要同步更新前后端类型和 `docs` 中的接口说明。
- 当前已实现接口见 `docs/contracts/后端接口契约-开发版.md`,第二阶段规划接口见 `docs/contracts/第二阶段接口契约-开发版.md`;已实现能力以 `/openapi.json` 为准。
- 前端页面、交互、状态管理和第一阶段验收要求见 `docs/contracts/前端页面需求说明-开发版.md`
- 前端页面、交互、状态管理及当前阶段后续页面需求见 `docs/contracts/前端页面需求说明-开发版.md`
- 分支、提交、Pull Request、Review 和冲突处理规范见 `docs/guides/Git使用细则-团队开发版.md`
- CI 检查、产物、发布和回滚规范见 `docs/guides/CI-CD细则-团队开发版.md`
+2 -2
View File
@@ -2,7 +2,7 @@
FastAPI + Pydantic 的本地 AI Core / Agent Core。项目使用 uv 管理依赖和虚拟环境。
当前实现包含 Knowledge/Retrieval、Chat、Agent Runtime、Tool/Permission、Skill/Plugin、Provider Adapter、任务、索引和开发阶段凭据加密存储。Provider 支持 Mock、OpenAI Chat/OpenAI-Compatible 与 OllamaOpenAI Responses、Anthropic Messages、MCP 独立 Host 和真实语音模型仍属于后续阶段。
当前实现包含 Knowledge/Retrieval、Chat、Agent Runtime、Tool/Permission、Skill/Plugin、stdio MCP Host、Plugin Command/Settings、Provider Adapter、任务、索引和开发阶段凭据加密存储。Provider 支持 Mock、OpenAI Chat/OpenAI-Compatible 与 OllamaOpenAI Responses、Anthropic Messages、操作系统级 Plugin 沙箱和真实语音模型仍属于后续阶段。
```powershell
uv sync
@@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
uv run pytest
```
当前基线为 92 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
当前基线为 136 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
+3
View File
@@ -11,6 +11,7 @@ from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
from app.contracts import ToolCall, ToolDefinition, ToolResult
from app.schema_security import reject_external_schema_references
ToolExecutor = Callable[[BaseModel, "ToolExecutionContext"], Any | Awaitable[Any]]
@@ -54,6 +55,8 @@ class ToolRegistry:
arguments_model: type[BaseModel],
executor: ToolExecutor,
) -> None:
Draft202012Validator.check_schema(definition.parameters)
reject_external_schema_references(definition.parameters)
with self._lock:
if definition.name in self._tools:
raise ValueError(f"Tool already registered: {definition.name}")
+12
View File
@@ -5,6 +5,7 @@ from app.agent.builtin_tools import register_builtin_tools
from app.contracts import ModelCapability, ProviderConfig, ProviderType
from app.config import BACKEND_DIR, get_settings
from app.extensions import PluginRuntime, SkillRuntime
from app.extensions.mcp_registry import McpServerRegistry
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
from app.providers.credentials import (
ChainedCredentialResolver,
@@ -22,6 +23,7 @@ class ApplicationContainer:
permissions: PermissionManager
skills: SkillRuntime
plugins: PluginRuntime
mcp_servers: McpServerRegistry
agent: AgentRuntime
@@ -53,6 +55,7 @@ def build_container() -> ApplicationContainer:
plugins = PluginRuntime(
tools,
credentials=credentials,
# 当前 Python Host 尚无 OS 沙箱。生产构建必须保持关闭,直到
# Tauri/Rust Host 能签发绑定命令摘要的可信启动许可。
allow_unsandboxed_mcp=settings.environment == "development",
@@ -60,6 +63,14 @@ def build_container() -> ApplicationContainer:
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
plugins.enable("text-tools")
mcp_servers = McpServerRegistry(
tools,
credentials,
settings.data_dir,
allow_process_launch=settings.environment == "development",
)
mcp_servers.restore_enabled()
skills = SkillRuntime(tools)
skills.install(BACKEND_DIR / "extensions" / "skills" / "knowledge-assistant")
skills.enable("knowledge-assistant")
@@ -80,6 +91,7 @@ def build_container() -> ApplicationContainer:
permissions=permissions,
skills=skills,
plugins=plugins,
mcp_servers=mcp_servers,
agent=agent,
)
+259 -2
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from typing import Any, Literal
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
@@ -205,7 +205,7 @@ class ToolDefinition(Contract):
description: str
parameters: dict[str, Any] = Field(default_factory=dict)
permission: str | None = None
source: Literal["builtin", "plugin"] = "builtin"
source: Literal["builtin", "plugin", "mcp_server"] = "builtin"
class ToolCall(Contract):
@@ -492,6 +492,263 @@ class PluginHostStatus(Contract):
error: str | None = None
# Independent user-managed MCP Server Registry. This is deliberately separate
# from Plugin manifests: a server can contribute tools without being a Plugin.
class McpServerTransport(str, Enum):
stdio = "stdio"
streamable_http = "streamable_http"
sse = "sse"
class McpServerConfig(Contract):
name: str = Field(min_length=1, max_length=80)
transport: McpServerTransport = McpServerTransport.stdio
command: str | None = Field(default=None, max_length=1024)
args: list[str] = Field(default_factory=list, max_length=64)
url: str | None = Field(default=None, max_length=4096)
headers: dict[str, str] = Field(default_factory=dict)
environment: dict[str, str] = Field(default_factory=dict)
secret_environment_keys: list[str] = Field(default_factory=list)
secret_header_keys: list[str] = Field(default_factory=list)
permissions: list[str] = Field(default_factory=list)
startup_timeout_seconds: float = Field(default=15, ge=1, le=120)
tool_timeout_seconds: float = Field(default=30, ge=1, le=300)
class McpServerCreateRequest(McpServerConfig):
pass
class McpServerUpdateRequest(McpServerConfig):
version: int = Field(ge=1)
class McpServerSecretWriteRequest(Contract):
secret: SecretStr = Field(min_length=1, max_length=32768)
class McpServerSecretStatus(Contract):
key: str
configured: bool
class McpServerTrustRequest(Contract):
command_digest: str = Field(min_length=64, max_length=64)
class McpServerStatus(Contract):
enabled: bool = False
status: PluginHostState = PluginHostState.stopped
tools_count: int = 0
protocol_version: str | None = None
remote_server_name: str | None = None
remote_server_version: str | None = None
error: str | None = None
last_tested_at: datetime | None = None
last_test_succeeded: bool | None = None
class McpServer(McpServerStatus):
server_id: str
version: int
name: str
transport: McpServerTransport
command: str | None = None
args: list[str] = Field(default_factory=list)
url: str | None = None
headers: dict[str, str] = Field(default_factory=dict)
environment: dict[str, str] = Field(default_factory=dict)
permissions: list[str] = Field(default_factory=list)
startup_timeout_seconds: float
tool_timeout_seconds: float
secret_environment: dict[str, bool] = Field(default_factory=dict)
secret_headers: dict[str, bool] = Field(default_factory=dict)
trusted: bool = False
command_digest: str
command_summary: str
class McpServerListResponse(Contract):
items: list[McpServer] = Field(default_factory=list)
class McpToolSummary(Contract):
name: str
remote_name: str
description: str
permission: str | None = None
class McpToolSummaryListResponse(Contract):
items: list[McpToolSummary] = Field(default_factory=list)
class PluginCommandLocation(str, Enum):
command_palette = "command_palette"
context_menu = "context_menu"
toolbar = "toolbar"
class PluginCommand(Contract):
command_id: str
plugin_id: str
title: str
description: str = ""
icon: str | None = None
locations: list[PluginCommandLocation] = Field(default_factory=list)
when: list[str] = Field(default_factory=list)
parameters: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
class PluginCommandListResponse(Contract):
items: list[PluginCommand] = Field(default_factory=list)
class PluginCommandContext(Contract):
vault_id: str | None = None
note_id: str | None = None
file_path: str | None = None
selection: str | None = None
class PluginCommandExecuteRequest(Contract):
arguments: dict[str, Any] = Field(default_factory=dict)
context: PluginCommandContext = Field(default_factory=PluginCommandContext)
class PluginNotificationEffectPayload(Contract):
level: Literal["info", "success", "warning", "error"] = "info"
message: str = Field(min_length=1, max_length=4096)
class PluginNavigateEffectPayload(Contract):
route: Literal[
"vault-entry",
"workspace",
"search",
"chat",
"agent",
"tasks",
"skills",
"plugins",
"themes",
"settings",
]
class PluginRefreshEffectPayload(Contract):
scope: Literal["workspace", "commands", "settings", "plugins"]
class PluginJobEffectPayload(Contract):
job_id: str = Field(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$",
)
class PluginNoEffectPayload(Contract):
pass
class PluginNoEffect(Contract):
type: Literal["none"] = "none"
payload: PluginNoEffectPayload = Field(default_factory=PluginNoEffectPayload)
class PluginNotificationEffect(Contract):
type: Literal["notification"] = "notification"
payload: PluginNotificationEffectPayload
class PluginNavigateEffect(Contract):
type: Literal["navigate"] = "navigate"
payload: PluginNavigateEffectPayload
class PluginRefreshEffect(Contract):
type: Literal["refresh"] = "refresh"
payload: PluginRefreshEffectPayload
class PluginJobEffect(Contract):
type: Literal["job"] = "job"
payload: PluginJobEffectPayload
PluginCommandEffect = Annotated[
PluginNoEffect
| PluginNotificationEffect
| PluginNavigateEffect
| PluginRefreshEffect
| PluginJobEffect,
Field(discriminator="type"),
]
PLUGIN_COMMAND_EFFECT_TYPES = (
PluginNoEffect,
PluginNotificationEffect,
PluginNavigateEffect,
PluginRefreshEffect,
PluginJobEffect,
)
class PluginCommandResult(Contract):
command_id: str
status: Literal["completed"] = "completed"
effect: PluginCommandEffect = Field(default_factory=PluginNoEffect)
class PluginSettingType(str, Enum):
string = "string"
number = "number"
boolean = "boolean"
select = "select"
secret = "secret"
class PluginSettingField(Contract):
key: str
label: str
description: str = ""
type: PluginSettingType
required: bool = False
default: Any | None = None
minimum: float | None = None
maximum: float | None = None
options: list[str] = Field(default_factory=list)
class PluginSecretState(Contract):
configured: bool = False
class PluginSettingsSchema(Contract):
plugin_id: str
schema_version: int = Field(ge=1)
fields: list[PluginSettingField] = Field(default_factory=list)
values: dict[str, Any] = Field(default_factory=dict)
secrets: dict[str, PluginSecretState] = Field(default_factory=dict)
class PluginSettingsUpdateRequest(Contract):
schema_version: int = Field(ge=1)
values: dict[str, Any] = Field(default_factory=dict)
class PluginSecretWriteRequest(Contract):
secret: SecretStr
class PluginSecretStatus(Contract):
plugin_id: str
key: str
configured: bool
class PluginPermissionGrantRequest(Contract):
permissions: list[str] = Field(default_factory=list)
+2 -6
View File
@@ -1,9 +1,5 @@
from app.extensions.runtime import (
AgentConfiguration,
ExtensionError,
PluginRuntime,
SkillRuntime,
)
from app.extensions.errors import ExtensionError
from app.extensions.runtime import AgentConfiguration, PluginRuntime, SkillRuntime
from app.extensions.mcp import McpBridge, McpBridgeError
__all__ = [
+847
View File
@@ -0,0 +1,847 @@
"""Plugin Command Registry 与 Settings/Secret 命名空间存储。"""
from __future__ import annotations
import asyncio
import hashlib
import inspect
import json
import math
import re
import threading
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from time import perf_counter
from typing import Any, Awaitable, Callable, Literal
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError, ValidationError as JsonSchemaValidationError
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.contracts import (
PLUGIN_COMMAND_EFFECT_TYPES,
PluginCommand,
PluginCommandContext,
PluginCommandEffect,
PluginCommandLocation,
PluginCommandResult,
PluginSecretState,
PluginSecretStatus,
PluginSettingField,
PluginSettingType,
PluginSettingsSchema,
)
from app.extensions.errors import ExtensionError
from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore
from app.schema_security import (
SchemaReferenceError,
reject_external_schema_references,
)
_CONTRIBUTION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
_SETTING_KEY = re.compile(r"^[a-z][a-z0-9._-]{0,127}$")
_HOST_ICONS = {"bolt", "document", "edit", "link", "refresh", "search", "setting"}
_WHEN_TOKENS = {
"workspace.has_vault",
"editor.has_note",
"editor.has_selection",
}
_CONTEXT_KEYS = {"vault_id", "note_id", "file_path", "selection"}
_WHEN_CONTEXT = {
"workspace.has_vault": "vault_id",
"editor.has_note": "note_id",
"editor.has_selection": "selection",
}
class PluginCommandSpec(BaseModel):
"""包内 commands.yaml 的宿主侧声明,不直接暴露 handler。"""
model_config = ConfigDict(extra="forbid")
command_id: str
title: str
description: str = ""
icon: str | None = None
locations: list[PluginCommandLocation] = Field(default_factory=list)
when: list[str] = Field(default_factory=list)
context: list[Literal["vault_id", "note_id", "file_path", "selection"]] = Field(
default_factory=list
)
parameters: dict[str, Any] = Field(
default_factory=lambda: {
"type": "object",
"properties": {},
"additionalProperties": False,
}
)
permission: str | None = None
secrets: list[str] = Field(default_factory=list)
handler: Literal["echo", "uppercase_selection"] | None = None
mcp_tool: str | None = None
timeout_seconds: int = Field(default=30, ge=1, le=120)
@model_validator(mode="after")
def validate_execution_target(self) -> "PluginCommandSpec":
if (self.handler is None) == (self.mcp_tool is None):
raise ValueError("Command must declare exactly one handler or mcp_tool target.")
return self
CommandExecutor = Callable[
[dict[str, Any], dict[str, Any]],
PluginCommandEffect | Awaitable[PluginCommandEffect],
]
PluginSecretResolver = Callable[[str], str | None]
@dataclass(slots=True)
class _RegisteredCommand:
command: PluginCommand
spec: PluginCommandSpec
executor: CommandExecutor
@dataclass(frozen=True, slots=True)
class PluginCommandAuditEvent:
"""不记录参数与上下文的轻量审计事件,避免把正文或 Secret 写入日志。"""
command_id: str
plugin_id: str
status: Literal["completed", "failed"]
duration_ms: int
error_code: str | None
created_at: datetime
class CommandRegistry:
"""只发布已启用 Plugin 的受控 Command Contribution。"""
def __init__(self) -> None:
self._commands: dict[str, _RegisteredCommand] = {}
self._audit: deque[PluginCommandAuditEvent] = deque(maxlen=500)
self._lock = threading.RLock()
def register(
self,
plugin_id: str,
spec: PluginCommandSpec,
executor: CommandExecutor,
) -> None:
validate_command_spec(plugin_id, spec)
command = PluginCommand(
command_id=spec.command_id,
plugin_id=plugin_id,
title=spec.title,
description=spec.description,
icon=spec.icon,
locations=spec.locations,
when=spec.when,
parameters=spec.parameters,
enabled=True,
)
with self._lock:
if spec.command_id in self._commands:
raise ExtensionError(
"PLUGIN_COMMAND_CONFLICT",
f"Plugin command is already registered: {spec.command_id}",
status_code=409,
details={"command_id": spec.command_id},
)
self._commands[spec.command_id] = _RegisteredCommand(command, spec, executor)
def unregister(self, command_id: str) -> None:
with self._lock:
self._commands.pop(command_id, None)
def contains(self, command_id: str) -> bool:
with self._lock:
return command_id in self._commands
def list(self, location: PluginCommandLocation | None = None) -> list[PluginCommand]:
with self._lock:
items = [
item.command.model_copy(deep=True)
for item in self._commands.values()
if location is None or location in item.command.locations
]
return sorted(items, key=lambda item: item.command_id)
def audit_events(self) -> list[PluginCommandAuditEvent]:
"""返回有界审计快照;事件刻意不包含 arguments/context/effect。"""
with self._lock:
return list(self._audit)
async def execute(
self,
command_id: str,
arguments: dict[str, Any],
context: PluginCommandContext,
) -> PluginCommandResult:
with self._lock:
registered = self._commands.get(command_id)
if registered is None:
raise ExtensionError(
"PLUGIN_COMMAND_NOT_FOUND",
f"Plugin command is not registered or enabled: {command_id}",
status_code=404,
details={"command_id": command_id},
)
started_at = perf_counter()
try:
Draft202012Validator(registered.spec.parameters).validate(arguments)
except JsonSchemaValidationError as exc:
error = ExtensionError(
"PLUGIN_COMMAND_ARGUMENT_INVALID",
"Plugin command arguments do not match the declared schema.",
details={"command_id": command_id, "path": list(exc.path)},
)
self._record_audit(registered, started_at, error.code)
raise error from exc
raw_context = context.model_dump(exclude_none=True)
missing = [
token
for token in registered.spec.when
if not raw_context.get(_WHEN_CONTEXT[token])
]
if missing:
error = ExtensionError(
"PLUGIN_COMMAND_CONTEXT_INVALID",
"Plugin command context does not satisfy its when conditions.",
details={"command_id": command_id, "missing": missing},
)
self._record_audit(registered, started_at, error.code)
raise error
scoped_context = {
key: raw_context[key]
for key in registered.spec.context
if key in raw_context
}
try:
effect = registered.executor(dict(arguments), scoped_context)
if inspect.isawaitable(effect):
effect = await asyncio.wait_for(
effect, timeout=registered.spec.timeout_seconds
)
except TimeoutError as exc:
error = ExtensionError(
"PLUGIN_COMMAND_TIMEOUT",
"Plugin command execution timed out.",
status_code=504,
details={"command_id": command_id},
)
self._record_audit(registered, started_at, error.code)
raise error from exc
except ExtensionError as exc:
self._record_audit(registered, started_at, exc.code)
raise
except Exception as exc:
error = ExtensionError(
"PLUGIN_COMMAND_EXECUTION_FAILED",
"Plugin command execution failed.",
status_code=502,
details={"command_id": command_id},
)
self._record_audit(registered, started_at, error.code)
raise error from exc
if not isinstance(effect, PLUGIN_COMMAND_EFFECT_TYPES):
error = ExtensionError(
"PLUGIN_COMMAND_RESULT_INVALID",
"Plugin command returned an invalid effect.",
status_code=502,
details={"command_id": command_id},
)
self._record_audit(registered, started_at, error.code)
raise error
try:
encoded_effect = json.dumps(effect.model_dump(mode="json"), ensure_ascii=False)
except (TypeError, ValueError) as exc:
error = ExtensionError(
"PLUGIN_COMMAND_RESULT_INVALID",
"Plugin command returned a non-serializable effect.",
status_code=502,
details={"command_id": command_id},
)
self._record_audit(registered, started_at, error.code)
raise error from exc
if len(encoded_effect.encode("utf-8")) > 64 * 1024:
error = ExtensionError(
"PLUGIN_COMMAND_RESULT_TOO_LARGE",
"Plugin command effect exceeds the 64 KiB response limit.",
status_code=502,
details={"command_id": command_id},
)
self._record_audit(registered, started_at, error.code)
raise error
self._record_audit(registered, started_at, None)
return PluginCommandResult(command_id=command_id, effect=effect)
def _record_audit(
self,
registered: _RegisteredCommand,
started_at: float,
error_code: str | None,
) -> None:
event = PluginCommandAuditEvent(
command_id=registered.command.command_id,
plugin_id=registered.command.plugin_id,
status="failed" if error_code else "completed",
duration_ms=max(0, round((perf_counter() - started_at) * 1000)),
error_code=error_code,
created_at=datetime.now(UTC),
)
with self._lock:
self._audit.append(event)
class PluginSettingsDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
section_id: str
schema_version: int = Field(ge=1)
fields: list[PluginSettingField] = Field(default_factory=list)
class PluginSettingsStore:
"""非敏感值写入插件命名空间;Secret 只保存加密凭据引用。"""
def __init__(self, credentials: EncryptedCredentialStore) -> None:
self.credentials = credentials
self._lock = threading.RLock()
@staticmethod
def _path() -> Path:
return get_settings().data_dir / "plugins" / "settings.json"
def get(
self, plugin_id: str, definition: PluginSettingsDefinition
) -> PluginSettingsSchema:
with self._lock:
entry = self._entry(self._read(), plugin_id)
stored_values = entry.get("values", {})
secret_refs = entry.get("secret_refs", {})
if not isinstance(stored_values, dict) or not isinstance(secret_refs, dict):
raise self._storage_format_error(plugin_id)
validated_refs = self._validate_secret_refs(plugin_id, secret_refs)
values = {
field.key: field.default
for field in definition.fields
if field.type != PluginSettingType.secret and field.default is not None
}
allowed_values = {
field.key
for field in definition.fields
if field.type != PluginSettingType.secret
}
fields = {field.key: field for field in definition.fields}
for key, value in stored_values.items():
if key not in allowed_values:
continue
try:
_validate_setting_value(fields[key], value)
except ExtensionError as exc:
raise self._storage_format_error(plugin_id) from exc
values[key] = value
secrets: dict[str, PluginSecretState] = {}
for field in definition.fields:
if field.type != PluginSettingType.secret:
continue
reference = validated_refs.get(field.key)
secrets[field.key] = PluginSecretState(
configured=isinstance(reference, str) and self._has_secret(reference)
)
return PluginSettingsSchema(
plugin_id=plugin_id,
schema_version=definition.schema_version,
fields=definition.fields,
values=values,
secrets=secrets,
)
def runtime_values(
self, plugin_id: str, definition: PluginSettingsDefinition
) -> dict[str, Any]:
"""返回可供 Command 使用的完整普通设置,并拦截未配置的必填项。"""
schema = self.get(plugin_id, definition)
missing = [
field.key
for field in definition.fields
if field.required
and field.type != PluginSettingType.secret
and field.key not in schema.values
]
if missing:
raise ExtensionError(
"PLUGIN_SETTINGS_REQUIRED",
"Required Plugin settings have not been configured.",
status_code=409,
details={"plugin_id": plugin_id, "fields": missing},
)
return schema.values
def update(
self,
plugin_id: str,
definition: PluginSettingsDefinition,
schema_version: int,
values: dict[str, Any],
) -> PluginSettingsSchema:
if schema_version != definition.schema_version:
raise ExtensionError(
"PLUGIN_SETTINGS_VERSION_CONFLICT",
"Plugin settings schema version is out of date.",
status_code=409,
details={
"plugin_id": plugin_id,
"requested_version": schema_version,
"current_version": definition.schema_version,
},
)
fields = {field.key: field for field in definition.fields}
unknown = sorted(set(values) - set(fields))
if unknown:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
"Plugin settings contain unknown fields.",
details={"plugin_id": plugin_id, "fields": unknown},
)
secret_keys = sorted(
key for key in values if fields[key].type == PluginSettingType.secret
)
if secret_keys:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
"Secret fields must use the dedicated Secret endpoint.",
details={"plugin_id": plugin_id, "fields": secret_keys},
)
for key, value in values.items():
_validate_setting_value(fields[key], value)
with self._lock:
data = self._read()
entry = self._entry(data, plugin_id, create=True)
current = entry.get("values", {})
if not isinstance(current, dict):
raise self._storage_format_error(plugin_id)
entry["values"] = current
current.update(values)
effective = {
field.key: field.default
for field in definition.fields
if field.type != PluginSettingType.secret and field.default is not None
}
effective.update(current)
missing = [
field.key
for field in definition.fields
if field.required
and field.type != PluginSettingType.secret
and field.key not in effective
]
if missing:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
"Required Plugin settings are missing.",
details={"plugin_id": plugin_id, "fields": missing},
)
entry["schema_version"] = definition.schema_version
self._write(data)
return self.get(plugin_id, definition)
def put_secret(
self,
plugin_id: str,
definition: PluginSettingsDefinition,
key: str,
secret: str,
) -> PluginSecretStatus:
_secret_field(definition, plugin_id, key)
if not secret:
raise ExtensionError(
"PLUGIN_SECRET_VALUE_INVALID",
"Plugin secret cannot be empty.",
details={"plugin_id": plugin_id, "key": key},
)
if len(secret.encode("utf-8")) > 64 * 1024:
raise ExtensionError(
"PLUGIN_SECRET_VALUE_INVALID",
"Plugin secret exceeds the 64 KiB limit.",
details={"plugin_id": plugin_id, "key": key},
)
reference = _secret_reference(plugin_id, key)
with self._lock:
data = self._read()
entry = self._entry(data, plugin_id, create=True)
refs = entry.get("secret_refs", {})
if not isinstance(refs, dict):
raise self._storage_format_error(plugin_id)
self._validate_secret_refs(plugin_id, refs)
entry["secret_refs"] = refs
try:
previous = self.credentials.resolve(reference)
self.credentials.put(reference, secret)
except CredentialStoreError as exc:
raise ExtensionError(
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
refs[key] = reference
entry["schema_version"] = definition.schema_version
try:
self._write(data)
except ExtensionError:
# 普通设置落盘失败时恢复凭据旧值,避免产生不可达的新 Secret。
try:
if previous is None:
self.credentials.delete(reference)
else:
self.credentials.put(reference, previous)
except CredentialStoreError:
pass
raise
return PluginSecretStatus(plugin_id=plugin_id, key=key, configured=True)
def delete_secret(
self,
plugin_id: str,
definition: PluginSettingsDefinition,
key: str,
) -> PluginSecretStatus:
_secret_field(definition, plugin_id, key)
with self._lock:
data = self._read()
entry = self._entry(data, plugin_id)
refs = entry.get("secret_refs", {})
if not isinstance(refs, dict):
raise self._storage_format_error(plugin_id)
self._validate_secret_refs(plugin_id, refs)
reference = _secret_reference(plugin_id, key)
had_reference = refs.pop(key, None) is not None
if plugin_id in data and had_reference:
self._write(data)
try:
self.credentials.delete(reference)
except CredentialStoreError as exc:
if had_reference:
refs[key] = reference
try:
self._write(data)
except ExtensionError as rollback_exc:
raise ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin Secret deletion failed and its reference could not be restored.",
status_code=500,
details={"plugin_id": plugin_id, "key": key},
) from rollback_exc
raise ExtensionError(
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
return PluginSecretStatus(plugin_id=plugin_id, key=key, configured=False)
def resolve_secret(
self, plugin_id: str, definition: PluginSettingsDefinition, key: str
) -> str | None:
_secret_field(definition, plugin_id, key)
with self._lock:
entry = self._entry(self._read(), plugin_id)
refs = entry.get("secret_refs", {})
if not isinstance(refs, dict):
raise self._storage_format_error(plugin_id)
reference = self._validate_secret_refs(plugin_id, refs).get(key)
try:
return self.credentials.resolve(reference) if isinstance(reference, str) else None
except CredentialStoreError as exc:
raise ExtensionError(
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
def remove_plugin(self, plugin_id: str) -> None:
with self._lock:
data = self._read()
entry = data.pop(plugin_id, None)
references: list[str] = []
if entry is not None and not isinstance(entry, dict):
raise self._storage_format_error(plugin_id)
if entry is not None:
refs = entry.get("secret_refs", {})
if not isinstance(refs, dict):
raise self._storage_format_error(plugin_id)
references = list(self._validate_secret_refs(plugin_id, refs).values())
if entry is not None:
self._write(data)
try:
self.credentials.delete_many(references)
except CredentialStoreError as exc:
if entry is not None:
data[plugin_id] = entry
try:
self._write(data)
except ExtensionError as rollback_exc:
raise ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin uninstall failed and its Settings namespace could not be restored.",
status_code=500,
details={"plugin_id": plugin_id},
) from rollback_exc
raise ExtensionError(
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
def _validate_secret_refs(
self, plugin_id: str, refs: dict[Any, Any]
) -> dict[str, str]:
validated: dict[str, str] = {}
for key, reference in refs.items():
if (
not isinstance(key, str)
or not _SETTING_KEY.fullmatch(key)
or not isinstance(reference, str)
or reference != _secret_reference(plugin_id, key)
):
raise self._storage_format_error(plugin_id)
validated[key] = reference
return validated
def _has_secret(self, reference: str) -> bool:
try:
return self.credentials.has(reference)
except CredentialStoreError as exc:
raise ExtensionError(
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
@staticmethod
def _storage_format_error(plugin_id: str) -> ExtensionError:
return ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin settings namespace has an invalid format.",
status_code=500,
details={"plugin_id": plugin_id},
)
def _entry(
self,
data: dict[str, dict[str, Any]],
plugin_id: str,
*,
create: bool = False,
) -> dict[str, Any]:
entry = data.get(plugin_id)
if entry is None:
if create:
data[plugin_id] = {}
return data[plugin_id]
return {}
if not isinstance(entry, dict):
raise self._storage_format_error(plugin_id)
return entry
def _read(self) -> dict[str, dict[str, Any]]:
path = self._path()
if not path.exists():
return {}
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin settings storage cannot be loaded.",
status_code=500,
) from exc
if not isinstance(value, dict):
raise ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin settings storage has an invalid format.",
status_code=500,
)
return value
def _write(self, value: dict[str, dict[str, Any]]) -> None:
path = self._path()
temporary = path.with_suffix(".tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
temporary.write_text(
json.dumps(value, ensure_ascii=False, sort_keys=True),
encoding="utf-8",
)
temporary.replace(path)
except OSError as exc:
try:
temporary.unlink(missing_ok=True)
except OSError:
pass
raise ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin settings storage cannot be written.",
status_code=500,
) from exc
def validate_settings_definition(
plugin_id: str, definition: PluginSettingsDefinition
) -> None:
if not _CONTRIBUTION_ID.fullmatch(definition.section_id):
raise _settings_schema_error(plugin_id, "Settings section id is invalid.")
if not definition.section_id.startswith(f"{plugin_id}."):
raise _settings_schema_error(
plugin_id, "Settings section id must use the Plugin namespace."
)
keys: set[str] = set()
for field in definition.fields:
if not _SETTING_KEY.fullmatch(field.key) or field.key in keys:
raise _settings_schema_error(plugin_id, f"Invalid or duplicate setting key: {field.key}")
keys.add(field.key)
if field.type == PluginSettingType.select and not field.options:
raise _settings_schema_error(plugin_id, f"Select setting requires options: {field.key}")
if field.type != PluginSettingType.select and field.options:
raise _settings_schema_error(plugin_id, f"Only select settings accept options: {field.key}")
if field.type != PluginSettingType.number and (
field.minimum is not None or field.maximum is not None
):
raise _settings_schema_error(plugin_id, f"Only number settings accept bounds: {field.key}")
if any(
bound is not None and not math.isfinite(bound)
for bound in (field.minimum, field.maximum)
):
raise _settings_schema_error(
plugin_id, f"Number setting bounds must be finite: {field.key}"
)
if field.minimum is not None and field.maximum is not None and field.minimum > field.maximum:
raise _settings_schema_error(plugin_id, f"Setting bounds are reversed: {field.key}")
if field.type == PluginSettingType.secret and field.default is not None:
raise _settings_schema_error(plugin_id, f"Secret settings cannot declare defaults: {field.key}")
if field.default is not None:
try:
_validate_setting_value(field, field.default)
except ExtensionError as exc:
raise _settings_schema_error(plugin_id, exc.message) from exc
def validate_command_spec(plugin_id: str, spec: PluginCommandSpec) -> None:
if not _CONTRIBUTION_ID.fullmatch(spec.command_id) or not spec.command_id.startswith(
f"{plugin_id}."
):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command id must be valid and use the Plugin namespace.",
details={"plugin_id": plugin_id, "command_id": spec.command_id},
)
if not spec.locations:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command must declare at least one location.",
details={"command_id": spec.command_id},
)
if len(spec.locations) != len(set(spec.locations)):
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Plugin command locations must be unique.")
if len(spec.when) != len(set(spec.when)) or len(spec.context) != len(set(spec.context)):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command when/context entries must be unique.",
)
if len(spec.secrets) != len(set(spec.secrets)):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command Secret entries must be unique.",
details={"command_id": spec.command_id},
)
unknown_when = sorted(set(spec.when) - _WHEN_TOKENS)
if unknown_when:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command declares unsupported when tokens.",
details={"command_id": spec.command_id, "when": unknown_when},
)
required_context = {_WHEN_CONTEXT[token] for token in spec.when}
if not required_context.issubset(set(spec.context)):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command context must include every field required by when.",
details={"command_id": spec.command_id},
)
if not set(spec.context).issubset(_CONTEXT_KEYS):
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Plugin command context is invalid.")
if spec.icon and spec.icon not in _HOST_ICONS:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command icon is not a supported Host icon.",
details={"command_id": spec.command_id, "icon": spec.icon},
)
if spec.parameters.get("type", "object") != "object":
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Command parameters must be an object schema.")
try:
Draft202012Validator.check_schema(spec.parameters)
reject_external_schema_references(spec.parameters)
except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc)
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
f"Plugin command parameters contain invalid JSON Schema: {message}",
) from exc
def _validate_setting_value(field: PluginSettingField, value: Any) -> None:
valid = False
if field.type == PluginSettingType.string:
valid = isinstance(value, str) and len(value.encode("utf-8")) <= 64 * 1024
elif field.type == PluginSettingType.number:
valid = (
(isinstance(value, int) and not isinstance(value, bool))
or (isinstance(value, float) and math.isfinite(value))
)
elif field.type == PluginSettingType.boolean:
valid = isinstance(value, bool)
elif field.type == PluginSettingType.select:
valid = isinstance(value, str) and value in field.options
if not valid:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
f"Plugin setting has an invalid value: {field.key}",
details={"key": field.key},
)
if field.type == PluginSettingType.number:
if field.minimum is not None and value < field.minimum:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
f"Plugin setting is below its minimum: {field.key}",
details={"key": field.key, "minimum": field.minimum},
)
if field.maximum is not None and value > field.maximum:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
f"Plugin setting is above its maximum: {field.key}",
details={"key": field.key, "maximum": field.maximum},
)
def _secret_field(
definition: PluginSettingsDefinition, plugin_id: str, key: str
) -> PluginSettingField:
field = next((item for item in definition.fields if item.key == key), None)
if field is None or field.type != PluginSettingType.secret:
raise ExtensionError(
"PLUGIN_SECRET_FIELD_NOT_FOUND",
f"Plugin secret field does not exist: {key}",
status_code=404,
details={"plugin_id": plugin_id, "key": key},
)
return field
def _secret_reference(plugin_id: str, key: str) -> str:
digest = hashlib.sha256(f"{plugin_id}\0{key}".encode("utf-8")).hexdigest()
return f"plugin.{digest}"
def _settings_schema_error(plugin_id: str, message: str) -> ExtensionError:
return ExtensionError(
"PLUGIN_SETTINGS_SCHEMA_INVALID",
message,
details={"plugin_id": plugin_id},
)
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from typing import Any
class ExtensionError(RuntimeError):
"""Extension Core 对 API 暴露的稳定领域错误。"""
def __init__(
self,
code: str,
message: str,
*,
status_code: int = 422,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.details = details or {}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+474 -24
View File
@@ -5,19 +5,40 @@ import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from uuid import uuid4
import yaml
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
from jsonschema.exceptions import (
SchemaError,
ValidationError as JsonSchemaValidationError,
)
from pydantic import (
BaseModel,
ConfigDict,
Field,
TypeAdapter,
ValidationError,
create_model,
)
from app.agent.tools import ToolExecutionContext, ToolRegistry
from app.agent.tools import ToolExecutionContext, ToolExecutionError, ToolRegistry
from app.agent.permissions import KNOWN_PERMISSIONS
from app.contracts import (
ModelCapability,
Plugin,
PluginCommand,
PluginCommandContext,
PluginCommandEffect,
PluginNoEffect,
PluginNotificationEffect,
PluginCommandLocation,
PluginCommandResult,
PluginManifest,
PluginHostStatus,
PluginSecretStatus,
PluginSettingType,
PluginSettingsSchema,
PluginStatus,
RetrievalConfig,
Skill,
@@ -25,27 +46,26 @@ from app.contracts import (
SkillStatus,
ToolDefinition,
)
from app.extensions.contributions import (
CommandRegistry,
PluginCommandSpec,
PluginSecretResolver,
PluginSettingsDefinition,
PluginSettingsStore,
validate_command_spec,
validate_settings_definition,
)
from app.extensions.errors import ExtensionError
from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool
from app.providers.credentials import EncryptedCredentialStore
from app.schema_security import (
SchemaReferenceError,
reject_external_schema_references,
)
_EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
class ExtensionError(RuntimeError):
def __init__(
self,
code: str,
message: str,
*,
status_code: int = 422,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.details = details or {}
@dataclass(frozen=True, slots=True)
class AgentConfiguration:
skill_id: str
@@ -238,14 +258,45 @@ class DeclarativePluginHost:
return {"text": str(values.get("text", "")).upper()}
raise ExtensionError("PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported handler: {handler}")
async def execute_command(
self,
handler: str,
arguments: dict[str, Any],
context: dict[str, Any],
settings: dict[str, Any],
resolve_secret: PluginSecretResolver,
) -> PluginCommandEffect:
"""执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。"""
if handler == "echo":
message = str(arguments.get("message", context.get("selection", "")))
if not message:
return PluginNoEffect()
return PluginNotificationEffect(
payload={"level": "info", "message": message},
)
if handler == "uppercase_selection":
text = str(arguments.get("text", context.get("selection", "")))
limit = int(settings.get("result_limit", 100))
return PluginNotificationEffect(
payload={"level": "success", "message": text[:limit].upper()},
)
raise ExtensionError(
"PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported command handler: {handler}"
)
@dataclass(slots=True)
class _PluginRecord:
plugin: Plugin
tools: list[DeclarativeToolSpec]
commands: list[PluginCommandSpec]
settings_definition: PluginSettingsDefinition | None
package_path: Path
registered_tools: list[str]
registered_commands: list[str]
mcp_remote_names: dict[str, str]
mcp_command_schemas: dict[str, dict[str, Any]]
class PluginRuntime:
@@ -256,12 +307,15 @@ class PluginRuntime:
tools: ToolRegistry,
host: DeclarativePluginHost | None = None,
mcp_bridge: McpBridge | None = None,
credentials: EncryptedCredentialStore | None = None,
*,
allow_unsandboxed_mcp: bool = False,
) -> None:
self.registry = tools
self.host = host or DeclarativePluginHost()
self.mcp = mcp_bridge or McpBridge()
self.commands = CommandRegistry()
self.settings = PluginSettingsStore(credentials or EncryptedCredentialStore())
self.allow_unsandboxed_mcp = allow_unsandboxed_mcp
self._records: dict[str, _PluginRecord] = {}
self._lock = threading.RLock()
@@ -287,6 +341,8 @@ class PluginRuntime:
_validate_backend(manifest)
specs = [] if manifest.backend.type == "mcp" else self._load_tools(root)
command_specs = self._load_commands(root)
settings_definition = self._load_settings(root)
if manifest.backend.type != "mcp":
declared = set(manifest.contributes.tools)
actual = {spec.name for spec in specs}
@@ -305,6 +361,89 @@ class PluginRuntime:
f"Tool permission is not declared by Plugin: {spec.permission}",
details={"tool": spec.name, "permission": spec.permission},
)
declared_commands = set(manifest.contributes.commands)
actual_commands = {spec.command_id for spec in command_specs}
if (
declared_commands != actual_commands
or len(manifest.contributes.commands) != len(declared_commands)
or len(command_specs) != len(actual_commands)
):
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"plugin.yaml command contributions must exactly match commands.yaml",
details={
"declared": sorted(declared_commands),
"actual": sorted(actual_commands),
},
)
for spec in command_specs:
validate_command_spec(manifest.plugin_id, spec)
if spec.permission and spec.permission not in manifest.permissions:
raise ExtensionError(
"PLUGIN_PERMISSION_UNDECLARED",
f"Command permission is not declared by Plugin: {spec.permission}",
details={"command": spec.command_id, "permission": spec.permission},
)
declared_sections = set(manifest.contributes.settings_sections)
actual_sections = (
{settings_definition.section_id} if settings_definition is not None else set()
)
if (
declared_sections != actual_sections
or len(manifest.contributes.settings_sections) != len(declared_sections)
):
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"plugin.yaml settings contributions must exactly match settings.yaml",
details={
"declared": sorted(declared_sections),
"actual": sorted(actual_sections),
},
)
if settings_definition is not None:
validate_settings_definition(manifest.plugin_id, settings_definition)
secret_fields = (
{
field.key
for field in settings_definition.fields
if field.type == PluginSettingType.secret
}
if settings_definition is not None
else set()
)
for spec in command_specs:
unknown_secrets = sorted(set(spec.secrets) - secret_fields)
if unknown_secrets:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command references undeclared Secret settings.",
details={
"command_id": spec.command_id,
"secrets": unknown_secrets,
},
)
if spec.secrets and "secrets.use" not in manifest.permissions:
raise ExtensionError(
"PLUGIN_PERMISSION_UNDECLARED",
"Commands using Secret settings require the secrets.use permission.",
details={"command_id": spec.command_id},
)
if spec.mcp_tool is not None:
_validate_id("MCP command target", spec.mcp_tool)
if manifest.backend.type != "mcp" or not spec.mcp_tool.startswith(
f"{manifest.plugin_id}."
):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"MCP Command target must use the current Plugin namespace.",
details={"command_id": spec.command_id},
)
if spec.mcp_tool in manifest.contributes.tools:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"MCP Command target cannot also be exposed as an Agent Tool.",
details={"command_id": spec.command_id},
)
record = _PluginRecord(
plugin=Plugin(
@@ -316,9 +455,13 @@ class PluginRuntime:
),
),
tools=specs,
commands=command_specs,
settings_definition=settings_definition,
package_path=root,
registered_tools=[],
registered_commands=[],
mcp_remote_names={},
mcp_command_schemas={},
)
self._records[manifest.plugin_id] = record
return record.plugin.model_copy(deep=True)
@@ -359,6 +502,8 @@ class PluginRuntime:
status_code=403,
details={"plugin_id": plugin_id},
)
if record.settings_definition is not None:
self.settings.runtime_values(plugin_id, record.settings_definition)
declared_tools = list(record.plugin.manifest.contributes.tools)
conflicts = [name for name in declared_tools if self.registry.contains(name)]
if conflicts:
@@ -368,20 +513,49 @@ class PluginRuntime:
status_code=409,
details={"plugin_id": plugin_id, "tools": conflicts},
)
command_conflicts = [
spec.command_id for spec in record.commands if self.commands.contains(spec.command_id)
]
if command_conflicts:
raise ExtensionError(
"PLUGIN_COMMAND_CONFLICT",
"Plugin commands are already registered.",
status_code=409,
details={"plugin_id": plugin_id, "commands": command_conflicts},
)
record.plugin.status = PluginStatus.starting
try:
if record.plugin.manifest.backend.type == "mcp":
discovered = self._start_mcp(record)
actual = {item.definition.name for item in discovered}
declared = set(declared_tools)
if actual != declared:
command_targets = {
spec.mcp_tool for spec in record.commands if spec.mcp_tool is not None
}
expected = declared | command_targets
if actual != expected:
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"Discovered MCP tools must exactly match Plugin contributions.",
details={"declared": sorted(declared), "actual": sorted(actual)},
"Discovered MCP tools must exactly match Tool and Command targets.",
details={"declared": sorted(expected), "actual": sorted(actual)},
)
for item in discovered:
if item.definition.name in declared:
self._register_mcp_tool(record, item)
else:
record.mcp_remote_names[item.definition.name] = item.remote_name
record.mcp_command_schemas[item.definition.name] = (
item.definition.parameters
)
for spec in (
command
for command in record.commands
if command.mcp_tool == item.definition.name
):
_validate_mcp_command_target_schema(
item.definition.parameters,
spec.command_id,
)
else:
for spec in record.tools:
arguments_model = _arguments_model(spec)
@@ -405,12 +579,135 @@ class PluginRuntime:
executor,
)
record.registered_tools.append(spec.name)
for spec in record.commands:
async def command_executor(
arguments: dict[str, Any],
context: dict[str, Any],
_spec: PluginCommandSpec = spec,
_record: _PluginRecord = record,
) -> PluginCommandEffect:
if (
not _record.plugin.enabled
or _record.plugin.status != PluginStatus.ready
):
raise ExtensionError(
"PLUGIN_COMMAND_NOT_FOUND",
"Plugin command is not available while its Plugin is inactive.",
status_code=404,
details={"command_id": _spec.command_id},
)
settings = (
self.settings.runtime_values(
_record.plugin.manifest.plugin_id,
_record.settings_definition,
)
if _record.settings_definition is not None
else {}
)
def resolve_secret(key: str) -> str | None:
if key not in _spec.secrets:
raise ExtensionError(
"PLUGIN_SECRET_ACCESS_DENIED",
"Command cannot access an undeclared Plugin Secret.",
status_code=403,
details={
"command_id": _spec.command_id,
"key": key,
},
)
if "secrets.use" not in _record.plugin.granted_permissions:
raise ExtensionError(
"PLUGIN_SECRET_ACCESS_DENIED",
"Plugin no longer has permission to access Secret settings.",
status_code=403,
details={"command_id": _spec.command_id, "key": key},
)
if _record.settings_definition is None:
return None
value = self.settings.resolve_secret(
_record.plugin.manifest.plugin_id,
_record.settings_definition,
key,
)
field = next(
item
for item in _record.settings_definition.fields
if item.key == key
)
if field.required and value is None:
raise ExtensionError(
"PLUGIN_SECRET_REQUIRED",
"A required Plugin Secret has not been configured.",
status_code=409,
details={"command_id": _spec.command_id, "key": key},
)
return value
if _spec.mcp_tool is not None:
remote_name = _record.mcp_remote_names[_spec.mcp_tool]
secret_values = {
key: value
for key in _spec.secrets
if (value := resolve_secret(key)) is not None
}
envelope = _mcp_command_envelope(
_spec,
arguments=arguments,
context=context,
settings=settings,
secrets=secret_values,
)
_validate_mcp_command_envelope(
_record.mcp_command_schemas[_spec.mcp_tool],
envelope,
_spec.command_id,
)
try:
effect = await self.mcp.call_tool(
_record.plugin.manifest.plugin_id,
remote_name,
envelope,
request_id=f"command:{uuid4().hex}",
)
except ToolExecutionError as exc:
raise ExtensionError(
exc.code,
"MCP Command target execution failed.",
status_code=502,
details={"command_id": _spec.command_id},
) from exc
try:
return TypeAdapter(PluginCommandEffect).validate_python(effect)
except ValidationError as exc:
raise ExtensionError(
"PLUGIN_COMMAND_RESULT_INVALID",
"MCP Command target returned an invalid effect.",
status_code=502,
details={"command_id": _spec.command_id},
) from exc
return await self.host.execute_command(
_spec.handler,
arguments,
context,
settings,
resolve_secret,
)
self.commands.register(plugin_id, spec, command_executor)
record.registered_commands.append(spec.command_id)
except Exception as exc:
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
for command_id in record.registered_commands:
self.commands.unregister(command_id)
record.registered_commands.clear()
record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
self.mcp.stop(plugin_id)
record.plugin.status = PluginStatus.error
record.plugin.error_message = _safe_extension_message(exc)
@@ -468,7 +765,11 @@ class PluginRuntime:
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
for command_id in record.registered_commands:
self.commands.unregister(command_id)
record.registered_commands.clear()
record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
if record.plugin.manifest.backend.type == "mcp":
self.mcp.stop(plugin_id)
record.plugin.enabled = False
@@ -479,6 +780,43 @@ class PluginRuntime:
record = self._record(plugin_id)
return self.mcp.status(plugin_id, record.plugin.manifest.backend)
def list_commands(
self, location: PluginCommandLocation | None = None
) -> list[PluginCommand]:
return self.commands.list(location)
async def execute_command(
self,
command_id: str,
arguments: dict[str, Any],
context: PluginCommandContext,
) -> PluginCommandResult:
return await self.commands.execute(command_id, arguments, context)
def get_settings(self, plugin_id: str) -> PluginSettingsSchema:
record = self._record(plugin_id)
definition = self._settings_definition(record)
return self.settings.get(plugin_id, definition)
def update_settings(
self, plugin_id: str, schema_version: int, values: dict[str, Any]
) -> PluginSettingsSchema:
record = self._record(plugin_id)
definition = self._settings_definition(record)
return self.settings.update(plugin_id, definition, schema_version, values)
def put_setting_secret(
self, plugin_id: str, key: str, secret: str
) -> PluginSecretStatus:
record = self._record(plugin_id)
definition = self._settings_definition(record)
return self.settings.put_secret(plugin_id, definition, key, secret)
def delete_setting_secret(self, plugin_id: str, key: str) -> PluginSecretStatus:
record = self._record(plugin_id)
definition = self._settings_definition(record)
return self.settings.delete_secret(plugin_id, definition, key)
def restart_host(self, plugin_id: str) -> PluginHostStatus:
with self._lock:
return self._restart_host(plugin_id)
@@ -506,7 +844,11 @@ class PluginRuntime:
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
for command_id in record.registered_commands:
self.commands.unregister(command_id)
record.registered_commands.clear()
record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
self.mcp.stop(plugin_id)
record.plugin.enabled = False
record.plugin.status = PluginStatus.installed
@@ -567,7 +909,11 @@ class PluginRuntime:
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
for command_id in record.registered_commands:
self.commands.unregister(command_id)
record.registered_commands.clear()
record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
record.plugin.enabled = False
record.plugin.status = PluginStatus.error
record.plugin.error_message = message
@@ -594,6 +940,7 @@ class PluginRuntime:
# stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同
# 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。
self.mcp.remove(plugin_id)
self.settings.remove_plugin(plugin_id)
del self._records[plugin_id]
def _record(self, plugin_id: str) -> _PluginRecord:
@@ -615,6 +962,52 @@ class PluginRuntime:
except ValidationError as exc:
raise _manifest_error("plugin tool", exc) from exc
@staticmethod
def _load_commands(root: Path) -> list[PluginCommandSpec]:
path = root / "commands.yaml"
if not path.exists():
return []
raw = _read_yaml(path)
items = raw.get("commands", [])
if not isinstance(items, list):
raise ExtensionError(
"EXTENSION_MANIFEST_INVALID",
"Invalid plugin command manifest: commands must be an array.",
)
try:
return [
PluginCommandSpec.model_validate(item)
for item in items
]
except ValidationError as exc:
raise _manifest_error("plugin command", exc) from exc
@staticmethod
def _load_settings(root: Path) -> PluginSettingsDefinition | None:
path = root / "settings.yaml"
if not path.exists():
return None
raw = _read_yaml(path)
try:
return PluginSettingsDefinition.model_validate(raw)
except ValidationError as exc:
raise ExtensionError(
"PLUGIN_SETTINGS_SCHEMA_INVALID",
"Invalid Plugin settings schema.",
details={"errors": exc.errors(include_url=False)},
) from exc
@staticmethod
def _settings_definition(record: _PluginRecord) -> PluginSettingsDefinition:
if record.settings_definition is None:
raise ExtensionError(
"PLUGIN_SETTINGS_NOT_FOUND",
"Plugin does not contribute a Settings section.",
status_code=404,
details={"plugin_id": record.plugin.manifest.plugin_id},
)
return record.settings_definition
def _package_dir(package_path: str | Path) -> Path:
root = Path(package_path).expanduser().resolve()
@@ -673,6 +1066,61 @@ def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]:
return _arguments_model_from_schema(spec.name, schema)
def _mcp_command_envelope(
spec: PluginCommandSpec,
*,
arguments: dict[str, Any],
context: dict[str, Any],
settings: dict[str, Any],
secrets: dict[str, str],
) -> dict[str, Any]:
return {
"_notesagent": {
"command_id": spec.command_id,
"arguments": arguments,
"context": context,
"settings": settings,
"secrets": secrets,
}
}
def _validate_mcp_command_envelope(
schema: dict[str, Any],
envelope: dict[str, Any],
command_id: str,
) -> None:
"""执行前用目标 Tool Schema 校验包含真实业务数据的宿主信封。"""
try:
Draft202012Validator(schema).validate(envelope)
except JsonSchemaValidationError as exc:
raise ExtensionError(
"PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH",
"MCP Command envelope does not match the target inputSchema.",
status_code=502,
details={"command_id": command_id, "path": list(exc.path)},
) from exc
def _validate_mcp_command_target_schema(
schema: dict[str, Any], command_id: str
) -> None:
"""启用时只检查稳定信封入口,避免用伪造业务值误判合法 Schema。"""
properties = schema.get("properties")
envelope_schema = (
properties.get("_notesagent") if isinstance(properties, dict) else None
)
if not isinstance(envelope_schema, dict) or envelope_schema.get("type") != "object":
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"MCP Command target inputSchema must directly declare "
"_notesagent with type object.",
details={"command_id": command_id},
)
def _arguments_model_from_schema(
tool_name: str, schema: dict[str, Any]
) -> type[BaseModel]:
@@ -688,10 +1136,12 @@ def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
schema = spec.parameters or {"type": "object", "properties": {}}
try:
Draft202012Validator.check_schema(schema)
except SchemaError as exc:
reject_external_schema_references(schema)
except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc)
raise ExtensionError(
"PLUGIN_TOOL_SCHEMA_INVALID",
f"Invalid JSON Schema for tool {spec.name}: {exc.message}",
f"Invalid JSON Schema for tool {spec.name}: {message}",
details={"tool": spec.name},
) from exc
if schema.get("type", "object") != "object" or not isinstance(
+1
View File
@@ -19,6 +19,7 @@ async def lifespan(_: FastAPI):
yield
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
container.plugins.shutdown()
container.mcp_servers.shutdown()
app = FastAPI(
+84 -9
View File
@@ -5,14 +5,15 @@ import os
import re
import threading
from pathlib import Path
from typing import Protocol
from typing import ClassVar, Protocol
from cryptography.fernet import Fernet, InvalidToken
from app.config import get_settings
_CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_PLUGIN_CREDENTIAL_PREFIX = "plugin."
_MCP_CREDENTIAL_PREFIX = "mcp."
class CredentialStoreError(RuntimeError):
@@ -23,10 +24,21 @@ class CredentialResolver(Protocol):
def resolve(self, credential_id: str | None) -> str | None: ...
def validate_provider_credential_id(credential_id: str | None) -> None:
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
if credential_id and credential_id.casefold().startswith(_PLUGIN_CREDENTIAL_PREFIX):
raise CredentialStoreError(
"Credential namespace is reserved for Plugin settings."
)
if credential_id and credential_id.casefold().startswith(_MCP_CREDENTIAL_PREFIX):
raise CredentialStoreError("Credential namespace is reserved for MCP settings.")
class EnvironmentCredentialResolver:
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
_development_aliases = {
_development_aliases: ClassVar[dict[str, str]] = {
"openai": "OPENAI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
}
@@ -74,7 +86,9 @@ class EncryptedCredentialStore:
try:
return Fernet(environment_key.encode("ascii"))
except (ValueError, UnicodeEncodeError) as exc:
raise CredentialStoreError("APP_CREDENTIAL_MASTER_KEY is invalid.") from exc
raise CredentialStoreError(
"APP_CREDENTIAL_MASTER_KEY is invalid."
) from exc
key_path.parent.mkdir(parents=True, exist_ok=True)
self._restrict(key_path.parent, 0o700)
@@ -91,7 +105,9 @@ class EncryptedCredentialStore:
try:
return Fernet(key_path.read_bytes().strip())
except (OSError, ValueError) as exc:
raise CredentialStoreError("Credential master key cannot be loaded.") from exc
raise CredentialStoreError(
"Credential master key cannot be loaded."
) from exc
def _read_tokens(self) -> dict[str, str]:
_, store_path = self._paths()
@@ -100,18 +116,24 @@ class EncryptedCredentialStore:
try:
data = json.loads(store_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise CredentialStoreError("Encrypted credential store cannot be loaded.") from exc
raise CredentialStoreError(
"Encrypted credential store cannot be loaded."
) from exc
if not isinstance(data, dict) or not all(
isinstance(key, str) and isinstance(value, str) for key, value in data.items()
isinstance(key, str) and isinstance(value, str)
for key, value in data.items()
):
raise CredentialStoreError("Encrypted credential store has an invalid format.")
raise CredentialStoreError(
"Encrypted credential store has an invalid format."
)
return data
def _write_tokens(self, tokens: dict[str, str]) -> None:
_, store_path = self._paths()
temporary = store_path.with_suffix(".tmp")
try:
store_path.parent.mkdir(parents=True, exist_ok=True)
self._restrict(store_path.parent, 0o700)
temporary = store_path.with_suffix(".tmp")
temporary.write_text(
json.dumps(tokens, ensure_ascii=True, sort_keys=True),
encoding="utf-8",
@@ -120,6 +142,14 @@ class EncryptedCredentialStore:
# 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。
temporary.replace(store_path)
self._restrict(store_path, 0o600)
except OSError as exc:
try:
temporary.unlink(missing_ok=True)
except OSError:
pass
raise CredentialStoreError(
"Encrypted credential store cannot be written."
) from exc
def put(self, credential_id: str, secret: str) -> None:
self._validate_id(credential_id)
@@ -158,6 +188,40 @@ class EncryptedCredentialStore:
self._write_tokens(tokens)
return removed
def delete_many(self, credential_ids: list[str]) -> set[str]:
"""用一次原子替换删除多个凭据,避免插件卸载只删除部分 Secret。"""
for credential_id in credential_ids:
self._validate_id(credential_id)
with self._lock:
tokens = self._read_tokens()
removed = {
credential_id
for credential_id in credential_ids
if credential_id in tokens
}
if removed:
for credential_id in removed:
del tokens[credential_id]
self._write_tokens(tokens)
return removed
def move_many(self, replacements: dict[str, str]) -> None:
"""原子迁移凭据 ID,直接移动密文且不覆盖已经写入的新凭据。"""
for old_id, new_id in replacements.items():
self._validate_id(old_id)
self._validate_id(new_id)
with self._lock:
tokens = self._read_tokens()
changed = False
for old_id, new_id in replacements.items():
if old_id != new_id and old_id in tokens:
tokens.setdefault(new_id, tokens.pop(old_id))
changed = True
if changed:
self._write_tokens(tokens)
class ChainedCredentialResolver:
def __init__(self, *resolvers: CredentialResolver) -> None:
@@ -170,3 +234,14 @@ class ChainedCredentialResolver:
if value:
return value
return None
class ProviderCredentialResolver:
"""Provider 专用防御层,避免配置绕过 HTTP 校验读取 Plugin Secret。"""
def __init__(self, delegate: CredentialResolver) -> None:
self._delegate = delegate
def resolve(self, credential_id: str | None) -> str | None:
validate_provider_credential_id(credential_id)
return self._delegate.resolve(credential_id)
+4 -2
View File
@@ -1,6 +1,6 @@
from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType
from app.providers.base import ModelProvider
from app.providers.credentials import CredentialResolver
from app.providers.credentials import CredentialResolver, ProviderCredentialResolver
from app.providers.ollama import OllamaProvider
from app.providers.openai_compatible import OpenAICompatibleProvider
@@ -11,7 +11,9 @@ class UnsupportedProviderError(ValueError):
class ProviderFactory:
def __init__(self, credentials: CredentialResolver) -> None:
self.credentials = credentials
# ProviderFactory 是所有可配置 Provider 的创建边界,在此统一禁止
# Provider 借用 Plugin Secret 引用,避免调用方漏包安全 Resolver。
self.credentials = ProviderCredentialResolver(credentials)
def build(self, config: ProviderConfig) -> ModelProvider:
if config.provider_type in {
+280 -20
View File
@@ -6,6 +6,8 @@ from uuid import uuid4
from fastapi import APIRouter, Header, Query
from fastapi.responses import StreamingResponse
from app.agent import AgentCapacityError, AgentRunNotFoundError
from app.container import container
from app.contracts import (
AgentRun,
AgentRunCreateRequest,
@@ -29,6 +31,14 @@ from app.contracts import (
IndexJob,
IndexRebuildRequest,
IndexStatus,
McpServer,
McpServerCreateRequest,
McpServerListResponse,
McpServerSecretStatus,
McpServerSecretWriteRequest,
McpServerTrustRequest,
McpServerUpdateRequest,
McpToolSummaryListResponse,
ModelEvent,
ModelEventType,
Note,
@@ -41,9 +51,17 @@ from app.contracts import (
PageMeta,
PermissionDecisionRequest,
Plugin,
PluginCommandExecuteRequest,
PluginCommandListResponse,
PluginCommandLocation,
PluginCommandResult,
PluginHostStatus,
PluginListResponse,
PluginPermissionGrantRequest,
PluginSecretStatus,
PluginSecretWriteRequest,
PluginSettingsSchema,
PluginSettingsUpdateRequest,
ProviderConfig,
ProviderCreateRequest,
ProviderListResponse,
@@ -74,10 +92,14 @@ from app.benchmarks import service as benchmark_service
from app.container import container
from app.errors import ApiError
from app.extensions import ExtensionError
from app.providers.registry import ProviderNotFoundError
from app.providers.factory import UnsupportedProviderError
from app.extensions.mcp_registry import McpRegistryError
from app.providers.base import ProviderError
from app.providers.credentials import CredentialStoreError
from app.providers.credentials import (
CredentialStoreError,
validate_provider_credential_id,
)
from app.providers.factory import UnsupportedProviderError
from app.providers.registry import ProviderNotFoundError
from app.retrieval.engine import engine
from app.services import (
index_service,
@@ -90,10 +112,25 @@ from app.services import (
router = APIRouter(prefix="/api")
async def mcp_call_async(operation):
"""Even registry reads can wait on lifecycle locks; keep all MCP work off the event loop."""
try:
return await asyncio.to_thread(operation)
except McpRegistryError as exc:
raise ApiError(exc.status_code, exc.code, exc.message) from exc
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def validate_public_credential_id(credential_id: str | None) -> None:
try:
validate_provider_credential_id(credential_id)
except CredentialStoreError as exc:
raise ApiError(422, "CREDENTIAL_NAMESPACE_RESERVED", str(exc)) from exc
def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str:
id_line = f"id: {event_id}\n" if event_id is not None else ""
return f"{id_line}event: {event}\ndata: {payload}\n\n"
@@ -194,14 +231,21 @@ async def list_notes(
folder: str | None = None,
tag: str | None = None,
) -> NoteListResponse:
items, total = note_service.list_notes(limit=limit, offset=offset, folder=folder, tag=tag)
return NoteListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset))
items, total = note_service.list_notes(
limit=limit, offset=offset, folder=folder, tag=tag
)
return NoteListResponse(
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
)
@router.post("/notes", response_model=Note, tags=["Notes"])
async def create_note(request: NoteCreateRequest) -> Note:
return await note_service.create_note(
title=request.title, markdown=request.markdown, folder=request.folder, tags=request.tags
title=request.title,
markdown=request.markdown,
folder=request.folder,
tags=request.tags,
)
@@ -209,7 +253,9 @@ async def create_note(request: NoteCreateRequest) -> Note:
async def get_note(note_id: str) -> Note:
note = await note_service.get_note(note_id)
if note is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
raise ApiError(
404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id}
)
return note
@@ -223,7 +269,9 @@ async def update_note(note_id: str, request: NoteUpdateRequest) -> Note:
@router.delete("/notes/{note_id}", response_model=OperationResponse, tags=["Notes"])
async def delete_note(note_id: str) -> OperationResponse:
if not await note_service.delete_note(note_id):
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
raise ApiError(
404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id}
)
return OperationResponse(status="completed", resource_id=note_id, message="deleted")
@@ -267,7 +315,9 @@ async def chat(request: ChatRequest) -> StreamingResponse:
data={"code": "PROVIDER_ERROR", "message": str(exc)},
timestamp=utc_now(),
)
done = ModelEvent(event=ModelEventType.done, sequence=1, timestamp=utc_now())
done = ModelEvent(
event=ModelEventType.done, sequence=1, timestamp=utc_now()
)
yield as_sse(error.event.value, error.model_dump_json())
yield as_sse(done.event.value, done.model_dump_json())
@@ -428,9 +478,7 @@ async def list_skills() -> SkillListResponse:
return SkillListResponse(items=container.skills.list())
@router.get(
"/skills/{skill_id}", response_model=Skill, tags=["Skills"]
)
@router.get("/skills/{skill_id}", response_model=Skill, tags=["Skills"])
async def get_skill(skill_id: str) -> Skill:
return extension_call(lambda: container.skills.get(skill_id))
@@ -470,7 +518,120 @@ async def disable_skill(skill_id: str) -> Skill:
)
async def uninstall_skill(skill_id: str) -> OperationResponse:
extension_call(lambda: container.skills.uninstall(skill_id))
return OperationResponse(status="completed", resource_id=skill_id, message="uninstalled")
return OperationResponse(
status="completed", resource_id=skill_id, message="uninstalled"
)
# Independent MCP Server Registry
@router.get("/mcp/servers", response_model=McpServerListResponse, tags=["MCP Servers"])
async def list_mcp_servers() -> McpServerListResponse:
return McpServerListResponse(items=await mcp_call_async(container.mcp_servers.list))
@router.post(
"/mcp/servers", response_model=McpServer, status_code=201, tags=["MCP Servers"]
)
async def create_mcp_server(request: McpServerCreateRequest) -> McpServer:
return await mcp_call_async(lambda: container.mcp_servers.create(request))
@router.get("/mcp/servers/{server_id}", response_model=McpServer, tags=["MCP Servers"])
async def get_mcp_server(server_id: str) -> McpServer:
return await mcp_call_async(lambda: container.mcp_servers.get(server_id))
@router.get(
"/mcp/servers/{server_id}/tools",
response_model=McpToolSummaryListResponse,
tags=["MCP Servers"],
)
async def list_mcp_server_tools(server_id: str) -> McpToolSummaryListResponse:
return McpToolSummaryListResponse(
items=await mcp_call_async(lambda: container.mcp_servers.list_tools(server_id))
)
@router.put("/mcp/servers/{server_id}", response_model=McpServer, tags=["MCP Servers"])
async def update_mcp_server(
server_id: str, request: McpServerUpdateRequest
) -> McpServer:
return await mcp_call_async(
lambda: container.mcp_servers.update(server_id, request)
)
@router.delete(
"/mcp/servers/{server_id}", response_model=OperationResponse, tags=["MCP Servers"]
)
async def delete_mcp_server(server_id: str) -> OperationResponse:
await mcp_call_async(lambda: container.mcp_servers.delete(server_id))
return OperationResponse(
status="completed", resource_id=server_id, message="deleted"
)
@router.post(
"/mcp/servers/{server_id}/trust", response_model=McpServer, tags=["MCP Servers"]
)
async def trust_mcp_server(server_id: str, request: McpServerTrustRequest) -> McpServer:
return await mcp_call_async(
lambda: container.mcp_servers.trust(server_id, request.command_digest)
)
@router.post(
"/mcp/servers/{server_id}/test", response_model=McpServer, tags=["MCP Servers"]
)
async def test_mcp_server(server_id: str) -> McpServer:
return await mcp_call_async(lambda: container.mcp_servers.test(server_id))
@router.post(
"/mcp/servers/{server_id}/enable", response_model=McpServer, tags=["MCP Servers"]
)
async def enable_mcp_server(server_id: str) -> McpServer:
return await mcp_call_async(lambda: container.mcp_servers.enable(server_id))
@router.post(
"/mcp/servers/{server_id}/disable", response_model=McpServer, tags=["MCP Servers"]
)
async def disable_mcp_server(server_id: str) -> McpServer:
return await mcp_call_async(lambda: container.mcp_servers.disable(server_id))
@router.put(
"/mcp/servers/{server_id}/secrets/{key}",
response_model=McpServerSecretStatus,
tags=["MCP Servers"],
)
async def put_mcp_server_secret(
server_id: str,
key: str,
request: McpServerSecretWriteRequest,
kind: str = Query(default="environment", pattern="^(environment|header)$"),
) -> McpServerSecretStatus:
return await mcp_call_async(
lambda: container.mcp_servers.put_secret(
server_id, key, request.secret.get_secret_value(), kind=kind
)
)
@router.delete(
"/mcp/servers/{server_id}/secrets/{key}",
response_model=McpServerSecretStatus,
tags=["MCP Servers"],
)
async def delete_mcp_server_secret(
server_id: str,
key: str,
kind: str = Query(default="environment", pattern="^(environment|header)$"),
) -> McpServerSecretStatus:
return await mcp_call_async(
lambda: container.mcp_servers.delete_secret(server_id, key, kind=kind)
)
# Plugins
@@ -562,11 +723,93 @@ async def restart_plugin_host(plugin_id: str) -> OperationResponse:
)
async def uninstall_plugin(plugin_id: str) -> OperationResponse:
plugin = extension_call(lambda: container.plugins.get(plugin_id))
dependent_skills = container.skills.depending_on_tools(plugin.manifest.contributes.tools)
dependent_skills = container.skills.depending_on_tools(
plugin.manifest.contributes.tools
)
await extension_call_async(
lambda: container.plugins.uninstall(plugin_id, dependent_skills)
)
return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled")
return OperationResponse(
status="completed", resource_id=plugin_id, message="uninstalled"
)
# Plugin Command / Settings Contributions
@router.get(
"/plugin-contributions/commands",
response_model=PluginCommandListResponse,
tags=["Plugins"],
)
async def list_plugin_commands(
location: PluginCommandLocation | None = Query(default=None),
) -> PluginCommandListResponse:
return PluginCommandListResponse(items=container.plugins.list_commands(location))
@router.post(
"/plugin-contributions/commands/{command_id}/execute",
response_model=PluginCommandResult,
tags=["Plugins"],
)
async def execute_plugin_command(
command_id: str, request: PluginCommandExecuteRequest
) -> PluginCommandResult:
try:
return await container.plugins.execute_command(
command_id, request.arguments, request.context
)
except ExtensionError as exc:
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
@router.get(
"/plugins/{plugin_id}/settings",
response_model=PluginSettingsSchema,
tags=["Plugins"],
)
async def get_plugin_settings(plugin_id: str) -> PluginSettingsSchema:
return extension_call(lambda: container.plugins.get_settings(plugin_id))
@router.put(
"/plugins/{plugin_id}/settings",
response_model=PluginSettingsSchema,
tags=["Plugins"],
)
async def update_plugin_settings(
plugin_id: str, request: PluginSettingsUpdateRequest
) -> PluginSettingsSchema:
return extension_call(
lambda: container.plugins.update_settings(
plugin_id, request.schema_version, request.values
)
)
@router.put(
"/plugins/{plugin_id}/settings/{key}/secret",
response_model=PluginSecretStatus,
tags=["Plugins"],
)
async def put_plugin_setting_secret(
plugin_id: str, key: str, request: PluginSecretWriteRequest
) -> PluginSecretStatus:
return extension_call(
lambda: container.plugins.put_setting_secret(
plugin_id, key, request.secret.get_secret_value()
)
)
@router.delete(
"/plugins/{plugin_id}/settings/{key}/secret",
response_model=PluginSecretStatus,
tags=["Plugins"],
)
async def delete_plugin_setting_secret(plugin_id: str, key: str) -> PluginSecretStatus:
return extension_call(
lambda: container.plugins.delete_setting_secret(plugin_id, key)
)
# Providers
@@ -576,6 +819,7 @@ async def uninstall_plugin(plugin_id: str) -> OperationResponse:
tags=["Providers"],
)
async def get_credential_status(credential_id: str) -> CredentialStatus:
validate_public_credential_id(credential_id)
try:
configured = container.credentials.has(credential_id)
except CredentialStoreError as exc:
@@ -591,6 +835,7 @@ async def get_credential_status(credential_id: str) -> CredentialStatus:
async def put_credential(
credential_id: str, request: CredentialWriteRequest
) -> CredentialStatus:
validate_public_credential_id(credential_id)
try:
container.credentials.put(credential_id, request.api_key.get_secret_value())
except CredentialStoreError as exc:
@@ -604,6 +849,7 @@ async def put_credential(
tags=["Providers"],
)
async def delete_credential(credential_id: str) -> CredentialStatus:
validate_public_credential_id(credential_id)
try:
container.credentials.delete(credential_id)
except CredentialStoreError as exc:
@@ -640,6 +886,7 @@ async def get_provider(provider_id: str) -> ProviderConfig:
tags=["Providers"],
)
async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
validate_public_credential_id(request.credential_id)
config = ProviderConfig(
provider_id=f"provider_{uuid4().hex}",
provider_type=request.provider_type,
@@ -672,7 +919,9 @@ async def update_provider(
) -> ProviderConfig:
current = configurable_provider_or_404(provider_id).config
if provider_id == "mock":
raise ApiError(409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified.")
raise ApiError(
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified."
)
fields = request.model_fields_set
if ("name" in fields and request.name is None) or (
"enabled" in fields and request.enabled is None
@@ -683,6 +932,8 @@ async def update_provider(
"name and enabled cannot be null when explicitly provided.",
)
updates = {name: getattr(request, name) for name in fields}
if "credential_id" in fields:
validate_public_credential_id(request.credential_id)
config = ProviderConfig.model_validate(
{**current.model_dump(mode="python"), **updates}
)
@@ -699,7 +950,9 @@ async def update_provider(
async def delete_provider(provider_id: str) -> OperationResponse:
configurable_provider_or_404(provider_id)
if provider_id == "mock":
raise ApiError(409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be deleted.")
raise ApiError(
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be deleted."
)
container.providers.unregister(provider_id)
return OperationResponse(status="completed", resource_id=provider_id)
@@ -742,6 +995,7 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
registered = configurable_provider_or_404(request.provider_id)
if request.credential_context_id:
validate_public_credential_id(request.credential_context_id)
temporary_config = registered.config.model_copy(
update={"credential_id": request.credential_context_id, "enabled": True}
)
@@ -776,7 +1030,9 @@ async def create_task(request: TaskCreateRequest) -> Task:
async def get_task(task_id: str) -> Task:
task = task_service.get_task(task_id)
if task is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
raise ApiError(
404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id}
)
return task
@@ -792,7 +1048,9 @@ async def update_task(task_id: str, request: TaskUpdateRequest) -> Task:
)
async def delete_task(task_id: str) -> OperationResponse:
if not task_service.delete_task(task_id):
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
raise ApiError(
404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id}
)
return OperationResponse(status="completed", resource_id=task_id, message="deleted")
@@ -842,7 +1100,9 @@ async def rebuild_index(request: IndexRebuildRequest) -> IndexJob:
async def get_index_job(job_id: str) -> IndexJob:
job = index_service.get_job(job_id)
if job is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "index job not found", {"job_id": job_id})
raise ApiError(
404, "RESOURCE_NOT_FOUND", "index job not found", {"job_id": job_id}
)
return job
+60
View File
@@ -0,0 +1,60 @@
"""共享 JSON Schema 安全约束。"""
from __future__ import annotations
from typing import Any
from urllib.parse import urljoin
from referencing import Registry
from referencing.exceptions import Unresolvable
from referencing.jsonschema import DRAFT202012
_SCHEMA_BASE_URI = "https://notesagent.invalid/local-schema"
class SchemaReferenceError(ValueError):
"""Schema 引用不符合宿主的离线、文档内解析约束。"""
class ExternalSchemaReferenceError(SchemaReferenceError):
def __init__(self, keyword: str, reference: Any) -> None:
super().__init__(f"External JSON Schema reference is not allowed: {reference!r}")
self.keyword = keyword
self.reference = reference
class UnresolvableLocalSchemaReferenceError(SchemaReferenceError):
def __init__(self, reference: str) -> None:
super().__init__(f"Local JSON Schema reference cannot be resolved: {reference!r}")
self.reference = reference
def reject_external_schema_references(schema: Any) -> None:
"""只允许可解析的文档内 Fragment,并按 JSON Schema Resource 作用域解析。"""
root = DRAFT202012.create_resource(schema)
root_uri = urljoin(_SCHEMA_BASE_URI, root.id() or "")
registry = Registry().with_resource(_SCHEMA_BASE_URI, root).crawl()
resolver = registry.resolver(root_uri)
_validate_resource_references(root, resolver)
def _validate_resource_references(resource, resolver: Any) -> None:
contents = resource.contents
if isinstance(contents, dict):
for keyword in ("$ref", "$dynamicRef"):
if keyword not in contents:
continue
reference = contents[keyword]
if not isinstance(reference, str) or not reference.startswith("#"):
raise ExternalSchemaReferenceError(keyword, reference)
try:
resolver.lookup(reference)
except Unresolvable as exc:
raise UnresolvableLocalSchemaReferenceError(reference) from exc
for subresource in resource.subresources():
_validate_resource_references(
subresource,
resolver.in_subresource(subresource),
)
@@ -0,0 +1,20 @@
commands:
- command_id: mcp-fixture.notify
title: MCP 通知
description: 通过隔离 MCP Host 返回宿主白名单通知 effect。
icon: bolt
locations:
- command_palette
when:
- editor.has_selection
context:
- selection
secrets:
- api_key
mcp_tool: mcp-fixture.command
parameters:
type: object
properties:
message:
type: string
additionalProperties: false
@@ -1,9 +1,10 @@
id: mcp-fixture
name: MCP Fixture
version: 1.0.0
description: 阶段 C 离线联调 Fixture,覆盖 MCP Tool 生命周期与错误边界。
description: 阶段 C/D 离线联调 Fixture,覆盖 MCP Tool、Command 与错误边界。
permissions:
- notes.read
- secrets.use
contributes:
tools:
- mcp-fixture.echo
@@ -12,6 +13,10 @@ contributes:
- mcp-fixture.large
- mcp-fixture.environment
- mcp-fixture.exit
commands:
- mcp-fixture.notify
settings_sections:
- mcp-fixture.general
backend:
type: mcp
transport: stdio
+45 -1
View File
@@ -54,6 +54,11 @@ TOOLS = {
"large": tool("large", "Return a result larger than the host limit."),
"environment": tool("environment", "Report whether host secrets leaked into the process."),
"exit": tool("exit", "Terminate the fixture process."),
"command": tool(
"command",
"Execute a NotesAgent Plugin Command envelope.",
{"_notesagent": {"type": "object"}},
),
}
# suffix 是可选字段,用于验证 Host 不会把缺省值擅自补成 null。
TOOLS["echo"]["inputSchema"]["required"] = ["text"]
@@ -62,6 +67,38 @@ TOOLS["echo"]["inputSchema"]["required"] = ["text"]
def call_tool(request_id: int, params: dict[str, Any]) -> None:
name = params.get("name")
arguments = params.get("arguments") or {}
if name == "command":
envelope = arguments.get("_notesagent") or {}
command_arguments = envelope.get("arguments") or {}
context = envelope.get("context") or {}
settings = envelope.get("settings") or {}
secrets = envelope.get("secrets") or {}
if not isinstance(secrets.get("api_key"), str):
respond(
request_id,
{
"content": [{"type": "text", "text": "declared secret missing"}],
"isError": True,
},
)
return
message = command_arguments.get("message") or context.get("selection") or ""
message = f"{settings.get('message_prefix', '')}{message}"
respond(
request_id,
{
"content": [{"type": "text", "text": "command completed"}],
"structuredContent": {
"type": "notification",
"payload": {
"level": "success",
"message": str(message),
},
},
"isError": False,
},
)
return
if name == "echo":
text = str(arguments.get("text", ""))
structured_content = {"echo": text}
@@ -183,7 +220,14 @@ def main() -> None:
elif params.get("cursor") == "page-2":
respond(
request_id,
{"tools": [TOOLS["large"], TOOLS["environment"], TOOLS["exit"]]},
{
"tools": [
TOOLS["large"],
TOOLS["environment"],
TOOLS["exit"],
TOOLS["command"],
]
},
)
else:
respond(
@@ -0,0 +1,11 @@
section_id: mcp-fixture.general
schema_version: 1
fields:
- key: message_prefix
label: Message Prefix
type: string
default: ""
- key: api_key
label: Fixture API Key
type: secret
required: true
@@ -0,0 +1,19 @@
commands:
- command_id: text-tools.uppercase-selection
title: 转为大写
description: 将当前选区或传入文本转换为大写并显示通知。
icon: edit
locations:
- command_palette
- context_menu
when:
- editor.has_selection
context:
- selection
handler: uppercase_selection
parameters:
type: object
properties:
text:
type: string
additionalProperties: false
@@ -6,6 +6,10 @@ permissions: []
contributes:
tools:
- text.uppercase
commands:
- text-tools.uppercase-selection
settings_sections:
- text-tools.general
backend:
type: internal_rpc
transport: none
@@ -0,0 +1,31 @@
section_id: text-tools.general
schema_version: 1
fields:
- key: result_limit
label: 结果字符数
description: Command 通知中最多保留的字符数。
type: number
required: true
default: 100
minimum: 1
maximum: 1000
- key: label_prefix
label: 标签前缀
type: string
default: ""
- key: output_style
label: 输出样式
type: select
default: notification
options:
- notification
- compact
- key: enabled_hint
label: 显示提示
type: boolean
default: true
- key: api_key
label: API Key
description: Secret 示例字段;普通 Settings API 永不返回明文。
type: secret
required: false
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"httpx>=0.28,<1.0",
"jsonschema>=4.25,<5.0",
"pyyaml>=6.0,<7.0",
"referencing>=0.36,<1.0",
"sqlite-vec>=0.1.9",
"uvicorn[standard]>=0.35,<1.0",
]
+250 -23
View File
@@ -1,26 +1,12 @@
import asyncio
import threading
from types import SimpleNamespace
import pytest
from app.main import health, service_status
from app.routes import (
get_index_status,
list_notes,
list_plugins,
list_provider_presets,
list_providers,
list_skills,
)
from app.routes import (
create_provider,
create_task,
delete_provider,
delete_task,
get_provider,
get_task,
list_tasks,
update_provider,
update_task,
)
from app.contracts import (
McpServerSecretStatus,
McpServerSecretWriteRequest,
ProviderCreateRequest,
ProviderType,
ProviderUpdateRequest,
@@ -28,6 +14,61 @@ from app.contracts import (
TaskStatus,
TaskUpdateRequest,
)
from app.main import health, service_status
from app.routes import (
create_provider,
create_task,
delete_provider,
delete_task,
get_index_status,
get_provider,
get_task,
list_notes,
list_plugins,
list_provider_presets,
list_providers,
list_skills,
list_tasks,
update_provider,
update_task,
)
def test_mcp_secret_routes_offload_blocking_lifecycle_work(monkeypatch) -> None:
from app import routes
caller_thread = threading.get_ident()
worker_threads: list[int] = []
class FakeMcpRegistry:
def put_secret(self, server_id, key, secret, *, kind):
worker_threads.append(threading.get_ident())
return McpServerSecretStatus(key=key, configured=True)
def delete_secret(self, server_id, key, *, kind):
worker_threads.append(threading.get_ident())
return McpServerSecretStatus(key=key, configured=False)
monkeypatch.setattr(
routes,
"container",
SimpleNamespace(mcp_servers=FakeMcpRegistry()),
)
written = asyncio.run(
routes.put_mcp_server_secret(
"server-1",
"TOKEN",
McpServerSecretWriteRequest(secret="hidden"),
kind="environment",
)
)
deleted = asyncio.run(
routes.delete_mcp_server_secret("server-1", "TOKEN", kind="environment")
)
assert written.configured is True
assert deleted.configured is False
assert worker_threads and all(item != caller_thread for item in worker_threads)
def test_health() -> None:
@@ -36,6 +77,172 @@ def test_health() -> None:
assert response.model_dump() == {"status": "ok"}
def test_mcp_create_and_trust_are_not_executed_on_event_loop(monkeypatch) -> None:
from app import routes
from app.contracts import McpServerCreateRequest, McpServerTrustRequest
caller = threading.get_ident()
workers = []
class Registry:
def create(self, request):
workers.append(threading.get_ident())
return "created"
def trust(self, server_id, digest):
workers.append(threading.get_ident())
return "trusted"
monkeypatch.setattr(routes, "container", SimpleNamespace(mcp_servers=Registry()))
assert (
asyncio.run(
routes.create_mcp_server(McpServerCreateRequest(name="test", command="uvx"))
)
== "created"
)
assert (
asyncio.run(
routes.trust_mcp_server(
"test", McpServerTrustRequest(command_digest="a" * 64)
)
)
== "trusted"
)
assert len(workers) == 2
assert all(worker != caller for worker in workers)
def test_mcp_split_config_and_secret_requests_persist_without_plaintext(
monkeypatch,
) -> None:
from fastapi.testclient import TestClient
from app import routes
from app.agent.tools import ToolRegistry
from app.config import get_settings
from app.extensions.mcp_registry import McpServerRegistry
from app.main import app
from app.providers.credentials import EncryptedCredentialStore
service = McpServerRegistry(
ToolRegistry(),
EncryptedCredentialStore(),
get_settings().data_dir,
allow_process_launch=True,
)
monkeypatch.setattr(routes, "container", SimpleNamespace(mcp_servers=service))
client = TestClient(app)
config = {
"name": "MiniMax configuration test",
"command": "uvx",
"environment": {"MINIMAX_API_HOST": "https://api.minimaxi.com"},
"secret_environment_keys": ["MINIMAX_API_KEY"],
"startup_timeout_seconds": 120,
"tool_timeout_seconds": 300,
}
# Reproduce the old frontend payload. The backend still enforces separation.
invalid = client.post(
"/api/mcp/servers",
json={
**config,
"environment": {
**config["environment"],
"MINIMAX_API_KEY": "synthetic-only",
},
},
)
assert invalid.status_code == 422
assert invalid.json()["error"]["code"] == "MCP_ENVIRONMENT_INVALID"
created = client.post("/api/mcp/servers", json=config)
assert created.status_code == 201
server_id = created.json()["server_id"]
saved = client.put(
f"/api/mcp/servers/{server_id}/secrets/MINIMAX_API_KEY",
json={"secret": "synthetic-only"},
)
assert saved.status_code == 200
current = client.get(f"/api/mcp/servers/{server_id}")
assert current.json()["secret_environment"] == {"MINIMAX_API_KEY": True}
assert "synthetic-only" not in current.text
assert "synthetic-only" not in service._path.read_text(encoding="utf-8")
_, credentials_path = service.credentials._paths()
assert "synthetic-only" not in credentials_path.read_text(encoding="utf-8")
assert not current.json()["enabled"] # Saving never starts a third-party process.
client.close()
@pytest.mark.parametrize("operation", ["create", "trust"])
def test_mcp_lifecycle_lock_contention_keeps_event_loop_responsive(
monkeypatch,
operation,
) -> None:
from app import routes
from app.agent.tools import ToolRegistry
from app.config import get_settings
from app.contracts import McpServerCreateRequest, McpServerTrustRequest
from app.extensions.mcp_registry import McpServerRegistry
from app.providers.credentials import EncryptedCredentialStore
service = McpServerRegistry(
ToolRegistry(),
EncryptedCredentialStore(),
get_settings().data_dir,
allow_process_launch=True,
)
request = McpServerCreateRequest(
name="Lock contention fixture", command="not-executed"
)
server = service.create(request)
monkeypatch.setattr(routes, "container", SimpleNamespace(mcp_servers=service))
entered = threading.Event()
locked = threading.Event()
release = threading.Event()
original = getattr(service, operation)
def observed(*args):
entered.set()
return original(*args)
def hold_lifecycle_lock():
with service._lifecycle_lock:
locked.set()
release.wait(timeout=5)
monkeypatch.setattr(service, operation, observed)
holder = threading.Thread(target=hold_lifecycle_lock, daemon=True)
holder.start()
# An independent watchdog lets the test fail rather than hang if a regression
# blocks the event loop itself (an asyncio timeout alone cannot catch that).
watchdog = threading.Timer(5, release.set)
watchdog.start()
async def exercise():
pending = asyncio.create_task(
routes.create_mcp_server(request)
if operation == "create"
else routes.trust_mcp_server(
server.server_id,
McpServerTrustRequest(command_digest=server.command_digest),
)
)
try:
assert await asyncio.to_thread(entered.wait, 2)
assert not pending.done()
assert not release.is_set()
assert (await health()).status == "ok"
finally:
release.set()
await pending
try:
assert locked.wait(timeout=2)
asyncio.run(exercise())
finally:
release.set()
watchdog.cancel()
holder.join(timeout=2)
def test_service_status() -> None:
response = asyncio.run(service_status())
@@ -52,7 +259,9 @@ def test_core_collections_are_typed() -> None:
assert notes.items == []
assert notes.page.limit == 20
assert [skill.manifest.skill_id for skill in skills.items] == ["knowledge-assistant"]
assert [skill.manifest.skill_id for skill in skills.items] == [
"knowledge-assistant"
]
assert skills.items[0].status == "ready"
assert [plugin.manifest.plugin_id for plugin in plugins.items] == ["text-tools"]
assert plugins.items[0].status == "ready"
@@ -73,9 +282,15 @@ def test_provider_presets_include_openai_and_deepseek() -> None:
def test_provider_presets_static_route_precedes_provider_id_route() -> None:
from app.routes import router
get_paths = [route.path for route in router.routes if "GET" in getattr(route, "methods", set())]
get_paths = [
route.path
for route in router.routes
if "GET" in getattr(route, "methods", set())
]
assert get_paths.index("/api/providers/presets") < get_paths.index("/api/providers/{provider_id}")
assert get_paths.index("/api/providers/presets") < get_paths.index(
"/api/providers/{provider_id}"
)
def test_openapi_contains_documented_frontend_interfaces() -> None:
@@ -95,8 +310,20 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
"/api/plugins/install",
"/api/plugins/{plugin_id}/host",
"/api/plugins/{plugin_id}/host/restart",
"/api/plugin-contributions/commands",
"/api/plugin-contributions/commands/{command_id}/execute",
"/api/plugins/{plugin_id}/settings",
"/api/plugins/{plugin_id}/settings/{key}/secret",
"/api/plugins/{plugin_id}/enable",
"/api/plugins/{plugin_id}/disable",
"/api/mcp/servers",
"/api/mcp/servers/{server_id}",
"/api/mcp/servers/{server_id}/tools",
"/api/mcp/servers/{server_id}/trust",
"/api/mcp/servers/{server_id}/test",
"/api/mcp/servers/{server_id}/enable",
"/api/mcp/servers/{server_id}/disable",
"/api/mcp/servers/{server_id}/secrets/{key}",
"/api/providers/test",
"/api/providers/presets",
"/api/credentials/{credential_id}",
+59 -1
View File
@@ -1,16 +1,21 @@
import asyncio
from pathlib import Path
import httpx
import pytest
from app.config import get_settings
from app.contracts import CredentialWriteRequest
from app.errors import ApiError
from app.providers.credentials import (
ChainedCredentialResolver,
CredentialStoreError,
EncryptedCredentialStore,
EnvironmentCredentialResolver,
)
from app.providers.factory import ProviderFactory
from app.providers.openai_compatible import OpenAICompatibleProvider
from app.routes import get_credential_status, put_credential
from app.routes import delete_credential, get_credential_status, put_credential
def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> None:
@@ -31,6 +36,33 @@ def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> No
assert store.resolve("deepseek") is None
def test_encrypted_credential_store_deletes_multiple_credentials_atomically() -> None:
store = EncryptedCredentialStore()
store.put("plugin.first", "first")
store.put("plugin.second", "second")
store.put("openai", "keep")
removed = store.delete_many(["plugin.first", "plugin.second"])
assert removed == {"plugin.first", "plugin.second"}
assert store.resolve("plugin.first") is None
assert store.resolve("plugin.second") is None
assert store.resolve("openai") == "keep"
def test_credential_write_os_error_uses_stable_store_error(monkeypatch) -> None:
store = EncryptedCredentialStore()
store.put("existing", "value")
def fail_replace(_path: Path, _target: Path) -> Path:
raise OSError("injected replace failure")
monkeypatch.setattr(Path, "replace", fail_replace)
with pytest.raises(CredentialStoreError, match="cannot be written"):
store.put("new", "value")
def test_credential_api_never_returns_secret() -> None:
written = asyncio.run(
put_credential(
@@ -72,3 +104,29 @@ def test_saved_credential_takes_precedence_over_environment_fallback(monkeypatch
resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver())
assert resolver.resolve("deepseek") == "saved-key"
def test_public_credential_api_rejects_plugin_namespace() -> None:
operations = [
get_credential_status("plugin.text-tools.api_key"),
put_credential(
"plugin.text-tools.api_key",
CredentialWriteRequest(api_key="must-not-write"),
),
delete_credential("plugin.text-tools.api_key"),
]
for operation in operations:
with pytest.raises(ApiError) as exc:
asyncio.run(operation)
assert exc.value.code == "CREDENTIAL_NAMESPACE_RESERVED"
assert EncryptedCredentialStore().resolve("plugin.text-tools.api_key") is None
def test_provider_resolver_cannot_read_plugin_secret() -> None:
store = EncryptedCredentialStore()
store.put("plugin.text-tools.api_key", "private-plugin-secret")
resolver = ProviderFactory(store).credentials
with pytest.raises(CredentialStoreError, match="reserved for Plugin settings"):
resolver.resolve("plugin.text-tools.api_key")
+165 -5
View File
@@ -11,12 +11,16 @@ from app.container import build_container
from app.contracts import (
AgentRunCreateRequest,
AgentRunStatus,
PluginCommandContext,
SkillStatus,
ToolCall,
)
from app.extensions import ExtensionError
from app.extensions.mcp import McpStdioClient
from app.extensions.runtime import _arguments_model_from_schema
from app.extensions.runtime import (
_arguments_model_from_schema,
_validate_mcp_command_target_schema,
)
from app.services import note_service
from app.config import BACKEND_DIR, get_settings
@@ -33,7 +37,7 @@ def mcp_container():
container = build_container()
installed = container.plugins.install(MCP_FIXTURE)
assert installed.status == "permission_required"
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"])
try:
yield container
finally:
@@ -349,7 +353,7 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
ToolExecutionContext(run_id="run_mcp_fixture"),
)
assert status.tools_count == 6
assert status.tools_count == 7
assert status.protocol_version == "2025-11-25"
assert status.server_name == "notesagent-mcp-fixture"
assert definition.permission == "notes.read"
@@ -396,6 +400,162 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
run(scenario())
def test_mcp_command_target_receives_scoped_context_and_declared_secret(
mcp_container,
) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
assert not mcp_container.tools.contains("mcp-fixture.command")
with pytest.raises(ExtensionError) as missing:
await mcp_container.plugins.execute_command(
"mcp-fixture.notify",
{},
PluginCommandContext(selection="来自选区"),
)
assert missing.value.code == "PLUGIN_SECRET_REQUIRED"
mcp_container.plugins.put_setting_secret(
"mcp-fixture", "api_key", "mcp-command-secret"
)
mcp_container.plugins.update_settings(
"mcp-fixture", 1, {"message_prefix": "Fixture: "}
)
result = await mcp_container.plugins.execute_command(
"mcp-fixture.notify",
{},
PluginCommandContext(
note_id="must-not-enter-envelope",
selection="来自选区",
),
)
assert result.effect.type == "notification"
assert result.effect.payload.model_dump() == {
"level": "success",
"message": "Fixture: 来自选区",
}
assert "mcp-command-secret" not in repr(
mcp_container.plugins.commands.audit_events()
)
run(scenario())
def test_mcp_command_target_rejects_incompatible_envelope_schema(tmp_path) -> None:
package = tmp_path / "mcp-bad-command"
shutil.copytree(MCP_FIXTURE, package)
for filename in ("plugin.yaml", "commands.yaml", "settings.yaml"):
path = package / filename
path.write_text(
path.read_text(encoding="utf-8").replace(
"mcp-fixture", "mcp-bad-command"
),
encoding="utf-8",
)
server_path = package / "server.py"
server_path.write_text(
server_path.read_text(encoding="utf-8").replace(
'{"_notesagent": {"type": "object"}}',
'{"unexpected": {"type": "string"}}',
),
encoding="utf-8",
)
container = build_container()
container.plugins.install(package)
container.plugins.set_permissions(
"mcp-bad-command", ["notes.read", "secrets.use"]
)
try:
with pytest.raises(ExtensionError) as exc:
container.plugins.enable("mcp-bad-command")
assert exc.value.code == "PLUGIN_CONTRIBUTION_INVALID"
assert container.plugins.get("mcp-bad-command").status == "error"
finally:
container.plugins.shutdown()
def test_mcp_command_target_enable_check_only_requires_protocol_marker() -> None:
# `not`/`oneOf` 等完整语义由实际调用前的官方 Validator 处理;启用检查
# 只确认不可被引用或组合隐藏的稳定宿主入口,避免维护不完整的求解器。
_validate_mcp_command_target_schema(
{
"type": "object",
"properties": {
"_notesagent": {
"type": "object",
"not": {"type": "object"},
}
},
},
"marker.run",
)
invalid_markers = [
{
"$defs": {"envelope": {"type": "object"}},
"properties": {"_notesagent": {"$ref": "#/$defs/envelope"}},
},
{
"allOf": [
{"properties": {"_notesagent": {"type": "object"}}},
]
},
]
for schema in invalid_markers:
with pytest.raises(ExtensionError) as exc:
_validate_mcp_command_target_schema(schema, "marker.run")
assert exc.value.code == "PLUGIN_CONTRIBUTION_INVALID"
def test_mcp_command_validates_actual_envelope_before_call(tmp_path) -> None:
package = tmp_path / "mcp-runtime-schema"
shutil.copytree(MCP_FIXTURE, package)
for filename in ("plugin.yaml", "commands.yaml", "settings.yaml"):
path = package / filename
path.write_text(
path.read_text(encoding="utf-8").replace(
"mcp-fixture", "mcp-runtime-schema"
),
encoding="utf-8",
)
server_path = package / "server.py"
server_path.write_text(
server_path.read_text(encoding="utf-8").replace(
'{"_notesagent": {"type": "object"}}',
'{"_notesagent": {"type": "object", "properties": '
'{"arguments": {"type": "object", "maxProperties": 0}, '
'"context": {"type": "object", "properties": '
'{"selection": {"type": "string"}}, "required": ["selection"]}}, '
'"required": ["arguments", "context"]}}',
),
encoding="utf-8",
)
container = build_container()
container.plugins.install(package)
container.plugins.set_permissions(
"mcp-runtime-schema", ["notes.read", "secrets.use"]
)
try:
# context.selection 是 Command 的 when/context 契约保证的真实字段;
# 启用期结构检查不得因没有伪造该业务值而拒绝目标 Schema。
container.plugins.enable("mcp-runtime-schema")
container.plugins.put_setting_secret(
"mcp-runtime-schema", "api_key", "configured"
)
with pytest.raises(ExtensionError) as exc:
run(
container.plugins.execute_command(
"mcp-runtime-schema.notify",
{"message": "must be rejected locally"},
PluginCommandContext(selection="visible"),
)
)
assert exc.value.code == "PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH"
finally:
container.plugins.shutdown()
def test_agent_calls_mcp_tool_through_registry_and_writes_trace(mcp_container) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
@@ -531,7 +691,7 @@ def test_production_rejects_unsandboxed_mcp_host(monkeypatch) -> None:
container = build_container()
installed = container.plugins.install(MCP_FIXTURE)
assert installed.status == "permission_required"
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"])
try:
with pytest.raises(ExtensionError) as exc:
container.plugins.enable("mcp-fixture")
@@ -565,7 +725,7 @@ def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container)
restarted = mcp_container.plugins.restart_host("mcp-fixture")
assert restarted.status == "ready"
assert restarted.tools_count == 6
assert restarted.tools_count == 7
assert mcp_container.tools.contains("mcp-fixture.echo")
run(scenario())
+769
View File
@@ -0,0 +1,769 @@
import asyncio
import hashlib
import json
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import httpx
import pytest
from app.agent.tools import ToolExecutionContext, ToolRegistry
from app.config import BACKEND_DIR, get_settings
from app.contracts import McpServerCreateRequest, McpServerUpdateRequest, ToolCall
from app.extensions.mcp import McpLegacySseClient
from app.extensions.mcp_registry import McpRegistryError, McpServerRegistry
from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore
SERVER = BACKEND_DIR / "extensions" / "fixtures" / "mcp-echo" / "server.py"
def request(**overrides) -> McpServerCreateRequest:
values = {
"name": "Echo MCP",
"command": sys.executable,
"args": [str(SERVER)],
"permissions": ["notes.read", "secrets.use"],
"secret_environment_keys": ["TEST_MCP_SECRET"],
}
values.update(overrides)
return McpServerCreateRequest(**values)
def registry(*, launch: bool = True) -> McpServerRegistry:
return McpServerRegistry(
ToolRegistry(),
EncryptedCredentialStore(),
get_settings().data_dir,
allow_process_launch=launch,
)
def test_registry_requires_current_trust_and_never_returns_secret() -> None:
service = registry()
created = service.create(request())
assert created.trusted is False
assert created.secret_environment == {"TEST_MCP_SECRET": False}
service.put_secret(created.server_id, "TEST_MCP_SECRET", "do-not-return")
configured = service.get(created.server_id)
assert configured.secret_environment == {"TEST_MCP_SECRET": True}
assert "do-not-return" not in configured.model_dump_json()
with pytest.raises(McpRegistryError, match="approve"):
service.test(created.server_id)
service.trust(created.server_id, created.command_digest)
tested = service.test(created.server_id)
assert tested.status == "stopped"
assert tested.last_test_succeeded is True
assert tested.tools_count > 0
service.shutdown()
def test_secret_change_disables_server_and_requires_a_new_connection_test() -> None:
service = registry()
created = service.create(request())
service.put_secret(created.server_id, "TEST_MCP_SECRET", "first")
service.trust(created.server_id, created.command_digest)
service.test(created.server_id)
service.enable(created.server_id)
service.put_secret(created.server_id, "TEST_MCP_SECRET", "second")
current = service.get(created.server_id)
assert current.enabled is False
assert current.last_test_succeeded is None
assert not any(
item.name.startswith(f"mcp.{created.server_id}.")
for item in service.tools.definitions()
)
with pytest.raises(McpRegistryError) as error:
service.enable(created.server_id)
assert error.value.code == "MCP_CONNECTION_TEST_REQUIRED"
service.shutdown()
def test_update_disables_server_and_revokes_command_trust() -> None:
service = registry()
created = service.create(request(secret_environment_keys=[]))
service.trust(created.server_id, created.command_digest)
service.test(created.server_id)
enabled = service.enable(created.server_id)
assert enabled.enabled is True
assert any(
item.name.startswith(f"mcp.{created.server_id}.")
for item in service.tools.definitions()
)
updated = service.update(
created.server_id,
McpServerUpdateRequest(
**request(name="Changed", secret_environment_keys=[]).model_dump(),
version=enabled.version,
),
)
assert updated.enabled is False
assert updated.trusted is False
assert not any(
item.name.startswith(f"mcp.{created.server_id}.")
for item in service.tools.definitions()
)
service.shutdown()
def test_update_remains_retryable_when_removed_secret_cleanup_fails(
monkeypatch,
) -> None:
service = registry()
created = service.create(request())
service.put_secret(created.server_id, "TEST_MCP_SECRET", "keep-until-retry")
def fail_delete_many(_secret_ids: list[str]) -> set[str]:
raise CredentialStoreError("credential store unavailable")
monkeypatch.setattr(service.credentials, "delete_many", fail_delete_many)
with pytest.raises(McpRegistryError) as error:
service.update(
created.server_id,
McpServerUpdateRequest(
**request(secret_environment_keys=[]).model_dump(),
version=created.version,
),
)
current = service.get(created.server_id)
assert error.value.code == "MCP_SECRET_STORE_ERROR"
assert current.version == created.version
assert current.secret_environment == {"TEST_MCP_SECRET": True}
service.shutdown()
def test_delete_keeps_server_retryable_when_secret_cleanup_fails(monkeypatch) -> None:
service = registry()
created = service.create(request())
service.put_secret(created.server_id, "TEST_MCP_SECRET", "keep-until-retry")
def fail_delete_many(_secret_ids: list[str]) -> set[str]:
raise CredentialStoreError("credential store unavailable")
monkeypatch.setattr(service.credentials, "delete_many", fail_delete_many)
with pytest.raises(McpRegistryError) as error:
service.delete(created.server_id)
current = service.get(created.server_id)
assert error.value.code == "MCP_SECRET_STORE_ERROR"
assert current.server_id == created.server_id
assert current.secret_environment == {"TEST_MCP_SECRET": True}
service.shutdown()
def test_unavailable_server_removes_bridge_host(monkeypatch) -> None:
service = registry()
created = service.create(request(secret_environment_keys=[]))
with service._lock:
service._records[created.server_id] = {
**service._records[created.server_id],
"enabled": True,
}
removed: list[str] = []
monkeypatch.setattr(service.bridge, "remove", removed.append)
generation = object()
service._generations[created.server_id] = generation
service._unavailable(created.server_id, generation, "connection lost")
current = service.get(created.server_id)
assert removed == [f"mcp.{created.server_id}"]
assert current.enabled is False
assert current.status == "unhealthy"
service.shutdown()
def test_old_failure_callback_cannot_stop_replacement_host(monkeypatch) -> None:
service = registry()
callbacks = []
original_start = service.bridge.start
def capture_callback(*args, **kwargs):
callbacks.append(args[4])
return original_start(*args, **kwargs)
monkeypatch.setattr(service.bridge, "start", capture_callback)
created = service.create(request(secret_environment_keys=[]))
service.trust(created.server_id, created.command_digest)
callback_thread = None
try:
service.test(created.server_id)
service.enable(created.server_id)
old_callback = callbacks[-1]
callback_started = threading.Event()
callback_finished = threading.Event()
def delayed_failure():
callback_started.set()
old_callback(f"mcp.{created.server_id}", "delayed old failure")
callback_finished.set()
# Queue the old callback while a replacement owns the lifecycle lock.
with service._lifecycle_lock:
callback_thread = threading.Thread(target=delayed_failure, daemon=True)
callback_thread.start()
assert callback_started.wait(timeout=2)
service.disable(created.server_id)
service.enable(created.server_id)
assert callback_finished.wait(timeout=2)
assert service.get(created.server_id).enabled is True
assert service.get(created.server_id).status == "ready"
assert service.tools.definitions()
callbacks[-1](f"mcp.{created.server_id}", "current failure")
assert service.get(created.server_id).enabled is False
assert service.get(created.server_id).status == "unhealthy"
finally:
service.shutdown()
if callback_thread is not None:
callback_thread.join(timeout=2)
def test_header_case_only_rename_preserves_secret() -> None:
service = registry()
config = {
"name": "HTTP",
"transport": "streamable_http",
"url": "https://example.test/mcp",
"secret_header_keys": ["Authorization"],
}
created = service.create(McpServerCreateRequest(**config))
service.put_secret(created.server_id, "Authorization", "synthetic", kind="header")
config["secret_header_keys"] = ["authorization"]
updated = service.update(
created.server_id, McpServerUpdateRequest(**config, version=created.version)
)
assert updated.secret_headers == {"authorization": True}
assert (
service.credentials.resolve(
service._secret_id(created.server_id, "authorization", "header")
)
== "synthetic"
)
def test_environment_secrets_are_case_sensitive_and_delete_independently() -> None:
service = registry()
created = service.create(request(secret_environment_keys=["TOKEN", "token"]))
service.put_secret(created.server_id, "TOKEN", "upper")
service.put_secret(created.server_id, "token", "lower")
assert (
service.credentials.resolve(service._secret_id(created.server_id, "TOKEN"))
== "upper"
)
assert (
service.credentials.resolve(service._secret_id(created.server_id, "token"))
== "lower"
)
service.delete_secret(created.server_id, "TOKEN")
assert service.get(created.server_id).secret_environment == {
"TOKEN": False,
"token": True,
}
def test_legacy_environment_credential_migration_is_idempotent() -> None:
service = registry()
created = service.create(request(secret_environment_keys=["TOKEN"]))
suffix = hashlib.sha256(b"environment\0token").hexdigest()[:20]
legacy_id = f"mcp.{created.server_id}.{suffix}"
service.credentials.put(legacy_id, "legacy-value")
service._records[created.server_id]["secret_environment_version"] = 1
service._write()
migrated = registry()
assert migrated.get(created.server_id).secret_environment == {"TOKEN": True}
assert (
migrated.credentials.resolve(migrated._secret_id(created.server_id, "TOKEN"))
== "legacy-value"
)
assert not migrated.credentials.has(legacy_id)
migrated.put_secret(created.server_id, "TOKEN", "new-value")
assert (
registry().credentials.resolve(migrated._secret_id(created.server_id, "TOKEN"))
== "new-value"
)
def test_ambiguous_legacy_credentials_are_not_assigned_to_two_variables() -> None:
service = registry()
created = service.create(request(secret_environment_keys=["TOKEN", "token"]))
suffix = hashlib.sha256(b"environment\0token").hexdigest()[:20]
legacy_id = f"mcp.{created.server_id}.{suffix}"
service.credentials.put(legacy_id, "cannot-reconstruct-originals")
service._records[created.server_id]["secret_environment_version"] = 1
service._write()
migrated = registry()
current = migrated.get(created.server_id)
assert current.secret_environment == {"TOKEN": False, "token": False}
assert current.enabled is False
assert current.last_test_succeeded is None
assert migrated.credentials.has(
legacy_id
) # Keep the original ciphertext recoverable.
migrated.put_secret(created.server_id, "TOKEN", "upper")
migrated.put_secret(created.server_id, "token", "lower")
assert registry().get(created.server_id).secret_environment == {
"TOKEN": True,
"token": True,
}
migrated.delete(created.server_id)
assert not migrated.credentials.has(legacy_id)
def test_credential_id_migration_keeps_new_values_and_is_atomic(monkeypatch) -> None:
credentials = EncryptedCredentialStore()
credentials.put("mcp.old", "old-value")
credentials.put("mcp.new", "new-value")
original_write = credentials._write_tokens
def fail_write(_tokens):
raise CredentialStoreError("synthetic failure")
monkeypatch.setattr(credentials, "_write_tokens", fail_write)
with pytest.raises(CredentialStoreError):
credentials.move_many({"mcp.old": "mcp.new"})
assert credentials.resolve("mcp.old") == "old-value"
assert credentials.resolve("mcp.new") == "new-value"
monkeypatch.setattr(credentials, "_write_tokens", original_write)
credentials.move_many({"mcp.old": "mcp.new"})
assert credentials.resolve("mcp.old") is None
assert credentials.resolve("mcp.new") == "new-value"
def test_ambiguous_legacy_secret_is_not_resurrected_after_removing_a_key() -> None:
service = registry()
created = service.create(request(secret_environment_keys=["TOKEN", "token"]))
legacy_id = service._legacy_environment_secret_id(created.server_id, "TOKEN")
service.credentials.put(legacy_id, "ambiguous-old-value")
service._records[created.server_id]["secret_environment_version"] = 1
service._write()
migrated = registry()
migrated.update(
created.server_id,
McpServerUpdateRequest(
**request(secret_environment_keys=["token"]).model_dump(),
version=created.version,
),
)
assert registry().get(created.server_id).secret_environment == {"token": False}
def test_production_rejects_process_launch_even_after_approval() -> None:
service = registry(launch=False)
created = service.create(request(secret_environment_keys=[]))
service.trust(created.server_id, created.command_digest)
with pytest.raises(McpRegistryError) as error:
service.enable(created.server_id)
assert error.value.code == "MCP_SANDBOX_REQUIRED"
@pytest.mark.parametrize("startup,tool", [(120, 300), (1.5, 2.5)])
def test_server_timeouts_survive_bridge_adaptation_and_reload(startup, tool) -> None:
service = registry()
created = service.create(
request(
secret_environment_keys=[],
startup_timeout_seconds=startup,
tool_timeout_seconds=tool,
)
)
service.trust(created.server_id, created.command_digest)
try:
tested = service.test(created.server_id)
assert tested.last_test_succeeded is True
assert tested.startup_timeout_seconds == startup
assert tested.tool_timeout_seconds == tool
restored = registry().get(created.server_id)
assert restored.startup_timeout_seconds == startup
assert restored.tool_timeout_seconds == tool
finally:
service.shutdown()
def test_enable_requires_successful_test_and_update_checks_version() -> None:
service = registry()
created = service.create(request(secret_environment_keys=[]))
service.trust(created.server_id, created.command_digest)
with pytest.raises(McpRegistryError) as error:
service.enable(created.server_id)
assert error.value.code == "MCP_CONNECTION_TEST_REQUIRED"
with pytest.raises(McpRegistryError) as error:
service.update(
created.server_id,
McpServerUpdateRequest(
**request(secret_environment_keys=[]).model_dump(), version=99
),
)
assert error.value.code == "MCP_SERVER_VERSION_CONFLICT"
def test_http_transport_rejects_invalid_cross_transport_fields() -> None:
service = registry()
with pytest.raises(McpRegistryError) as error:
service.create(
request(
transport="streamable_http",
url="https://example.invalid/mcp",
secret_environment_keys=[],
)
)
assert error.value.code == "MCP_CONFIG_INVALID"
def test_registry_rejects_corrupt_persisted_json(tmp_path) -> None:
path = tmp_path / "mcp"
path.mkdir()
(path / "servers.json").write_text("{broken", encoding="utf-8")
with pytest.raises(McpRegistryError) as error:
McpServerRegistry(
ToolRegistry(),
EncryptedCredentialStore(),
tmp_path,
allow_process_launch=True,
)
assert error.value.code == "MCP_REGISTRY_INVALID"
def test_registry_rejects_structurally_invalid_record(tmp_path) -> None:
path = tmp_path / "mcp"
path.mkdir()
(path / "servers.json").write_text(
json.dumps({"server-1": {"name": "Broken", "transport": "stdio"}}),
encoding="utf-8",
)
with pytest.raises(McpRegistryError) as error:
McpServerRegistry(
ToolRegistry(),
EncryptedCredentialStore(),
tmp_path,
allow_process_launch=True,
)
assert error.value.code == "MCP_REGISTRY_INVALID"
def test_registry_rejects_create_before_exceeding_persisted_limit(
monkeypatch,
) -> None:
service = registry()
service.create(request(name="Only server"))
monkeypatch.setattr("app.extensions.mcp_registry._MAX_MCP_SERVERS", 1)
with pytest.raises(McpRegistryError) as error:
service.create(request(name="One too many"))
assert error.value.code == "MCP_SERVER_LIMIT_REACHED"
assert len(service.list()) == 1
service.shutdown()
def test_stdio_command_is_not_parsed_as_a_shell_string() -> None:
service = registry()
created = service.create(
request(
command=f'"{sys.executable}" "{SERVER}"',
args=[],
secret_environment_keys=[],
)
)
service.trust(created.server_id, created.command_digest)
with pytest.raises(McpRegistryError) as error:
service.test(created.server_id)
assert error.value.code == "PLUGIN_HOST_START_FAILED"
assert service.get(created.server_id).last_test_succeeded is False
service.shutdown()
def test_enabled_server_is_restored_from_persisted_registry() -> None:
first = registry()
created = first.create(request(secret_environment_keys=[]))
first.trust(created.server_id, created.command_digest)
first.test(created.server_id)
first.enable(created.server_id)
first.shutdown()
restored = registry()
restored.restore_enabled()
current = restored.get(created.server_id)
assert current.enabled is True
assert current.status == "ready"
assert any(
item.name.startswith(f"mcp.{created.server_id}.")
for item in restored.tools.definitions()
)
restored.shutdown()
def test_lifecycle_operations_are_serialized_and_tool_names_are_isolated() -> None:
service = registry()
servers = [
service.create(request(name=f"Echo {index}", secret_environment_keys=[]))
for index in range(2)
]
for server in servers:
service.trust(server.server_id, server.command_digest)
service.test(server.server_id)
with ThreadPoolExecutor(max_workers=4) as pool:
enabled = list(
pool.map(lambda item: service.enable(item.server_id), servers * 2)
)
assert all(item.enabled for item in enabled)
names = [
item.name for item in service.tools.definitions() if item.source == "mcp_server"
]
assert len(names) == len(set(names))
assert all(
any(name.startswith(f"mcp.{item.server_id}.") for name in names)
for item in servers
)
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(lambda item: service.disable(item.server_id), servers * 2))
assert not any(item.source == "mcp_server" for item in service.tools.definitions())
service.shutdown()
def _http_result(request_id: int, result: dict) -> httpx.Response:
return httpx.Response(
200,
headers={"content-type": "application/json"},
json={"jsonrpc": "2.0", "id": request_id, "result": result},
)
def test_streamable_http_supports_session_headers_secrets_and_tool_summary(
monkeypatch,
) -> None:
requests: list[httpx.Request] = []
request_timeouts: dict[str, float] = {}
def handler(request_value: httpx.Request) -> httpx.Response:
requests.append(request_value)
if request_value.method == "GET":
return httpx.Response(405)
if request_value.method == "DELETE":
return httpx.Response(405)
payload = json.loads(request_value.content)
timeout = request_value.extensions.get("timeout", {}).get("read")
if isinstance(timeout, (int, float)):
request_timeouts[payload.get("method", "notification")] = float(timeout)
if payload.get("method") == "initialize":
response = _http_result(
payload["id"],
{
"protocolVersion": "2025-11-25",
"capabilities": {"tools": {}},
"serverInfo": {"name": "HTTP Fixture", "version": "1"},
},
)
response.headers["MCP-Session-Id"] = "session-test"
return response
if payload.get("method") == "tools/list":
return _http_result(
payload["id"],
{
"tools": [
{
"name": "echo",
"description": "Echo over HTTP",
"inputSchema": {"type": "object", "properties": {}},
}
]
},
)
if payload.get("method") == "tools/call":
return _http_result(
payload["id"], {"structuredContent": {"transport": "http"}}
)
return httpx.Response(202)
real_client = httpx.Client
monkeypatch.setattr(
"app.extensions.mcp.httpx.Client",
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
)
service = registry()
created = service.create(
McpServerCreateRequest(
name="Remote MCP",
transport="streamable_http",
url="https://mcp.example.test/mcp",
headers={"X-Client": "NotesAgent"},
secret_header_keys=["Authorization"],
)
)
service.put_secret(
created.server_id, "Authorization", "Bearer hidden", kind="header"
)
service.trust(created.server_id, created.command_digest)
tested = service.test(created.server_id)
assert tested.last_test_succeeded is True
assert tested.secret_headers == {"Authorization": True}
assert "Bearer hidden" not in tested.model_dump_json()
assert service.list_tools(created.server_id)[0].remote_name == "echo"
assert any(
request.headers.get("mcp-session-id") == "session-test" for request in requests
)
assert any(
request.headers.get("mcp-protocol-version") == "2025-11-25"
for request in requests
)
assert all(
request.headers.get("authorization") == "Bearer hidden" for request in requests
)
assert request_timeouts["initialize"] == 15
assert request_timeouts["notifications/initialized"] == 15
assert request_timeouts["tools/list"] == 15
enabled = service.enable(created.server_id)
tool_name = service.list_tools(created.server_id)[0].name
result = asyncio.run(
service.tools.execute(
ToolCall(tool_call_id="call-1", name=tool_name, arguments={}),
ToolExecutionContext(run_id="run-1"),
)
)
assert enabled.enabled is True
assert result.success is True
assert result.output == {"transport": "http"}
assert request_timeouts["tools/call"] == 30
service.disable(created.server_id)
service.shutdown()
class _LegacyEventStream(httpx.SyncByteStream):
def __init__(self) -> None:
self.closed = threading.Event()
def __iter__(self):
yield b"event: endpoint\ndata: /messages\n\n"
time.sleep(0.1)
initialize = {
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "Legacy Fixture"},
},
}
yield f"data: {json.dumps(initialize)}\n\n".encode()
time.sleep(0.1)
tools = {
"jsonrpc": "2.0",
"id": 2,
"result": {"tools": []},
}
yield f"data: {json.dumps(tools)}\n\n".encode()
self.closed.wait()
def close(self) -> None:
self.closed.set()
def test_legacy_sse_uses_same_origin_endpoint(monkeypatch) -> None:
posted_urls: list[str] = []
event_stream = _LegacyEventStream()
def handler(request_value: httpx.Request) -> httpx.Response:
if request_value.method == "GET":
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
stream=event_stream,
)
posted_urls.append(str(request_value.url))
return httpx.Response(202)
real_client = httpx.Client
monkeypatch.setattr(
"app.extensions.mcp.httpx.Client",
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
)
service = registry()
created = service.create(
McpServerCreateRequest(
name="Legacy MCP",
transport="sse",
url="https://legacy.example.test/sse",
)
)
service.trust(created.server_id, created.command_digest)
tested = service.test(created.server_id)
assert tested.last_test_succeeded is True
assert posted_urls and all(
url == "https://legacy.example.test/messages" for url in posted_urls
)
service.shutdown()
event_stream.close()
class _EndingLegacyEventStream(httpx.SyncByteStream):
def __iter__(self):
yield b"event: endpoint\ndata: /messages\n\n"
def test_legacy_sse_eof_marks_client_unavailable(monkeypatch) -> None:
def handler(_request_value: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
stream=_EndingLegacyEventStream(),
)
real_client = httpx.Client
monkeypatch.setattr(
"app.extensions.mcp.httpx.Client",
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
)
broken = threading.Event()
client = McpLegacySseClient(
"https://legacy.example.test/sse",
headers={},
startup_timeout_seconds=1,
on_seen=lambda: None,
on_broken=lambda _message: broken.set(),
on_tools_changed=lambda: None,
)
client.start()
assert broken.wait(timeout=1)
client.stop()
class _CrossOriginLegacyEventStream(httpx.SyncByteStream):
def __iter__(self):
yield b"event: endpoint\ndata: https://attacker.example/messages\n\n"
def test_legacy_sse_rejects_cross_origin_message_endpoint(monkeypatch) -> None:
def handler(request_value: httpx.Request) -> httpx.Response:
assert request_value.method == "GET"
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
stream=_CrossOriginLegacyEventStream(),
)
real_client = httpx.Client
monkeypatch.setattr(
"app.extensions.mcp.httpx.Client",
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
)
service = registry()
created = service.create(
McpServerCreateRequest(
name="Unsafe legacy MCP",
transport="sse",
url="https://legacy.example.test/sse",
)
)
service.trust(created.server_id, created.command_digest)
with pytest.raises(McpRegistryError) as error:
service.test(created.server_id)
assert error.value.code == "MCP_HTTP_RESPONSE_INVALID"
service.shutdown()
+83
View File
@@ -0,0 +1,83 @@
import json
from contextlib import closing
import httpx
import pytest
from app.extensions import mcp
class ChunkStream(httpx.SyncByteStream):
def __init__(self, chunks):
self.chunks = chunks
self.bytes_read = 0
def __iter__(self):
for chunk in self.chunks:
self.bytes_read += len(chunk)
yield chunk
def test_sse_rejects_unterminated_line_before_reading_entire_stream(monkeypatch):
monkeypatch.setattr(mcp, "MAX_MCP_MESSAGE_BYTES", 1024)
stream = ChunkStream([b"x" * 256] * 256)
with (
closing(httpx.Response(200, stream=stream)) as response,
pytest.raises(mcp.McpBridgeError, match="too large"),
):
list(mcp._iter_sse(response))
assert stream.bytes_read == 1280
def test_sse_limits_combined_event_before_partial_line_is_complete(monkeypatch):
monkeypatch.setattr(mcp, "MAX_MCP_MESSAGE_BYTES", 32)
stream = ChunkStream(
[b"data: 123456789\n", b"data: 123456789\n", b"x", b"not-read"]
)
with (
closing(httpx.Response(200, stream=stream)) as response,
pytest.raises(mcp.McpBridgeError, match="too large"),
):
list(mcp._iter_sse(response))
assert stream.bytes_read == 33
@pytest.mark.parametrize("separator", [b"\n", b"\r", b"\r\n"])
@pytest.mark.parametrize("chunk_size", [1, 2, 7, 1024])
def test_sse_preserves_utf8_and_line_endings_across_chunks(separator, chunk_size):
payload = json.dumps(
{"jsonrpc": "2.0", "id": 1, "result": {"text": "中文"}}, ensure_ascii=False
)
wire = b"\xef\xbb\xbf" + separator.join(
[
b": heartbeat",
b"event: message",
b"id: replay-1",
("data: " + payload).encode(),
b"",
b"",
]
)
stream = ChunkStream(
[wire[index : index + chunk_size] for index in range(0, len(wire), chunk_size)]
)
with closing(httpx.Response(200, stream=stream)) as response:
assert list(mcp._iter_sse(response)) == [("message", "replay-1", payload)]
def test_sse_event_limit_resets_between_events(monkeypatch):
monkeypatch.setattr(mcp, "MAX_MCP_MESSAGE_BYTES", 16)
with closing(
httpx.Response(200, stream=ChunkStream([b"data: one\n\ndata: two\r\r"]))
) as response:
assert list(mcp._iter_sse(response)) == [
("message", None, "one"),
("message", None, "two"),
]
def test_sse_preserves_multiline_data_and_final_unterminated_line():
with closing(
httpx.Response(200, stream=ChunkStream([b"data: first\ndata: last"]))
) as response:
assert list(mcp._iter_sse(response)) == [("message", None, "first\nlast")]
+653
View File
@@ -0,0 +1,653 @@
import asyncio
import json
from pathlib import Path
import pytest
from pydantic import TypeAdapter, ValidationError
from app.agent import ToolRegistry
from app.config import BACKEND_DIR, get_settings
from app.container import build_container
from app.contracts import (
PluginCommandContext,
PluginCommandEffect,
PluginNoEffect,
PluginSettingType,
)
from app.extensions import ExtensionError, PluginRuntime
from app.extensions.contributions import _secret_reference
from app.extensions.runtime import DeclarativePluginHost
from app.providers.credentials import CredentialStoreError
TEXT_TOOLS = BACKEND_DIR / "extensions" / "plugins" / "text-tools"
def run(coroutine):
return asyncio.run(coroutine)
def test_command_list_filter_and_lifecycle() -> None:
container = build_container()
commands = container.plugins.list_commands()
palette = container.plugins.list_commands(location="command_palette")
assert [item.command_id for item in commands] == ["text-tools.uppercase-selection"]
assert palette[0].plugin_id == "text-tools"
assert palette[0].icon == "edit"
assert palette[0].when == ["editor.has_selection"]
container.plugins.disable("text-tools")
assert container.plugins.list_commands() == []
with pytest.raises(ExtensionError) as exc:
run(
container.plugins.execute_command(
"text-tools.uppercase-selection",
{},
PluginCommandContext(selection="hello"),
)
)
assert exc.value.code == "PLUGIN_COMMAND_NOT_FOUND"
container.plugins.enable("text-tools")
assert len(container.plugins.list_commands()) == 1
def test_command_executes_with_scoped_context_and_settings() -> None:
container = build_container()
container.plugins.update_settings("text-tools", 1, {"result_limit": 4})
result = run(
container.plugins.execute_command(
"text-tools.uppercase-selection",
{},
PluginCommandContext(
vault_id="default",
note_id="note_private",
file_path="private.md",
selection="abcdef",
),
)
)
assert result.status == "completed"
assert result.effect.type == "notification"
assert result.effect.payload.model_dump() == {
"level": "success",
"message": "ABCD",
}
def test_echo_command_returns_none_for_empty_message() -> None:
host = DeclarativePluginHost()
empty = run(host.execute_command("echo", {}, {}, {}, lambda _: None))
populated = run(
host.execute_command("echo", {"message": "hello"}, {}, {}, lambda _: None)
)
assert isinstance(empty, PluginNoEffect)
assert populated.type == "notification"
assert populated.payload.message == "hello"
@pytest.mark.parametrize(
("effect_type", "payload"),
[
("none", {"unexpected": True}),
("notification", {"level": "debug", "message": "invalid"}),
("navigate", {"route": "https://example.com"}),
("refresh", {"scope": "everything"}),
("job", {"job_id": "invalid job id"}),
],
)
def test_command_effect_rejects_untrusted_payloads(effect_type, payload) -> None:
with pytest.raises(ValidationError):
TypeAdapter(PluginCommandEffect).validate_python(
{"type": effect_type, "payload": payload}
)
def test_command_rejects_missing_context_and_invalid_arguments() -> None:
container = build_container()
with pytest.raises(ExtensionError) as context_error:
run(
container.plugins.execute_command(
"text-tools.uppercase-selection", {}, PluginCommandContext()
)
)
assert context_error.value.code == "PLUGIN_COMMAND_CONTEXT_INVALID"
with pytest.raises(ExtensionError) as argument_error:
run(
container.plugins.execute_command(
"text-tools.uppercase-selection",
{"unknown": True},
PluginCommandContext(selection="hello"),
)
)
assert argument_error.value.code == "PLUGIN_COMMAND_ARGUMENT_INVALID"
audit = container.plugins.commands.audit_events()
assert [event.error_code for event in audit[-2:]] == [
"PLUGIN_COMMAND_CONTEXT_INVALID",
"PLUGIN_COMMAND_ARGUMENT_INVALID",
]
# 审计事件不得携带参数、正文选区或返回 effect。
assert "hello" not in repr(audit)
def test_command_only_receives_declared_context() -> None:
class CapturingHost(DeclarativePluginHost):
def __init__(self) -> None:
self.context = None
async def execute_command(
self, handler, arguments, context, settings, resolve_secret
):
self.context = context
return PluginNoEffect()
host = CapturingHost()
runtime = PluginRuntime(ToolRegistry(), host=host)
runtime.install(TEXT_TOOLS)
runtime.enable("text-tools")
run(
runtime.execute_command(
"text-tools.uppercase-selection",
{},
PluginCommandContext(
vault_id="default", note_id="note_private", selection="visible"
),
)
)
assert host.context == {"selection": "visible"}
def test_command_resolves_only_declared_plugin_secrets(tmp_path: Path) -> None:
class SecretHost(DeclarativePluginHost):
def __init__(self) -> None:
self.secret = None
self.denied_code = None
async def execute_command(
self, handler, arguments, context, settings, resolve_secret
):
self.secret = resolve_secret("api_key")
try:
resolve_secret("undeclared")
except ExtensionError as exc:
self.denied_code = exc.code
return PluginNoEffect()
host = SecretHost()
runtime = PluginRuntime(ToolRegistry(), host=host)
package = tmp_path / "secret-command"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: secret-command
name: Secret Command
version: 1.0.0
permissions: [secrets.use]
contributes:
commands: [secret-command.run]
settings_sections: [secret-command.general]
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text(
"""
commands:
- command_id: secret-command.run
title: Secret Command
locations: [command_palette]
secrets: [api_key]
handler: echo
""".strip(),
encoding="utf-8",
)
(package / "settings.yaml").write_text(
"""
section_id: secret-command.general
schema_version: 1
fields:
- key: api_key
label: API Key
type: secret
""".strip(),
encoding="utf-8",
)
runtime.install(package)
runtime.set_permissions("secret-command", ["secrets.use"])
runtime.enable("secret-command")
runtime.put_setting_secret("secret-command", "api_key", "runtime-only-secret")
run(
runtime.execute_command(
"secret-command.run",
{},
PluginCommandContext(selection="visible"),
)
)
assert host.secret == "runtime-only-secret"
assert host.denied_code == "PLUGIN_SECRET_ACCESS_DENIED"
assert "runtime-only-secret" not in repr(runtime.commands.audit_events())
def test_settings_schema_contains_defaults_and_hides_secret() -> None:
container = build_container()
schema = container.plugins.get_settings("text-tools")
by_key = {field.key: field for field in schema.fields}
assert schema.schema_version == 1
assert schema.values == {
"result_limit": 100,
"label_prefix": "",
"output_style": "notification",
"enabled_hint": True,
}
assert "api_key" not in schema.values
assert schema.secrets["api_key"].configured is False
assert by_key["api_key"].type == PluginSettingType.secret
def test_settings_update_validates_version_type_bounds_and_secret_boundary() -> None:
container = build_container()
updated = container.plugins.update_settings(
"text-tools", 1, {"result_limit": 20, "output_style": "compact"}
)
assert updated.values["result_limit"] == 20
assert updated.values["output_style"] == "compact"
cases = [
(2, {}, "PLUGIN_SETTINGS_VERSION_CONFLICT"),
(1, {"result_limit": 0}, "PLUGIN_SETTINGS_FIELD_INVALID"),
(1, {"enabled_hint": "yes"}, "PLUGIN_SETTINGS_FIELD_INVALID"),
(1, {"output_style": "unknown"}, "PLUGIN_SETTINGS_FIELD_INVALID"),
(1, {"api_key": "plaintext"}, "PLUGIN_SETTINGS_FIELD_INVALID"),
(1, {"unknown": True}, "PLUGIN_SETTINGS_FIELD_INVALID"),
]
for version, values, code in cases:
with pytest.raises(ExtensionError) as exc:
container.plugins.update_settings("text-tools", version, values)
assert exc.value.code == code
def test_required_plain_setting_blocks_enable_until_configured(tmp_path: Path) -> None:
package = tmp_path / "required-setting"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: required-setting
name: Required Setting
version: 1.0.0
contributes:
commands: [required-setting.run]
settings_sections: [required-setting.general]
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text(
"""
commands:
- command_id: required-setting.run
title: Required Setting
locations: [command_palette]
handler: echo
""".strip(),
encoding="utf-8",
)
(package / "settings.yaml").write_text(
"""
section_id: required-setting.general
schema_version: 1
fields:
- key: endpoint
label: Endpoint
type: string
required: true
""".strip(),
encoding="utf-8",
)
runtime = PluginRuntime(ToolRegistry())
runtime.install(package)
with pytest.raises(ExtensionError) as exc:
runtime.enable("required-setting")
assert exc.value.code == "PLUGIN_SETTINGS_REQUIRED"
assert runtime.get("required-setting").status == "installed"
runtime.update_settings("required-setting", 1, {"endpoint": "local"})
assert runtime.enable("required-setting").status == "ready"
def test_secret_roundtrip_never_enters_plain_settings_storage() -> None:
container = build_container()
plaintext = "stage-d-secret-value"
status = container.plugins.put_setting_secret("text-tools", "api_key", plaintext)
schema = container.plugins.get_settings("text-tools")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
credentials_path = get_settings().data_dir / "credentials" / "credentials.json"
assert status.configured is True
assert schema.secrets["api_key"].configured is True
assert "api_key" not in schema.values
assert plaintext not in settings_path.read_text(encoding="utf-8")
assert plaintext not in credentials_path.read_text(encoding="utf-8")
stored_settings = json.loads(settings_path.read_text(encoding="utf-8"))
reference = stored_settings["text-tools"]["secret_refs"]["api_key"]
assert reference.startswith("plugin.")
assert len(reference) == 71
assert "text-tools" not in reference and "api_key" not in reference
assert container.credentials.resolve(reference) == plaintext
deleted = container.plugins.delete_setting_secret("text-tools", "api_key")
assert deleted.configured is False
assert container.credentials.resolve(reference) is None
def test_uninstall_removes_plugin_settings_and_secret_namespace() -> None:
container = build_container()
container.plugins.update_settings("text-tools", 1, {"result_limit": 12})
container.plugins.put_setting_secret("text-tools", "api_key", "temporary")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
reference = json.loads(settings_path.read_text(encoding="utf-8"))[
"text-tools"
]["secret_refs"]["api_key"]
container.plugins.uninstall("text-tools")
stored = json.loads(settings_path.read_text(encoding="utf-8"))
assert "text-tools" not in stored
assert container.credentials.resolve(reference) is None
def test_plugin_secret_reference_has_fixed_credential_safe_length() -> None:
reference = _secret_reference("p" * 512, "k" * 128)
assert reference.startswith("plugin.")
assert len(reference) <= 128
def test_tampered_secret_reference_cannot_cross_credential_namespace() -> None:
container = build_container()
container.credentials.put("openai", "provider-private-secret")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text(
json.dumps(
{
"text-tools": {
"schema_version": 1,
"values": {},
"secret_refs": {"api_key": "openai"},
}
}
),
encoding="utf-8",
)
with pytest.raises(ExtensionError) as read_error:
container.plugins.get_settings("text-tools")
with pytest.raises(ExtensionError) as uninstall_error:
container.plugins.uninstall("text-tools")
assert read_error.value.code == "PLUGIN_STORAGE_ERROR"
assert uninstall_error.value.code == "PLUGIN_STORAGE_ERROR"
assert container.credentials.resolve("openai") == "provider-private-secret"
def test_secret_delete_restores_reference_when_credential_delete_fails(
monkeypatch,
) -> None:
container = build_container()
container.plugins.put_setting_secret("text-tools", "api_key", "keep-me")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
original = settings_path.read_text(encoding="utf-8")
reference = _secret_reference("text-tools", "api_key")
def fail_delete(_credential_id: str) -> bool:
raise CredentialStoreError("injected delete failure")
monkeypatch.setattr(container.credentials, "delete", fail_delete)
with pytest.raises(ExtensionError) as exc:
container.plugins.delete_setting_secret("text-tools", "api_key")
assert exc.value.code == "PLUGIN_SECRET_STORE_ERROR"
assert settings_path.read_text(encoding="utf-8") == original
assert container.credentials.resolve(reference) == "keep-me"
def test_uninstall_restores_settings_when_atomic_secret_delete_fails(
monkeypatch,
) -> None:
container = build_container()
container.plugins.update_settings("text-tools", 1, {"result_limit": 12})
container.plugins.put_setting_secret("text-tools", "api_key", "keep-me")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
original = settings_path.read_text(encoding="utf-8")
reference = _secret_reference("text-tools", "api_key")
def fail_delete_many(_credential_ids: list[str]) -> set[str]:
raise CredentialStoreError("injected batch delete failure")
monkeypatch.setattr(container.credentials, "delete_many", fail_delete_many)
with pytest.raises(ExtensionError) as exc:
container.plugins.uninstall("text-tools")
assert exc.value.code == "PLUGIN_SECRET_STORE_ERROR"
assert settings_path.read_text(encoding="utf-8") == original
assert container.credentials.resolve(reference) == "keep-me"
assert container.plugins.get("text-tools").manifest.plugin_id == "text-tools"
def test_invalid_command_and_settings_manifest_are_rejected(tmp_path: Path) -> None:
invalid_command = tmp_path / "invalid-command"
invalid_command.mkdir()
(invalid_command / "plugin.yaml").write_text(
"""
id: invalid-command
name: Invalid Command
version: 1.0.0
contributes:
commands: [other.run]
""".strip(),
encoding="utf-8",
)
(invalid_command / "commands.yaml").write_text(
"""
commands:
- command_id: other.run
title: Invalid
locations: [command_palette]
handler: echo
""".strip(),
encoding="utf-8",
)
invalid_settings = tmp_path / "invalid-settings"
invalid_settings.mkdir()
(invalid_settings / "plugin.yaml").write_text(
"""
id: invalid-settings
name: Invalid Settings
version: 1.0.0
contributes:
settings_sections: [invalid-settings.general]
""".strip(),
encoding="utf-8",
)
(invalid_settings / "settings.yaml").write_text(
"""
section_id: invalid-settings.general
schema_version: 1
fields:
- key: token
label: Token
type: secret
default: leaked-default
""".strip(),
encoding="utf-8",
)
runtime = PluginRuntime(ToolRegistry())
with pytest.raises(ExtensionError) as command_error:
runtime.install(invalid_command)
assert command_error.value.code == "PLUGIN_COMMAND_INVALID"
with pytest.raises(ExtensionError) as settings_error:
runtime.install(invalid_settings)
assert settings_error.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID"
@pytest.mark.parametrize("bound", [".nan", ".inf", "-.inf"])
def test_non_finite_setting_bounds_are_rejected(tmp_path: Path, bound: str) -> None:
package = tmp_path / f"invalid-bound-{bound.replace('.', 'dot').replace('-', 'neg')}"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: invalid-bound
name: Invalid Bound
version: 1.0.0
contributes:
settings_sections: [invalid-bound.general]
backend:
type: none
transport: none
""".strip(),
encoding="utf-8",
)
(package / "settings.yaml").write_text(
f"""
section_id: invalid-bound.general
schema_version: 1
fields:
- key: limit
label: Limit
type: number
minimum: {bound}
""".strip(),
encoding="utf-8",
)
with pytest.raises(ExtensionError) as exc:
PluginRuntime(ToolRegistry()).install(package)
assert exc.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID"
assert "must be finite" in exc.value.message
def test_null_command_list_returns_stable_manifest_error(tmp_path: Path) -> None:
package = tmp_path / "null-commands"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: null-commands
name: Null Commands
version: 1.0.0
contributes:
commands: []
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text("commands:\n", encoding="utf-8")
with pytest.raises(ExtensionError) as exc:
PluginRuntime(ToolRegistry()).install(package)
assert exc.value.code == "EXTENSION_MANIFEST_INVALID"
def test_external_command_schema_reference_is_rejected(tmp_path: Path) -> None:
package = tmp_path / "external-ref"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: external-ref
name: External Ref
version: 1.0.0
contributes:
commands: [external-ref.run]
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text(
"""
commands:
- command_id: external-ref.run
title: External Ref
locations: [command_palette]
handler: echo
parameters:
$ref: file:///host/private-schema.json
""".strip(),
encoding="utf-8",
)
with pytest.raises(ExtensionError) as exc:
PluginRuntime(ToolRegistry()).install(package)
assert exc.value.code == "PLUGIN_COMMAND_INVALID"
assert "External JSON Schema reference" in exc.value.message
def test_settings_missing_and_secret_field_errors_are_stable() -> None:
container = build_container()
with pytest.raises(ExtensionError) as missing:
container.plugins.get_settings("does-not-exist")
assert missing.value.code == "PLUGIN_NOT_FOUND"
with pytest.raises(ExtensionError) as field:
container.plugins.put_setting_secret("text-tools", "result_limit", "secret")
assert field.value.code == "PLUGIN_SECRET_FIELD_NOT_FOUND"
with pytest.raises(ExtensionError) as empty:
container.plugins.put_setting_secret("text-tools", "api_key", "")
assert empty.value.code == "PLUGIN_SECRET_VALUE_INVALID"
def test_corrupted_plugin_settings_namespace_returns_stable_error() -> None:
container = build_container()
settings_path = get_settings().data_dir / "plugins" / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text('{"text-tools": []}', encoding="utf-8")
with pytest.raises(ExtensionError) as exc:
container.plugins.get_settings("text-tools")
assert exc.value.code == "PLUGIN_STORAGE_ERROR"
with pytest.raises(ExtensionError) as secret_exc:
container.plugins.put_setting_secret("text-tools", "api_key", "must-not-orphan")
assert secret_exc.value.code == "PLUGIN_STORAGE_ERROR"
credentials_path = get_settings().data_dir / "credentials" / "credentials.json"
credential_ids = (
json.loads(credentials_path.read_text(encoding="utf-8")).keys()
if credentials_path.exists()
else []
)
assert not any(item.startswith("plugin.") for item in credential_ids)
+65
View File
@@ -0,0 +1,65 @@
import pytest
from app.schema_security import (
ExternalSchemaReferenceError,
UnresolvableLocalSchemaReferenceError,
reject_external_schema_references,
)
@pytest.mark.parametrize(
"schema",
[
{"$ref": "file:///host/private-schema.json"},
{"properties": {"value": {"$ref": "https://schema.invalid/value.json"}}},
{"allOf": [{"$dynamicRef": "https://schema.invalid/dynamic"}]},
],
)
def test_external_json_schema_references_are_rejected(schema) -> None:
with pytest.raises(ExternalSchemaReferenceError):
reject_external_schema_references(schema)
def test_local_json_schema_fragment_reference_is_allowed() -> None:
reject_external_schema_references(
{
"$defs": {"value": {"type": "string"}},
"properties": {"value": {"$ref": "#/$defs/value"}},
}
)
@pytest.mark.parametrize("reference", ["#/$defs/missing", "#missing-anchor"])
def test_unresolvable_local_schema_reference_is_rejected(reference: str) -> None:
with pytest.raises(UnresolvableLocalSchemaReferenceError):
reject_external_schema_references({"type": "object", "$ref": reference})
def test_root_reference_cannot_use_anchor_from_nested_schema_resource() -> None:
schema = {
"$defs": {
"nested": {
"$id": "nested",
"$anchor": "inside",
"type": "string",
}
},
"properties": {"value": {"$ref": "#inside"}},
}
with pytest.raises(UnresolvableLocalSchemaReferenceError):
reject_external_schema_references(schema)
def test_nested_schema_resource_can_resolve_its_own_anchor() -> None:
schema = {
"$defs": {
"nested": {
"$id": "nested",
"$anchor": "inside",
"allOf": [{"$ref": "#inside"}],
}
}
}
reject_external_schema_references(schema)
+2
View File
@@ -374,6 +374,7 @@ dependencies = [
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "pyyaml" },
{ name = "referencing" },
{ name = "sqlite-vec" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -390,6 +391,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28,<1.0" },
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
{ name = "referencing", specifier = ">=0.36,<1.0" },
{ name = "sqlite-vec", specifier = ">=0.1.9" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" },
]
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as pluginService from '@/services/pluginService'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import CommandPalette from './CommandPalette.vue'
vi.mock('@/services/pluginService', async (loadOriginal) => {
const original = await loadOriginal<typeof import('@/services/pluginService')>()
return { ...original, listPluginCommands: vi.fn(), executePluginCommand: vi.fn() }
})
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(pluginService.listPluginCommands).mockResolvedValue([{
command_id: 'demo.selection',
plugin_id: 'demo',
title: '处理选区',
description: '',
icon: null,
locations: ['command_palette'],
when: ['workspace.has_vault', 'editor.has_note', 'editor.has_selection'],
parameters: { type: 'object', properties: {}, additionalProperties: false },
enabled: true,
}])
vi.mocked(pluginService.executePluginCommand).mockResolvedValue({
command_id: 'demo.selection',
status: 'completed',
effect: { type: 'notification', payload: { level: 'success', message: '完成' } },
})
})
afterEach(() => {
document.body.innerHTML = ''
vi.restoreAllMocks()
})
describe('CommandPalette Plugin Command', () => {
it('filters by when context and sends stable backend identities plus the captured selection', async () => {
const workspace = useWorkspaceStore()
workspace.hasVault = true
workspace.vaultId = 'vault-default'
const editor = useEditorStore()
editor.currentNoteId = 'note-1'
editor.currentFilePath = '/note.md'
vi.spyOn(window, 'getSelection').mockReturnValue({
toString: () => 'selected text',
} as Selection)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
})
await router.push('/')
const wrapper = mount(CommandPalette, { attachTo: document.body, global: { plugins: [router] } })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true }))
await flushPromises()
const command = Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.includes('处理选区'))
expect(command).toBeTruthy()
command!.click()
await flushPromises()
expect(pluginService.executePluginCommand).toHaveBeenCalledWith('demo.selection', {}, {
vault_id: 'vault-default',
note_id: 'note-1',
file_path: '/note.md',
selection: 'selected text',
})
expect(document.body.textContent).toContain('完成')
wrapper.unmount()
})
})
@@ -5,18 +5,26 @@ import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme'
import { useWorkspaceStore } from '@/stores/workspace'
import * as workspaceService from '@/services/workspaceService'
import * as pluginService from '@/services/pluginService'
import type { PluginCommand, PluginCommandEffect } from '@/contracts'
import { usePluginStore } from '@/stores/plugin'
const router = useRouter()
const editorStore = useEditorStore()
const themeStore = useThemeStore()
const workspaceStore = useWorkspaceStore()
const pluginStore = usePluginStore()
const open = ref(false)
const query = ref('')
const input = ref<HTMLInputElement | null>(null)
const pluginCommands = ref<PluginCommand[]>([])
const commandError = ref('')
const commandNotice = ref('')
const selectionSnapshot = ref<string | null>(null)
interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> }
const commands = computed<Command[]>(() => [
const builtinCommands = computed<Command[]>(() => [
{ id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') },
{ id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') },
{ id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') },
@@ -28,14 +36,37 @@ const commands = computed<Command[]>(() => [
{ id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote },
])
const commands = computed<Command[]>(() => [
...builtinCommands.value,
...pluginCommands.value.filter(isPluginCommandAvailable).map((command) => ({
id: 'plugin:' + command.command_id,
label: command.title,
hint: 'Plugin · ' + command.plugin_id,
run: () => executePluginCommand(command),
})),
])
function isPluginCommandAvailable(command: PluginCommand) {
if (!command.enabled) return false
return command.when.every((condition) => {
if (condition === 'workspace.has_vault') return Boolean(workspaceStore.vaultId)
if (condition === 'editor.has_note') return Boolean(editorStore.currentNoteId)
if (condition === 'editor.has_selection') return Boolean(selectionSnapshot.value)
return false
})
}
const filteredCommands = computed(() => {
const value = query.value.trim().toLocaleLowerCase()
return value ? commands.value.filter((command) => `${command.label} ${command.hint}`.toLocaleLowerCase().includes(value)) : commands.value
})
function show() {
selectionSnapshot.value = window.getSelection()?.toString() || null
open.value = true
query.value = ''
commandError.value = ''
void loadPluginCommands()
void nextTick(() => input.value?.focus())
}
@@ -44,7 +75,11 @@ function hide() { open.value = false }
async function execute(command: Command | undefined) {
if (!command) return
hide()
try {
await command.run()
} catch (error) {
commandNotice.value = error instanceof Error ? error.message : '命令执行失败'
}
}
async function createNote() {
@@ -58,6 +93,55 @@ async function createNote() {
await router.push('/workspace')
}
async function loadPluginCommands() {
try {
pluginCommands.value = await pluginService.listPluginCommands('command_palette')
} catch (error) {
commandError.value = error instanceof Error ? error.message : 'Plugin 命令加载失败'
}
}
function hasRequiredArguments(command: PluginCommand) {
return Array.isArray(command.parameters.required) && command.parameters.required.length > 0
}
async function executePluginCommand(command: PluginCommand) {
if (hasRequiredArguments(command)) {
pluginStore.selectPlugin(command.plugin_id)
await router.push('/extensions/plugins')
commandNotice.value = '请在 Plugin 详情页填写参数后执行“' + command.title + '”。'
return
}
const result = await pluginService.executePluginCommand(command.command_id, {}, {
vault_id: workspaceStore.hasVault ? workspaceStore.vaultId : null,
note_id: editorStore.currentNoteId,
file_path: editorStore.currentFilePath,
selection: selectionSnapshot.value,
})
await applyPluginEffect(result.effect)
}
async function applyPluginEffect(effect: PluginCommandEffect) {
if (effect.type === 'notification') { commandNotice.value = effect.payload.message; return }
if (effect.type === 'navigate') {
const routes: Record<string, string> = {
'vault-entry': '/', workspace: '/workspace', search: '/search', chat: '/chat',
agent: '/agent/runs', tasks: '/tasks', skills: '/extensions/skills',
plugins: '/extensions/plugins', themes: '/themes', settings: '/settings',
}
await router.push(routes[effect.payload.route])
return
}
if (effect.type === 'refresh') {
if (effect.payload.scope === 'plugins') await pluginStore.loadPlugins()
if (effect.payload.scope === 'commands') await loadPluginCommands()
commandNotice.value = '相关数据已刷新。'
return
}
if (effect.type === 'job') { commandNotice.value = '后台任务已创建:' + effect.payload.job_id; return }
commandNotice.value = 'Plugin 命令执行完成。'
}
function handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') {
event.preventDefault()
@@ -72,10 +156,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</script>
<template>
<div v-if="commandNotice" class="command-toast" role="status">
<span>{{ commandNotice }}</span><button aria-label="关闭通知" @click="commandNotice = ''">×</button>
</div>
<Teleport to="body">
<div v-if="open" class="command-backdrop" @click.self="hide">
<section class="command-palette" role="dialog" aria-modal="true" aria-label="命令面板">
<input ref="input" v-model="query" class="command-input" placeholder="输入命令…" @keydown.enter.prevent="execute(filteredCommands[0])" />
<p v-if="commandError" class="command-error">{{ commandError }}</p>
<div class="command-list">
<button v-for="command in filteredCommands" :key="command.id" type="button" @click="execute(command)">
<span>{{ command.label }}</span><small>{{ command.hint }}</small>
@@ -99,6 +187,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.command-list small, .command-list p, footer { color: var(--color-text-tertiary); }
.command-list p { padding: var(--space-xl); text-align: center; }
footer { display: flex; gap: var(--space-lg); padding: var(--space-sm) var(--space-lg); border-top: 1px solid var(--color-border-subtle); font-size: var(--font-size-xs); }
.command-error { margin: var(--space-sm); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-md); background: var(--color-error-soft); color: var(--color-error); font-size: var(--font-size-sm); }
.command-toast { position: fixed; top: 48px; right: var(--space-xl); z-index: calc(var(--z-modal) + 1); display: flex; align-items: center; gap: var(--space-lg); max-width: min(420px, calc(100vw - 32px)); padding: var(--space-md) var(--space-lg); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-elevated); box-shadow: var(--shadow-lg); animation: notice-in var(--motion-normal) both; }
.command-toast button { color: var(--color-text-tertiary); font-size: var(--font-size-xl); }
@keyframes command-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes command-palette-in { from { opacity: 0; transform: translateY(-8px) scale(.99); } to { opacity: 1; transform: translateY(0) scale(1); } }
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Search, Setting } from '@element-plus/icons-vue'
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Monitor, Search, Setting } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue'
const route = useRoute()
@@ -16,6 +16,7 @@ const navItems = [
{ name: 'tasks', icon: CircleCheck, label: '任务' },
{ name: 'skills', icon: Lightning, label: 'Skill' },
{ name: 'plugins', icon: Connection, label: 'Plugin' },
{ name: 'mcp-servers', icon: Monitor, label: 'MCP' },
{ name: 'themes', icon: Brush, label: '主题' },
{ name: 'settings', icon: Setting, label: '设置' },
]
+1 -1
View File
@@ -21,7 +21,7 @@ const pageTitle = computed(() => {
agent: '智能体执行轨迹',
tasks: '任务',
skills: 'Skill 管理',
plugins: 'Plugin 管理',
plugins: 'Plugin 与 MCP',
themes: '主题管理',
settings: '设置',
}
+126 -1
View File
@@ -193,7 +193,7 @@ export interface ToolDefinition {
name: string
description: string
parameters: Record<string, unknown>
source?: 'builtin' | 'plugin'
source?: 'builtin' | 'plugin' | 'mcp_server'
plugin_id?: string
}
@@ -271,6 +271,86 @@ export interface PluginHostStatus {
error?: string | null
}
export type PluginCommandLocation = 'command_palette' | 'context_menu' | 'toolbar'
export interface PluginCommand {
command_id: string
plugin_id: string
title: string
description: string
icon?: string | null
locations: PluginCommandLocation[]
when: string[]
parameters: Record<string, unknown>
enabled: boolean
}
export interface PluginCommandContext {
vault_id?: string | null
note_id?: string | null
file_path?: string | null
selection?: string | null
}
export type PluginCommandEffect =
| { type: 'none'; payload: Record<string, never> }
| {
type: 'notification'
payload: { level: 'info' | 'success' | 'warning' | 'error'; message: string }
}
| {
type: 'navigate'
payload: {
route:
| 'vault-entry'
| 'workspace'
| 'search'
| 'chat'
| 'agent'
| 'tasks'
| 'skills'
| 'plugins'
| 'themes'
| 'settings'
}
}
| { type: 'refresh'; payload: { scope: 'workspace' | 'commands' | 'settings' | 'plugins' } }
| { type: 'job'; payload: { job_id: string } }
export interface PluginCommandResult {
command_id: string
status: 'completed'
effect: PluginCommandEffect
}
export type PluginSettingType = 'string' | 'number' | 'boolean' | 'select' | 'secret'
export interface PluginSettingField {
key: string
label: string
description: string
type: PluginSettingType
required: boolean
default?: unknown
minimum?: number | null
maximum?: number | null
options: string[]
}
export interface PluginSettingsSchema {
plugin_id: string
schema_version: number
fields: PluginSettingField[]
values: Record<string, unknown>
secrets: Record<string, { configured: boolean }>
}
export interface PluginSecretStatus {
plugin_id: string
key: string
configured: boolean
}
export interface PluginContribution {
type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section'
id: string
@@ -455,6 +535,51 @@ export interface OperationResponse {
message?: string | null
}
export type McpServerTransport = 'stdio' | 'streamable_http' | 'sse'
export type McpServerState = 'stopped' | 'starting' | 'ready' | 'unhealthy' | 'error'
export interface McpServerInput {
version?: number
name: string
transport: McpServerTransport
command?: string | null
args: string[]
url?: string | null
headers: Record<string, string>
environment: Record<string, string>
secret_environment_keys: string[]
secret_header_keys: string[]
permissions: string[]
startup_timeout_seconds: number
tool_timeout_seconds: number
}
export interface McpServer extends Omit<McpServerInput, 'secret_environment_keys' | 'secret_header_keys'> {
server_id: string
version: number
secret_environment: Record<string, boolean>
secret_headers: Record<string, boolean>
enabled: boolean
trusted: boolean
command_digest: string
command_summary: string
status: McpServerState
tools_count: number
protocol_version?: string | null
remote_server_name?: string | null
remote_server_version?: string | null
error?: string | null
last_tested_at?: string | null
last_test_succeeded?: boolean | null
}
export interface McpToolSummary {
name: string
remote_name: string
description: string
permission?: string | null
}
export interface ApiNoteBlock {
block_id: string
note_id: string
@@ -27,6 +27,9 @@ beforeEach(() => {
if (filePath === '/数据结构/红黑树.md') return '# 红黑树\n\n新的文件内容'
throw new Error(`Unexpected file path: ${filePath}`)
})
vi.spyOn(workspaceService, 'getNoteId').mockImplementation(async (filePath) =>
filePath.includes('红黑树') ? 'note-rbt' : 'note-welcome'
)
})
afterEach(() => {
@@ -0,0 +1,169 @@
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { McpServer } from '@/contracts'
import * as service from '@/services/mcpServerService'
import McpServersView from './McpServersView.vue'
vi.mock('@/services/mcpServerService', () => ({
listMcpServers: vi.fn(), createMcpServer: vi.fn(), updateMcpServer: vi.fn(),
deleteMcpServer: vi.fn(), trustMcpServer: vi.fn(), testMcpServer: vi.fn(),
enableMcpServer: vi.fn(), disableMcpServer: vi.fn(), putMcpServerSecret: vi.fn(),
}))
const server: McpServer = {
server_id: 'server-1', version: 2, name: 'Remote', transport: 'streamable_http',
command: null, args: [], url: 'https://mcp.example.test/mcp', headers: {}, environment: {},
secret_environment: {}, secret_headers: { Authorization: false }, permissions: [],
startup_timeout_seconds: 15, tool_timeout_seconds: 30, enabled: false, trusted: true,
command_digest: 'a'.repeat(64), command_summary: 'https://mcp.example.test/mcp',
status: 'stopped', tools_count: 1, last_test_succeeded: false,
}
async function render(items: McpServer[] = []) {
vi.mocked(service.listMcpServers).mockResolvedValue(items)
const wrapper = mount(McpServersView, { global: { stubs: { AppIcon: true } } })
await flushPromises()
return wrapper
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('confirm', vi.fn(() => true))
})
describe('McpServersView', () => {
it('switches transport templates and round-trips the JSON configuration mode', async () => {
const wrapper = await render()
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'Streamable HTTP')!.trigger('click')
expect(wrapper.find('input[placeholder="https://example.com/mcp"]').exists()).toBe(true)
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
const raw = (wrapper.get('.json-editor').element as HTMLTextAreaElement).value
expect(JSON.parse(raw)).toMatchObject({ transport: 'streamable_http', command: null })
expect(raw).not.toContain('secret_value')
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
expect(wrapper.text()).toContain('MCP URL')
})
it('rejects invalid JSON without sending a create request', async () => {
const wrapper = await render()
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue('{invalid')
await flushPromises()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('服务器配置不是有效 JSON')
expect(service.createMcpServer).not.toHaveBeenCalled()
})
it('keeps secrets request-only, exposes test failures, and confirms deletion', async () => {
const wrapper = await render([server])
const password = wrapper.get('input[type="password"]')
await password.setValue('request-only-secret')
vi.mocked(service.putMcpServerSecret).mockResolvedValue({} as never)
await wrapper.findAll('button').find(button => button.text() === '保存')!.trigger('click')
await flushPromises()
expect(service.putMcpServerSecret).toHaveBeenCalledWith('server-1', 'Authorization', 'request-only-secret', 'header')
expect((password.element as HTMLInputElement).value).toBe('')
vi.mocked(service.testMcpServer).mockRejectedValue(new Error('连接失败'))
await wrapper.findAll('button').find(button => button.text().includes('测试连接'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('连接失败')
vi.mocked(service.deleteMcpServer).mockResolvedValue({ status: 'completed' })
await wrapper.findAll('button').find(button => button.text().includes('删除'))!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalled()
expect(service.deleteMcpServer).toHaveBeenCalledWith('server-1')
})
it('confirms permission changes before updating an existing server', async () => {
const wrapper = await render([server])
vi.mocked(service.updateMcpServer).mockResolvedValue(server)
await wrapper.findAll('button').find(button => button.text().includes('编辑'))!.trigger('click')
await wrapper.get('input[placeholder="network.request, notes.read"]').setValue('notes.read')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('旧测试与授权会失效'))
expect(service.updateMcpServer).toHaveBeenCalled()
})
it('saves an environment API key via the encrypted endpoint, not the config body', async () => {
const wrapper = await render()
vi.mocked(service.createMcpServer).mockResolvedValue({ ...server, server_id: 'new-server' })
vi.mocked(service.putMcpServerSecret).mockResolvedValue({})
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue(JSON.stringify({ command: 'uvx', environment: { MINIMAX_API_KEY: 'synthetic-only' }, secret_environment_keys: ['MINIMAX_API_KEY'] }))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.createMcpServer).toHaveBeenCalledWith(expect.objectContaining({ environment: {}, secret_environment_keys: ['MINIMAX_API_KEY'] }))
expect(JSON.stringify(vi.mocked(service.createMcpServer).mock.calls)).not.toContain('synthetic-only')
expect(service.putMcpServerSecret).toHaveBeenCalledWith('new-server', 'MINIMAX_API_KEY', 'synthetic-only', 'environment')
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
})
it('retains imported keys over mode switches and retries partial saves without duplicates', async () => {
const wrapper = await render()
vi.mocked(service.createMcpServer).mockResolvedValue({ ...server, server_id: 'new-server', version: 1 })
vi.mocked(service.updateMcpServer).mockResolvedValue({ ...server, server_id: 'new-server', version: 2 })
vi.mocked(service.putMcpServerSecret).mockRejectedValueOnce(new Error('credential store unavailable')).mockResolvedValue({})
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue(JSON.stringify({ command: 'uvx', env: { API_KEY: 'retry-value' } }))
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
expect(wrapper.text()).toContain('已识别 1 项密钥')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
expect((wrapper.get('.json-editor').element as HTMLTextAreaElement).value).not.toContain('retry-value')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('.modal-card [role="alert"]').text()).toContain('服务器配置已保存,但密钥保存失败')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.createMcpServer).toHaveBeenCalledTimes(1)
expect(service.updateMcpServer).toHaveBeenCalledWith('new-server', expect.objectContaining({ version: 1 }))
expect(service.putMcpServerSecret).toHaveBeenCalledTimes(2)
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
})
it('clears staged keys on cancel and accepts minimal JSON while editing', async () => {
const wrapper = await render([server])
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue('{"command":"uvx","env":{"API_KEY":"cancelled-value"}}')
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === '取消')!.trigger('click')
await wrapper.findAll('button').find(button => button.text().includes('编辑'))!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue('{"name":"Minimal","url":"https://example.test/mcp"}')
vi.mocked(service.updateMcpServer).mockResolvedValue(server)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.updateMcpServer).toHaveBeenCalledWith('server-1', expect.objectContaining({ version: 2, headers: {}, args: [] }))
expect(service.putMcpServerSecret).not.toHaveBeenCalled()
})
it('saves an imported Header secret after a case-only declaration rename', async () => {
const wrapper = await render()
vi.mocked(service.createMcpServer).mockResolvedValue({ ...server, secret_headers: { authorization: false } })
vi.mocked(service.putMcpServerSecret).mockResolvedValue({})
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
await wrapper.get('.json-editor').setValue(JSON.stringify({ url: 'https://example.test/mcp', headers: { Authorization: 'synthetic-draft' } }))
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
await wrapper.get('textarea[placeholder="Authorization"]').setValue('authorization')
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
expect(wrapper.text()).toContain('已识别 1 项密钥')
expect((wrapper.get('.json-editor').element as HTMLTextAreaElement).value).not.toContain('synthetic-draft')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.createMcpServer).toHaveBeenCalledWith(expect.objectContaining({ headers: {}, secret_header_keys: ['authorization'] }))
expect(service.putMcpServerSecret).toHaveBeenCalledWith('server-1', 'authorization', 'synthetic-draft', 'header')
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
})
})
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts'
import * as service from '@/services/mcpServerService'
import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson, type ImportedSecret, type SecretKind } from './configuration'
const servers = ref<McpServer[]>([])
const busy = ref('')
const error = ref('')
const dialogOpen = ref(false)
const editingId = ref<string | null>(null)
const editingOriginal = ref<McpServer | null>(null)
const editorMode = ref<'form' | 'json'>('form')
const argsText = ref('')
const environmentText = ref('{}')
const headersText = ref('{}')
const secretKeysText = ref('')
const secretHeaderKeysText = ref('')
const permissionsText = ref('')
const rawConfig = ref('')
const secretDrafts = reactive<Record<string, string>>({})
const form = reactive<McpServerInput>(emptyMcpConfig())
const importedSecrets = ref<ImportedSecret[]>([])
const dialogTitle = computed(() => editingId.value ? '编辑 MCP 服务器' : '新增 MCP 服务器')
async function load() {
error.value = ''
try { servers.value = await service.listMcpServers() }
catch (cause) { error.value = message(cause, '读取 MCP 服务器失败') }
}
function resetEditor(input: McpServerInput) {
Object.assign(form, emptyMcpConfig(), { version: undefined }, input)
argsText.value = input.args.join('\n')
environmentText.value = JSON.stringify(input.environment, null, 2)
headersText.value = JSON.stringify(input.headers, null, 2)
secretKeysText.value = input.secret_environment_keys.join('\n')
secretHeaderKeysText.value = input.secret_header_keys.join('\n')
permissionsText.value = input.permissions.join(', ')
editorMode.value = 'form'
rawConfig.value = ''
}
function openCreate() {
if (busy.value) return
error.value = ''
importedSecrets.value = []
editingId.value = null
editingOriginal.value = null
resetEditor(emptyMcpConfig())
dialogOpen.value = true
}
function openEdit(server: McpServer) {
if (busy.value) return
error.value = ''
importedSecrets.value = []
editingId.value = server.server_id
editingOriginal.value = server
resetEditor({
version: server.version, name: server.name, transport: server.transport,
command: server.command, args: [...server.args], url: server.url,
headers: { ...server.headers }, environment: { ...server.environment },
secret_environment_keys: Object.keys(server.secret_environment),
secret_header_keys: Object.keys(server.secret_headers), permissions: [...server.permissions],
startup_timeout_seconds: server.startup_timeout_seconds,
tool_timeout_seconds: server.tool_timeout_seconds,
})
dialogOpen.value = true
}
function applyTemplate(transport: McpServerTransport) {
form.transport = transport
if (transport === 'stdio') {
form.command = 'uvx'; form.url = null
argsText.value = '--isolated\n--from\npackage-name==1.0.0\nserver-command'
} else {
form.command = null; argsText.value = ''; form.url = transport === 'sse' ? 'http://127.0.0.1:3000/sse' : 'http://127.0.0.1:3000/mcp'
}
}
function parseObject(value: string, label: string): Record<string, string> {
let parsed: unknown
try { parsed = JSON.parse(value || '{}') } catch { throw new Error(`${label}必须是 JSON 对象`) }
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' || Object.values(parsed).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`)
return parsed as Record<string, string>
}
function formPayload(): McpServerInput {
const stdio = form.transport === 'stdio'
return {
version: form.version,
name: form.name.trim(), transport: form.transport,
command: stdio ? form.command?.trim() : null,
args: stdio ? argsText.value.split('\n').map(value => value.trim()).filter(Boolean) : [],
url: stdio ? null : form.url?.trim(),
headers: stdio ? {} : parseObject(headersText.value, '普通 Header'),
environment: stdio ? parseObject(environmentText.value, '普通环境变量') : {},
secret_environment_keys: stdio ? splitKeys(secretKeysText.value) : [],
secret_header_keys: stdio ? [] : splitKeys(secretHeaderKeysText.value),
permissions: permissionsText.value.split(',').map(value => value.trim()).filter(Boolean),
startup_timeout_seconds: form.startup_timeout_seconds,
tool_timeout_seconds: form.tool_timeout_seconds,
}
}
function payload(requireConnection = true): McpServerInput {
const { config, secrets } = editorMode.value === 'form'
? normalizeMcpConfig(formPayload(), '', requireConnection) : parseMcpJson(rawConfig.value, form.name, requireConnection)
// Keep only still-declared drafts. A mode switch must not discard imported keys,
// and editing the declaration must not later send a removed key to the Secret API.
importedSecrets.value = mergeImportedSecrets(config, importedSecrets.value, secrets)
if (editingId.value) config.version = form.version
if (editorMode.value === 'json') rawConfig.value = JSON.stringify(config, null, 2)
else {
environmentText.value = JSON.stringify(config.environment, null, 2)
headersText.value = JSON.stringify(config.headers, null, 2)
secretKeysText.value = config.secret_environment_keys.join('\n')
secretHeaderKeysText.value = config.secret_header_keys.join('\n')
}
return config
}
function switchMode(mode: 'form' | 'json') {
try {
if (mode === editorMode.value) return
error.value = ''
if (mode === 'json') rawConfig.value = JSON.stringify(payload(false), null, 2)
else resetEditor(payload(false))
editorMode.value = mode
} catch (cause) { error.value = message(cause, '配置转换失败') }
}
async function save() {
if (busy.value) return
let saved: McpServer | undefined
try {
error.value = ''
const input = payload()
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error('请填写服务器名称和连接地址')
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?')) return
busy.value = 'save'
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
// Commit the returned ID/version before saving secrets so a partial failure can
// retry this server instead of creating a duplicate or sending a stale version.
editingId.value = saved.server_id
editingOriginal.value = saved
resetEditor({ ...input, version: saved.version })
for (const item of [...importedSecrets.value]) {
await service.putMcpServerSecret(saved.server_id, item.key, item.value, item.kind)
importedSecrets.value = importedSecrets.value.filter(candidate => candidate !== item)
}
closeEditor()
await load()
} catch (cause) {
if (saved) await load()
error.value = `${saved ? '服务器配置已保存,但密钥保存失败;可点击保存重试。' : ''}${message(cause, '保存失败')}`
}
finally { busy.value = '' }
}
function closeEditor() {
importedSecrets.value = []
rawConfig.value = ''
environmentText.value = '{}'
headersText.value = '{}'
dialogOpen.value = false
}
function executionChanged(server: McpServer, input: McpServerInput) {
const sortedEntries = (value: Record<string, string>) => Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
const current = [
server.transport, server.command, server.args, server.url,
sortedEntries(server.headers), sortedEntries(server.environment),
Object.keys(server.secret_headers).sort(), Object.keys(server.secret_environment).sort(),
[...server.permissions].sort(), server.startup_timeout_seconds, server.tool_timeout_seconds,
]
const next = [
input.transport, input.command, input.args, input.url,
sortedEntries(input.headers), sortedEntries(input.environment),
[...input.secret_header_keys].sort(), [...input.secret_environment_keys].sort(),
[...input.permissions].sort(), input.startup_timeout_seconds, input.tool_timeout_seconds,
]
return JSON.stringify(current) !== JSON.stringify(next)
}
async function approve(server: McpServer): Promise<McpServer | null> {
if (server.trusted) return server
const localWarning = server.transport === 'stdio' ? '\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。' : '\n\n连接可能向该地址发送配置的 Header。'
if (!confirm(`请确认 MCP 连接:\n\n${server.command_summary}${localWarning}\n\n是否继续?`)) return null
return service.trustMcpServer(server)
}
async function test(server: McpServer) { await act(server, 'test', current => service.testMcpServer(current.server_id)) }
async function toggle(server: McpServer) { await act(server, 'toggle', current => current.enabled ? service.disableMcpServer(current.server_id) : service.enableMcpServer(current.server_id)) }
async function act(server: McpServer, action: string, operation: (server: McpServer) => Promise<McpServer>) {
busy.value = `${action}:${server.server_id}`; error.value = ''
try { const current = action === 'toggle' && server.enabled ? server : await approve(server); if (!current) return; await operation(current); await load() }
catch (cause) { error.value = message(cause, '操作失败') }
finally { busy.value = '' }
}
async function remove(server: McpServer) {
if (!confirm(`删除“${server.name}”及其加密凭据?`)) return
try { busy.value = `delete:${server.server_id}`; await service.deleteMcpServer(server.server_id); await load() }
catch (cause) { error.value = message(cause, '删除失败') } finally { busy.value = '' }
}
async function saveSecret(server: McpServer, key: string, kind: SecretKind) {
const draftKey = `${server.server_id}:${kind}:${key}`
const value = secretDrafts[draftKey]?.trim()
if (!value) return
try { busy.value = `secret:${draftKey}`; await service.putMcpServerSecret(server.server_id, key, value, kind); secretDrafts[draftKey] = ''; await load() }
catch (cause) { error.value = message(cause, '保存密钥失败') } finally { busy.value = '' }
}
function splitKeys(value: string) { return value.split(/[\n,]/).map(item => item.trim()).filter(Boolean) }
function message(cause: unknown, fallback: string) { return cause instanceof Error ? cause.message : fallback }
onMounted(load)
</script>
<template>
<section class="feature-page mcp-page">
<header class="feature-header"><div><h1>MCP 服务器</h1><p>管理独立 MCP Server 的连接凭据与工具生命周期</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> 刷新</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> 新增服务器</button></div></header>
<div class="notice-banner">stdio 本机进程仅在开发环境开放Streamable HTTP 为首选远程传输SSE 仅用于兼容旧服务器uvx 隔离依赖但不是安全沙箱</div>
<div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="!servers.length" class="panel empty"><AppIcon :icon="Connection" :size="34" /><h2>尚未配置 MCP 服务器</h2><p>添加 Server,测试连接成功后才能启用工具。</p><button class="button-primary" @click="openCreate">新增服务器</button></div>
<div v-else class="server-list">
<article v-for="server in servers" :key="server.server_id" class="panel server-card">
<div class="server-main"><div class="server-title"><AppIcon :icon="Connection" :size="24" /><div><h2>{{ server.name }}</h2><code>{{ server.command_summary }}</code></div></div><span class="badge" :class="{ success: server.status === 'ready', error: ['error','unhealthy'].includes(server.status) }">{{ server.status }}</span></div>
<div class="metadata"><span>{{ server.transport }}</span><span>v{{ server.version }}</span><span>{{ server.tools_count }} 个工具</span><span>{{ server.trusted ? '连接已确认' : '等待确认连接' }}</span><span v-if="server.last_test_succeeded">当前配置测试成功</span><span v-if="server.remote_server_name">{{ server.remote_server_name }} {{ server.remote_server_version }}</span></div>
<div v-if="server.error" class="error-banner compact">{{ server.error }}</div>
<div v-if="Object.keys(server.secret_environment).length || Object.keys(server.secret_headers).length" class="secrets">
<label v-for="(configured, key) in server.secret_environment" :key="`env:${key}`"><span>环境变量 · {{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:environment:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key, 'environment')">保存</button></span></label>
<label v-for="(configured, key) in server.secret_headers" :key="`header:${key}`"><span>HTTP Header · {{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:header:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key, 'header')">保存</button></span></label>
</div>
<footer class="card-actions"><button class="button-secondary" :disabled="!!busy || server.enabled" @click="test(server)"><AppIcon :icon="VideoPlay" /> 测试连接</button><button class="button-secondary" :disabled="!!busy" @click="openEdit(server)"><AppIcon :icon="EditPen" /> 编辑</button><button class="button-danger" :disabled="!!busy" @click="remove(server)"><AppIcon :icon="Delete" /> 删除</button><button class="button-primary" :disabled="!!busy || (!server.enabled && !server.last_test_succeeded)" :title="!server.enabled && !server.last_test_succeeded ? '请先测试当前配置' : ''" @click="toggle(server)">{{ server.enabled ? '停用' : '启用' }}</button></footer>
</article>
</div>
<div v-if="dialogOpen" class="modal-backdrop" @click.self="!busy && closeEditor()">
<form class="modal-card" @submit.prevent="save">
<fieldset :disabled="!!busy" class="editor-fields">
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="closeEditor">×</button></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div v-if="importedSecrets.length" class="notice-banner">已识别 {{ importedSecrets.length }} 项密钥保存时将单独加密不会写入普通服务器配置取消将清除未保存密钥</div>
<div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">表单配置</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">JSON 配置</button></div>
<template v-if="editorMode === 'form'">
<label>服务器名称<input v-model="form.name" maxlength="80" placeholder="例如:文件系统工具"></label>
<div class="template-row"><span>服务器配置</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio 模板</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE兼容</button></div>
<template v-if="form.transport === 'stdio'"><label>可执行命令<input v-model="form.command" placeholder="uvx、npx 或可信可执行文件路径"></label><label>参数(每行一项)<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>普通环境变量(JSON<textarea v-model="environmentText" rows="5"></textarea></label><label>敏感环境变量名(每行一项)<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template>
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>普通 HeaderJSON<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"NotesAgent"}'></textarea></label><label>敏感 Header 名(每行一项)<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
<label>声明权限逗号分隔可选<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
<div class="two-columns"><label>启动超时<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>工具超时<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
</template>
<label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>支持 NotesAgent 配置command/args/env 和单服务器 mcpServers 配置已声明的 Secret 及常见 API KeyTokenAuthorization 会拆分后加密保存其他敏感值请显式声明不要把密钥放入命令或参数</small><small>兼容导入 timeout 为启动超时sse_read_timeout 为工具等待上限不保留 SSE 读取超时语义</small></label>
<footer><button type="button" class="button-secondary" @click="closeEditor">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
</fieldset>
</form>
</div>
</section>
</template>
<style scoped>
.editor-fields { display: grid; gap: var(--space-lg); border: 0; padding: 0; margin: 0; min-width: 0; }
.mcp-page { overflow: auto; }.notice-banner,.error-banner { margin-bottom: var(--space-lg); }.server-list { display: grid; gap: var(--space-lg); }.server-card { display: grid; gap: var(--space-md); }
.server-main,.server-title,.metadata,.card-actions,.inline-actions,.template-row,.modal-card header,.modal-card footer { display: flex; align-items: center; gap: var(--space-sm); }.server-main { justify-content: space-between; }.server-title { align-items: flex-start; }.server-title h2 { margin-bottom: 4px; }.server-title code { color: var(--color-text-secondary); overflow-wrap: anywhere; }.metadata { flex-wrap: wrap; color: var(--color-text-tertiary); font-size: var(--font-size-sm); }.metadata span + span::before { content: '·'; margin-right: var(--space-sm); }.compact { margin: 0; }
.card-actions { justify-content: flex-end; border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-md); }.empty { text-align: center; place-items: center; display: grid; gap: var(--space-md); padding: 64px; }.secrets { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-md); display: grid; gap: var(--space-sm); }.secrets label { display: grid; grid-template-columns: minmax(220px,.7fr) 1fr; align-items: center; gap: var(--space-md); }.secrets small,.modal-card small { color: var(--color-text-tertiary); }.secret-input { display: flex; gap: var(--space-sm); }.secret-input input { flex: 1; }
.modal-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgb(0 0 0 / .48); display: grid; place-items: center; padding: var(--space-xl); }.modal-card { width: min(800px,100%); max-height: calc(100vh - 48px); overflow: auto; background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); box-shadow: var(--shadow-xl); padding: var(--space-xl); display: grid; gap: var(--space-lg); animation: modal-in var(--motion-normal) ease-out; }.modal-card header,.modal-card footer { justify-content: space-between; }.modal-card footer { justify-content: flex-end; }.modal-card label { display: grid; gap: var(--space-xs); font-weight: 600; }.modal-card input,.modal-card textarea { width: 100%; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); padding: 10px 12px; color: var(--color-text-primary); background: var(--color-background-secondary); font: inherit; }.modal-card textarea { resize: vertical; font-family: var(--font-family-mono); font-size: var(--font-size-sm); }.json-editor { line-height: 1.55; }.close { border: 0; background: transparent; color: var(--color-text-secondary); font-size: 28px; cursor: pointer; }
.template-row { flex-wrap: wrap; }.template-row > span { margin-right: auto; font-weight: 600; }.template,.mode-tabs button { border: 1px solid var(--color-border-default); background: var(--color-background-secondary); color: var(--color-text-secondary); padding: 7px 10px; border-radius: var(--radius-md); cursor: pointer; }.template.active,.mode-tabs button.active { color: var(--color-accent-primary); border-color: var(--color-accent-primary); background: var(--color-accent-soft); }.mode-tabs { display: inline-flex; justify-self: start; gap: 2px; padding: 3px; border-radius: var(--radius-md); background: var(--color-background-secondary); }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(.99); } } @media (max-width:720px) { .two-columns,.secrets label { grid-template-columns:1fr; }.card-actions { flex-wrap:wrap; } }
</style>
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson } from './configuration'
describe('MCP configuration normalization', () => {
it('retains renamed HTTP drafts with the latest spelling and value', () => {
const config = { ...emptyMcpConfig(), secret_header_keys: ['authorization'] }
const previous = [{ kind: 'header' as const, key: 'Authorization', value: 'old-value' }]
expect(mergeImportedSecrets(config, previous, [])).toEqual([{ kind: 'header', key: 'authorization', value: 'old-value' }])
expect(mergeImportedSecrets(config, previous, [{ kind: 'header', key: 'AUTHORIZATION', value: 'new-value' }])).toEqual([{ kind: 'header', key: 'authorization', value: 'new-value' }])
expect(mergeImportedSecrets(emptyMcpConfig(), previous, [])).toEqual([])
})
it('does not transfer an environment draft across a case-only rename', () => {
const config = { ...emptyMcpConfig(), secret_environment_keys: ['TOKEN', 'token'] }
const previous = [{ kind: 'environment' as const, key: 'TOKEN', value: 'upper' }, { kind: 'environment' as const, key: 'token', value: 'lower' }]
expect(mergeImportedSecrets(config, previous, [])).toEqual(previous)
expect(mergeImportedSecrets({ ...config, secret_environment_keys: ['token'] }, [previous[0]!], [])).toEqual([])
})
it('fills backend defaults for minimal JSON', () => {
const { config } = parseMcpJson('{"name":"demo","command":"uvx"}')
expect(config).toMatchObject({ transport: 'stdio', args: [], headers: {}, environment: {}, permissions: [], secret_header_keys: [] })
})
it('extracts a key pasted into environment despite its existing secret declaration', () => {
const { config, secrets } = normalizeMcpConfig({
name: 'MiniMax', command: 'uvx', secret_environment_keys: ['MINIMAX_API_KEY'],
environment: { MINIMAX_API_KEY: 'synthetic-key', MINIMAX_API_HOST: 'https://api.minimaxi.com' },
})
expect(config.environment).toEqual({ MINIMAX_API_HOST: 'https://api.minimaxi.com' })
expect(config.secret_environment_keys).toEqual(['MINIMAX_API_KEY'])
expect(JSON.stringify(config)).not.toContain('synthetic-key')
expect(secrets).toEqual([{ kind: 'environment', key: 'MINIMAX_API_KEY', value: 'synthetic-key' }])
})
it('imports a standard single-server wrapper and legacy timeouts', () => {
const { config, secrets } = normalizeMcpConfig({ mcpServers: { MiniMax: {
command: 'uvx', args: ['--with', 'mcp<2', 'minimax-coding-plan-mcp', '-y'],
env: { MINIMAX_API_KEY: 'synthetic-key' }, timeout: 120, sse_read_timeout: 300,
} } })
expect(config).toMatchObject({ name: 'MiniMax', transport: 'stdio', environment: {}, startup_timeout_seconds: 120, tool_timeout_seconds: 300 })
expect(secrets).toHaveLength(1)
})
it('extracts case-insensitive HTTP credentials without duplicate declarations', () => {
const { config, secrets } = normalizeMcpConfig({ url: 'https://example.test/mcp', headers: { authorization: 'synthetic' }, secret_header_keys: ['Authorization'] })
expect(config.headers).toEqual({})
expect(config.secret_header_keys).toEqual(['Authorization'])
expect(secrets[0]?.key).toBe('Authorization')
})
it.each([
[{ command: 'uvx', args: 'not-array' }, 'args'],
[{ command: 'uvx', environment: [] }, 'environment'],
[{ command: 'uvx', timeout: 121 }, '启动超时'],
[{ command: 'uvx', args: ['[https://example.test](https://example.test)'] }, '纯 URL'],
[{ command: 'uvx', api_key: 'do-not-echo' }, '顶层'],
[{ command: 'uvx', env: {}, environment: {} }, '只保留一个'],
[{ mcpServers: { one: {}, two: {} } }, '一次导入一个'],
])('rejects invalid fields without leaking their values', (input, hint) => {
expect(() => normalizeMcpConfig(input)).toThrow(hint)
try { normalizeMcpConfig(input) } catch (error) { expect(String(error)).not.toContain('do-not-echo') }
})
})
+139
View File
@@ -0,0 +1,139 @@
import type { McpServerInput } from '@/contracts'
export type SecretKind = 'environment' | 'header'
export interface ImportedSecret { kind: SecretKind; key: string; value: string }
export function mergeImportedSecrets(config: McpServerInput, previous: ImportedSecret[], incoming: ImportedSecret[]): ImportedSecret[] {
const merged = new Map<string, ImportedSecret>()
for (const item of [...previous, ...incoming]) {
const normalize = (key: string) => item.kind === 'header' ? key.toLowerCase() : key
const keys = item.kind === 'header' ? config.secret_header_keys : config.secret_environment_keys
const declared = keys.find(key => normalize(key) === normalize(item.key))
if (declared === undefined) continue
// HTTP identity is case-insensitive, but the Secret API requires the current
// declared spelling. New inline values replace older drafts of that identity.
merged.set(`${item.kind}:${normalize(declared)}`, { ...item, key: declared })
}
return [...merged.values()]
}
export function emptyMcpConfig(): McpServerInput {
return {
name: '', transport: 'stdio', command: '', args: [], url: null, headers: {},
environment: {}, secret_environment_keys: [], secret_header_keys: [], permissions: [],
startup_timeout_seconds: 15, tool_timeout_seconds: 30,
}
}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}必须是 JSON 对象`)
return value as Record<string, unknown>
}
function strings(value: unknown, label: string): string[] {
if (value === undefined) return []
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串数组`)
return [...value]
}
function entries(value: unknown, label: string): Record<string, string> {
if (value === undefined) return {}
const result = object(value, label)
if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`)
return { ...result } as Record<string, string>
}
function timeout(value: unknown, fallback: number, max: number, label: string): number {
if (value === undefined) return fallback
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}必须是 1${max} 秒之间的数字`)
return value
}
// Do not silently rewrite executable arguments or secret values copied from chat.
function checkUrl(value: string, label: string) {
if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}请填写纯 URL,不要粘贴 Markdown 链接`)
}
export function parseMcpJson(raw: string, fallbackName = '', requireConnection = true) {
let parsed: unknown
try { parsed = JSON.parse(raw) }
catch { throw new Error('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义') }
return normalizeMcpConfig(parsed, fallbackName, requireConnection)
}
/** Normalize external client JSON before it reaches either the form or the API.
* Inline secrets leave the public config here and are sent only to the Secret API.
*/
export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) {
let raw = object(parsed, '服务器配置')
if ('mcpServers' in raw) {
const servers = Object.entries(object(raw.mcpServers, 'mcpServers'))
if (servers.length !== 1) throw new Error('请一次导入一个 MCP 服务器')
fallbackName = servers[0]![0]
raw = object(servers[0]![1], '服务器配置')
}
const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout'])
if (Object.keys(raw).some(key => !allowed.has(key))) {
// Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys.
throw new Error('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层')
}
if (raw.env !== undefined && raw.environment !== undefined) throw new Error('env 与 environment 请只保留一个,避免覆盖配置')
const transport = raw.transport ?? raw.type ?? (raw.url ? 'streamable_http' : 'stdio')
if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error('transport 必须是 stdio、streamable_http 或 sse')
const config = emptyMcpConfig()
config.transport = transport as McpServerInput['transport']
const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : 'MCP 服务器'))
if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error('服务器名称必须为 180 个字符')
config.name = name.trim()
for (const key of ['command', 'url'] as const) {
const value = raw[key]
if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}必须是字符串`)
config[key] = typeof value === 'string' ? value.trim() : null
}
config.args = strings(raw.args, 'args')
if (config.args.length > 64) throw new Error('args 最多允许 64 项')
for (const value of config.args) checkUrl(value, 'args 中的地址')
config.environment = entries(raw.environment ?? raw.env, 'environment/env')
config.headers = entries(raw.headers, 'headers')
config.secret_environment_keys = [...new Set(strings(raw.secret_environment_keys, 'secret_environment_keys'))]
config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))]
config.permissions = strings(raw.permissions, 'permissions')
config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, '启动超时')
// Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting.
config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, '工具超时')
if (config.transport === 'stdio') {
if (requireConnection && !config.command) throw new Error('stdio 配置必须填写 command')
if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error('stdio 配置不能包含 URL 或 HTTP Header')
} else {
if (requireConnection && !config.url) throw new Error('HTTP/SSE 配置必须填写 url')
if (config.url) {
checkUrl(config.url, 'url')
let url: URL
try { url = new URL(config.url) } catch { throw new Error('url 必须是有效的 HTTP(S) 地址') }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('url 必须为不含账号密码或片段的 HTTP(S) 地址')
}
if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error('HTTP/SSE 配置不能包含 command、args 或环境变量')
}
const secrets: ImportedSecret[] = []
for (const kind of ['environment', 'header'] as const) {
const values = kind === 'environment' ? config.environment : config.headers
const keys = kind === 'environment' ? config.secret_environment_keys : config.secret_header_keys
const identity = (key: string) => kind === 'header' ? key.toLowerCase() : key
const allKeys = [...Object.keys(values), ...keys]
if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error('HTTP Header 名称不能仅大小写不同而重复声明')
const validKey = kind === 'environment' ? /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/
if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? '环境变量' : 'Header'}名称无效;敏感变量名只能填名称,不能填密钥值`)
for (const [key, value] of Object.entries(values)) {
const declared = keys.find(item => identity(item) === identity(key))
const sensitive = /api[_-]?key|token|secret|password|authorization|cookie|credential/i.test(key)
if (declared || sensitive) {
if (!value || value.length > 32768) throw new Error('密钥值必须为 132768 个字符')
const secretKey = declared ?? key
if (!declared) keys.push(key)
secrets.push({ kind, key: secretKey, value })
delete values[key]
} else if (/host|url|endpoint/i.test(key)) checkUrl(value, '环境变量或 Header 地址')
}
}
return { config, secrets }
}
@@ -0,0 +1,109 @@
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plugin } from '@/contracts'
import * as pluginService from '@/services/pluginService'
import PluginMcpPanel from './PluginMcpPanel.vue'
import { useWorkspaceStore } from '@/stores/workspace'
vi.mock('@/services/pluginService', async (loadOriginal) => {
const original = await loadOriginal<typeof import('@/services/pluginService')>()
return {
...original,
getPluginHostStatus: vi.fn(),
restartPluginHost: vi.fn(),
getPluginSettings: vi.fn(),
updatePluginSettings: vi.fn(),
putPluginSecret: vi.fn(),
deletePluginSecret: vi.fn(),
listPluginCommands: vi.fn(),
executePluginCommand: vi.fn(),
}
})
const plugin: Plugin = {
plugin_id: 'mcp-demo',
name: 'MCP Demo',
version: '1.0.0',
description: 'demo',
status: 'ready',
enabled: true,
permissions: [],
contributions: [
{ type: 'settings_section', id: 'mcp-demo.general', name: 'settings' },
{ type: 'command', id: 'mcp-demo.run', name: 'run' },
],
backend_type: 'mcp',
transport: 'stdio',
}
async function render() {
const pinia = createPinia()
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
})
await router.push('/')
const wrapper = mount(PluginMcpPanel, {
props: { plugin },
global: { plugins: [pinia, router], stubs: { AppIcon: true } },
})
const workspaceStore = useWorkspaceStore(pinia)
workspaceStore.vaultId = 'default'
workspaceStore.hasVault = true
return wrapper
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(pluginService.getPluginHostStatus).mockResolvedValue({
plugin_id: 'mcp-demo', backend_type: 'mcp', transport: 'stdio',
status: 'ready', tools_count: 2, server_name: 'demo',
})
vi.mocked(pluginService.getPluginSettings).mockResolvedValue({
plugin_id: 'mcp-demo',
schema_version: 1,
fields: [
{ key: 'limit', label: '数量', description: '', type: 'number', required: true, options: [] },
{ key: 'api_key', label: 'API Key', description: '', type: 'secret', required: true, options: [] },
],
values: { limit: 5 },
secrets: { api_key: { configured: false } },
})
vi.mocked(pluginService.putPluginSecret).mockResolvedValue({
plugin_id: 'mcp-demo', key: 'api_key', configured: true,
})
vi.mocked(pluginService.listPluginCommands).mockResolvedValue([])
})
describe('PluginMcpPanel', () => {
it('loads MCP Host status and exposes restart controls', async () => {
const wrapper = await render()
await flushPromises()
expect(pluginService.getPluginHostStatus).toHaveBeenCalledWith('mcp-demo')
expect(wrapper.text()).toContain('demo')
expect(wrapper.text()).toContain('工具数量')
})
it('builds settings fields from schema and writes secrets separately', async () => {
const wrapper = await render()
const settingsTab = wrapper.findAll('button').find((button) => button.text() === '设置与密钥')
expect(settingsTab).toBeTruthy()
await settingsTab!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('数量')
expect(wrapper.text()).toContain('API Key')
await wrapper.get('input[type="password"]').setValue('secret-only-in-request')
const secretButton = wrapper.findAll('button').find((button) => button.text() === '安全保存')
expect(secretButton).toBeTruthy()
await secretButton!.trigger('click')
await flushPromises()
expect(pluginService.putPluginSecret).toHaveBeenCalledWith('mcp-demo', 'api_key', 'secret-only-in-request')
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
expect(wrapper.text()).toContain('已配置')
})
})
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { Key, Refresh, VideoPlay } from '@element-plus/icons-vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/common/AppIcon.vue'
import type { Plugin, PluginCommand, PluginHostStatus, PluginSettingField, PluginSettingsSchema } from '@/contracts'
import * as pluginService from '@/services/pluginService'
import { useEditorStore } from '@/stores/editor'
import { usePluginStore } from '@/stores/plugin'
import { useWorkspaceStore } from '@/stores/workspace'
const props = defineProps<{ plugin: Plugin }>()
const pluginStore = usePluginStore()
const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore()
const router = useRouter()
const activeTab = ref<'host' | 'settings' | 'commands'>('host')
const host = ref<PluginHostStatus | null>(null)
const schema = ref<PluginSettingsSchema | null>(null)
const values = ref<Record<string, unknown>>({})
//
const secrets = ref<Record<string, string>>({})
const commands = ref<PluginCommand[]>([])
const argumentsByCommand = ref<Record<string, Record<string, unknown>>>({})
const loading = ref(false)
const busy = ref('')
const error = ref('')
const notice = ref('')
let loadVersion = 0
const hasSettings = computed(() => props.plugin.contributions.some((item) => item.type === 'settings_section'))
const tabs = computed(() => [
...(props.plugin.backend_type === 'mcp' ? [{ id: 'host' as const, label: 'MCP Host' }] : []),
...(hasSettings.value ? [{ id: 'settings' as const, label: '设置与密钥' }] : []),
{ id: 'commands' as const, label: '插件命令' },
])
watch(() => props.plugin.plugin_id, () => {
loadVersion++
activeTab.value = props.plugin.backend_type === 'mcp' ? 'host' : hasSettings.value ? 'settings' : 'commands'
host.value = null
schema.value = null
values.value = {}
secrets.value = {}
commands.value = []
void loadActive()
}, { immediate: true })
function feedback(message = '') { error.value = message; notice.value = '' }
function message(reason: unknown, fallback: string) { return reason instanceof Error ? reason.message : fallback }
function formatTime(value?: string | null) { return value ? new Date(value).toLocaleString() : '—' }
async function selectTab(tab: typeof activeTab.value) {
activeTab.value = tab
await loadActive()
}
async function loadActive() {
const version = ++loadVersion
const pluginId = props.plugin.plugin_id
const tab = activeTab.value
feedback()
loading.value = true
try {
if (tab === 'host') {
const loadedHost = await pluginService.getPluginHostStatus(pluginId)
if (version === loadVersion) host.value = loadedHost
}
if (tab === 'settings') {
const loadedSchema = await pluginService.getPluginSettings(pluginId)
if (version === loadVersion) {
schema.value = loadedSchema
values.value = { ...loadedSchema.values }
}
}
if (tab === 'commands') {
const loadedCommands = (await pluginService.listPluginCommands()).filter((command) => command.plugin_id === pluginId)
if (version === loadVersion) {
commands.value = loadedCommands
for (const command of loadedCommands) argumentsByCommand.value[command.command_id] = {}
}
}
} catch (reason) {
if (version === loadVersion) feedback(message(reason, 'MCP 数据加载失败'))
} finally {
if (version === loadVersion) loading.value = false
}
}
async function restartHost() {
busy.value = 'host'
feedback()
try {
await pluginService.restartPluginHost(props.plugin.plugin_id)
host.value = await pluginService.getPluginHostStatus(props.plugin.plugin_id)
await pluginStore.loadPlugins()
notice.value = 'MCP Host 已重启。'
} catch (reason) { feedback(message(reason, 'MCP Host 重启失败')) } finally { busy.value = '' }
}
function updateValue(field: PluginSettingField, raw: string | boolean) {
values.value[field.key] = field.type === 'number' && typeof raw === 'string' ? (raw === '' ? null : Number(raw)) : raw
}
async function saveSettings() {
if (!schema.value) return
busy.value = 'settings'
feedback()
try {
schema.value = await pluginService.updatePluginSettings(props.plugin.plugin_id, schema.value.schema_version, values.value)
values.value = { ...schema.value.values }
notice.value = '普通设置已保存。'
} catch (reason) { feedback(message(reason, '设置保存失败')) } finally { busy.value = '' }
}
async function saveSecret(field: PluginSettingField) {
const secret = secrets.value[field.key]?.trim()
if (!secret) { feedback('请输入' + field.label); return }
busy.value = 'secret:' + field.key
feedback()
try {
const state = await pluginService.putPluginSecret(props.plugin.plugin_id, field.key, secret)
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = ''
notice.value = field.label + '已加密保存。'
} catch (reason) { feedback(message(reason, '密钥保存失败')) } finally { busy.value = '' }
}
async function deleteSecret(field: PluginSettingField) {
if (!confirm('删除已保存的' + field.label + '')) return
busy.value = 'secret:' + field.key
feedback()
try {
const state = await pluginService.deletePluginSecret(props.plugin.plugin_id, field.key)
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = ''
notice.value = field.label + '已删除。'
} catch (reason) { feedback(message(reason, '密钥删除失败')) } finally { busy.value = '' }
}
function properties(command: PluginCommand): Record<string, Record<string, unknown>> {
const result = command.parameters.properties
return result && typeof result === 'object' && !Array.isArray(result) ? result as Record<string, Record<string, unknown>> : {}
}
function required(command: PluginCommand, key: string) {
return Array.isArray(command.parameters.required) && command.parameters.required.includes(key)
}
function commandAvailable(command: PluginCommand) {
if (!command.enabled) return false
return command.when.every((condition) => {
if (condition === 'workspace.has_vault') return Boolean(workspaceStore.vaultId)
if (condition === 'editor.has_note') return Boolean(editorStore.currentNoteId)
// Plugin
if (condition === 'editor.has_selection') return false
return false
})
}
function updateArgument(commandId: string, key: string, raw: string, definition: Record<string, unknown>) {
const target = argumentsByCommand.value[commandId] ??= {}
if (definition.type === 'number' || definition.type === 'integer') target[key] = raw === '' ? undefined : Number(raw)
else if (definition.type === 'boolean') target[key] = raw === 'true'
else target[key] = raw
}
async function execute(command: PluginCommand) {
busy.value = command.command_id
feedback()
try {
const result = await pluginService.executePluginCommand(command.command_id, argumentsByCommand.value[command.command_id] ?? {}, {
vault_id: workspaceStore.hasVault ? workspaceStore.vaultId : null,
note_id: editorStore.currentNoteId,
file_path: editorStore.currentFilePath,
selection: null,
})
if (result.effect.type === 'notification') notice.value = result.effect.payload.message
else if (result.effect.type === 'job') notice.value = '后台任务已创建:' + result.effect.payload.job_id
else if (result.effect.type === 'navigate') {
const routes: Record<string, string> = {
'vault-entry': '/', workspace: '/workspace', search: '/search', chat: '/chat',
agent: '/agent/runs', tasks: '/tasks', skills: '/extensions/skills',
plugins: '/extensions/plugins', themes: '/themes', settings: '/settings',
}
await router.push(routes[result.effect.payload.route])
} else if (result.effect.type === 'refresh') {
await loadActive()
notice.value = '相关数据已刷新。'
} else notice.value = '命令执行完成。'
} catch (reason) { feedback(message(reason, '命令执行失败')) } finally { busy.value = '' }
}
</script>
<template>
<section class="mcp-panel">
<nav class="mcp-tabs" aria-label="MCP Plugin 配置">
<button v-for="tab in tabs" :key="tab.id" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">{{ tab.label }}</button>
</nav>
<div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="notice" class="notice-banner">{{ notice }}</div>
<div v-if="activeTab === 'host'" class="mcp-section">
<div class="section-head"><div><h3>MCP Host 状态</h3><p>查看协议协商运行状态与 Host 错误</p></div><div class="inline-actions"><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />刷新</button><button class="button-primary" :disabled="busy === 'host' || !plugin.enabled" @click="restartHost">{{ busy === 'host' ? '重启中' : '重启 Host' }}</button></div></div>
<div v-if="host" class="status-grid">
<div><span>状态</span><strong><i class="status-dot" :class="host.status"></i>{{ host.status }}</strong></div>
<div><span>服务</span><strong>{{ host.server_name || '—' }} {{ host.server_version || '' }}</strong></div>
<div><span>协议版本</span><strong>{{ host.protocol_version || '—' }}</strong></div>
<div><span>工具数量</span><strong>{{ host.tools_count }}</strong></div>
<div><span>启动时间</span><strong>{{ formatTime(host.started_at) }}</strong></div>
<div><span>最后心跳</span><strong>{{ formatTime(host.last_seen_at) }}</strong></div>
</div>
<div v-else-if="loading" class="empty-state">正在读取 Host 状态</div>
<div v-if="host?.error" class="error-banner host-error">{{ host.error }}</div>
<p class="security-hint">当前仅运行插件清单声明的 stdio MCP Server不开放任意 Shell 命令和环境变量编辑</p>
</div>
<div v-else-if="activeTab === 'settings'" class="mcp-section">
<div class="section-head"><div><h3>设置与密钥</h3><p>表单由后端 Schema 生成密钥不会被读取或回显</p></div><button class="button-primary" :disabled="!schema || busy === 'settings'" @click="saveSettings">{{ busy === 'settings' ? '保存中' : '保存普通设置' }}</button></div>
<div v-if="schema" class="settings-list">
<div v-for="field in schema.fields" :key="field.key" class="setting-row">
<div class="field-copy"><label :for="'plugin-setting-' + field.key"><AppIcon v-if="field.type === 'secret'" :icon="Key" :size="15" />{{ field.label }}<em v-if="field.required">必填</em></label><p>{{ field.description || (field.type === 'secret' ? '加密保存,不在页面回显。' : '') }}</p></div>
<template v-if="field.type === 'secret'">
<div class="secret-control"><input :id="'plugin-setting-' + field.key" :value="secrets[field.key] || ''" class="input" type="password" autocomplete="new-password" :placeholder="schema.secrets[field.key]?.configured ? '已配置;输入新值可替换' : '输入密钥'" @input="secrets[field.key] = ($event.target as HTMLInputElement).value"><button class="button-secondary" :disabled="!secrets[field.key]?.trim() || busy === 'secret:' + field.key" @click="saveSecret(field)">安全保存</button><button v-if="schema.secrets[field.key]?.configured" class="button-danger" @click="deleteSecret(field)">删除</button></div>
<span class="secret-state" :class="{ configured: schema.secrets[field.key]?.configured }">{{ schema.secrets[field.key]?.configured ? '已配置' : '未配置' }}</span>
</template>
<template v-else-if="field.type === 'boolean'"><label class="check-control"><input :id="'plugin-setting-' + field.key" type="checkbox" :checked="Boolean(values[field.key])" @change="updateValue(field, ($event.target as HTMLInputElement).checked)">{{ values[field.key] ? '开启' : '关闭' }}</label></template>
<template v-else-if="field.type === 'select'"><select :id="'plugin-setting-' + field.key" class="select" :value="values[field.key]" @change="updateValue(field, ($event.target as HTMLSelectElement).value)"><option v-for="option in field.options" :key="option" :value="option">{{ option }}</option></select></template>
<template v-else><input :id="'plugin-setting-' + field.key" class="input" :type="field.type === 'number' ? 'number' : 'text'" :min="field.minimum ?? undefined" :max="field.maximum ?? undefined" :required="field.required" :value="values[field.key] ?? ''" @input="updateValue(field, ($event.target as HTMLInputElement).value)"></template>
</div>
</div>
<div v-else-if="loading" class="empty-state">正在读取 Plugin 设置</div>
</div>
<div v-else class="mcp-section">
<div class="section-head"><div><h3>Plugin 命令</h3><p>执行该 Plugin 注册的受控 Command Contribution</p></div><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />刷新</button></div>
<div v-if="commands.length" class="command-list">
<article v-for="command in commands" :key="command.command_id" class="item-card command-card">
<div class="command-head"><div><strong>{{ command.title }}</strong><p>{{ command.description || command.command_id }}</p></div><span class="badge" :class="{ success: commandAvailable(command), warning: command.enabled && !commandAvailable(command) }">{{ commandAvailable(command) ? '可执行' : command.enabled ? '缺少上下文' : '不可用' }}</span></div>
<div v-if="Object.keys(properties(command)).length" class="command-fields">
<label v-for="(definition, key) in properties(command)" :key="key" class="field"><span>{{ String(definition.title || key) }}<em v-if="required(command, key)">必填</em></span><select v-if="Array.isArray(definition.enum)" class="select" @change="updateArgument(command.command_id, key, ($event.target as HTMLSelectElement).value, definition)"><option value="">请选择</option><option v-for="option in definition.enum" :key="String(option)" :value="String(option)">{{ option }}</option></select><select v-else-if="definition.type === 'boolean'" class="select" @change="updateArgument(command.command_id, key, ($event.target as HTMLSelectElement).value, definition)"><option value="false">否</option><option value="true">是</option></select><input v-else class="input" :type="definition.type === 'number' || definition.type === 'integer' ? 'number' : 'text'" @input="updateArgument(command.command_id, key, ($event.target as HTMLInputElement).value, definition)"></label>
</div>
<button class="button-primary command-run" :disabled="!commandAvailable(command) || busy === command.command_id" @click="execute(command)"><AppIcon :icon="VideoPlay" :size="15" />{{ busy === command.command_id ? '执行中…' : '执行命令' }}</button>
</article>
</div>
<div v-else-if="!loading" class="empty-state"><div><strong>没有可用命令</strong><p>启用 Plugin 已注册的命令会出现在这里</p></div></div>
</div>
</section>
</template>
<style scoped>
.mcp-panel { margin-top: var(--space-xl); padding-top: var(--space-xl); border-top: 1px solid var(--color-border-default); }
.mcp-tabs { display: flex; gap: var(--space-xs); margin-bottom: var(--space-xl); padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-background-secondary); }
.mcp-tabs button { padding: 9px var(--space-md); border-radius: var(--radius-md); color: var(--color-text-secondary); }
.mcp-tabs button:hover { background: var(--color-background-hover); }
.mcp-tabs button.active { background: var(--color-surface-primary); color: var(--color-accent-primary); box-shadow: var(--shadow-sm); }
.mcp-section { min-height: 220px; }
.section-head, .command-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-md); margin-bottom: var(--space-lg); }
.section-head p, .command-head p { margin-top: var(--space-xs); color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
.section-head button, .command-run { display: inline-flex; align-items: center; gap: var(--space-xs); }
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(165px, 1fr)); gap: var(--space-sm); }
.status-grid > div { display: grid; gap: var(--space-xs); padding: var(--space-md); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.status-grid span { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.status-grid strong { display: flex; align-items: center; gap: var(--space-xs); font-size: var(--font-size-sm); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-text-tertiary); }
.status-dot.ready { background: var(--color-success); box-shadow: 0 0 0 4px var(--color-success-soft); }
.status-dot.error, .status-dot.unhealthy { background: var(--color-error); box-shadow: 0 0 0 4px var(--color-error-soft); }
.status-dot.starting { background: var(--color-warning); box-shadow: 0 0 0 4px var(--color-warning-soft); }
.security-hint { margin-top: var(--space-lg); padding: var(--space-md); border-left: 3px solid var(--color-info); background: var(--color-info-soft); color: var(--color-text-secondary); font-size: var(--font-size-sm); }
.host-error { margin-top: var(--space-lg); }
.settings-list { display: grid; }
.setting-row { display: grid; grid-template-columns: minmax(180px, .9fr) minmax(260px, 1.1fr) auto; align-items: center; gap: var(--space-lg); padding: var(--space-lg) 0; border-bottom: 1px solid var(--color-border-subtle); }
.field-copy label { display: flex; align-items: center; gap: var(--space-xs); font-weight: 650; }
.field-copy p { margin-top: var(--space-xs); color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
em { margin-left: var(--space-xs); color: var(--color-error); font-size: var(--font-size-xs); font-style: normal; }
.secret-control { display: flex; gap: var(--space-xs); }
.secret-state { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.secret-state.configured { color: var(--color-success); }
.check-control { display: flex; align-items: center; gap: var(--space-sm); color: var(--color-text-secondary); }
.check-control input { width: 18px; height: 18px; accent-color: var(--color-accent-primary); }
.command-list, .command-card { display: grid; gap: var(--space-sm); }
.command-card:hover { transform: none; }
.command-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); }
.command-run { justify-self: end; }
@media (max-width: 800px) { .mcp-tabs { overflow-x: auto; } .mcp-tabs button { flex: 0 0 auto; } .setting-row { grid-template-columns: 1fr; gap: var(--space-sm); } .secret-control { flex-wrap: wrap; } }
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Connection } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import PluginMcpPanel from './PluginMcpPanel.vue'
import { onMounted, ref } from 'vue'
import { usePluginStore } from '@/stores/plugin'
@@ -16,7 +17,7 @@ async function uninstall(id: string, name: string) { if (!confirm(`卸载“${na
<template>
<section class="feature-page">
<header class="feature-header"><div><h1>Plugin 管理</h1><p>管理插件生命周期权限和受控 Contribution</p></div><button class="button-primary" @click="install">安装 Plugin</button></header>
<header class="feature-header"><div><h1>Plugin MCP</h1><p>管理插件生命周期MCP Host权限和受控 Contribution</p></div><button class="button-primary" @click="install">安装 Plugin</button></header>
<div v-if="pluginStore.error || actionError" class="error-banner">{{ pluginStore.error || actionError }}</div>
<div v-if="pluginStore.selectedPlugin" class="panel">
<div class="detail-head"><div><span class="badge" :class="{ success: pluginStore.selectedPlugin.status === 'ready', error: pluginStore.selectedPlugin.status === 'error', warning: pluginStore.selectedPlugin.status === 'permission_required' }">{{ pluginStore.selectedPlugin.status }}</span><h2>{{ pluginStore.selectedPlugin.icon }} {{ pluginStore.selectedPlugin.name }}</h2><p class="muted">v{{ pluginStore.selectedPlugin.version }} · {{ pluginStore.selectedPlugin.backend_type || 'none' }}/{{ pluginStore.selectedPlugin.transport || 'none' }}</p></div><div class="inline-actions"><button v-if="pluginStore.selectedPlugin.status === 'permission_required'" class="button-primary" @click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)">授权权限</button><button class="button-secondary" @click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)">{{ pluginStore.selectedPlugin.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)">卸载</button></div></div>
@@ -24,6 +25,7 @@ async function uninstall(id: string, name: string) { if (!confirm(`卸载“${na
<div class="detail-grid"><div><h3>权限</h3><div class="tag-list"><span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>Contribution</h3><div class="contribution-list"><div v-for="item in pluginStore.selectedPlugin.contributions" :key="item.id" class="item-card"><span class="badge info">{{ item.type }}</span><strong>{{ item.name }}</strong><p class="subtle">{{ item.description || item.id }}</p></div></div></div></div>
<div v-if="pluginStore.selectedPlugin.last_error" class="error-banner last-error">{{ pluginStore.selectedPlugin.last_error }}</div>
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">依赖此插件的 Skill{{ pluginStore.selectedPlugin.dependent_skills.join('') }}</div>
<PluginMcpPanel :plugin="pluginStore.selectedPlugin" />
</div>
<div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><AppIcon :icon="Connection" :size="22" /><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} Contribution</p></article></div>
</section>
@@ -22,7 +22,7 @@ async function waitForPath(path: string) {
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
vi.spyOn(workspaceService, 'openVault').mockResolvedValue({ path: 'C:/vault', name: 'vault' })
vi.spyOn(workspaceService, 'openVault').mockResolvedValue({ vault_id: 'default', path: 'C:/vault', name: 'vault' })
vi.spyOn(workspaceService, 'getFileTree').mockResolvedValue([
{
id: 'folder-data', name: '数据结构', path: '/数据结构', type: 'folder', is_open: true,
@@ -35,6 +35,9 @@ beforeEach(() => {
vi.spyOn(workspaceService, 'readFileContent').mockImplementation(async (path) =>
path.includes('红黑树') ? '# 红黑树\n' : '# 二叉搜索树\n'
)
vi.spyOn(workspaceService, 'getNoteId').mockImplementation(async (path) =>
path.includes('红黑树') ? 'note-rbt' : 'note-bst'
)
})
afterEach(() => {
@@ -63,10 +66,41 @@ describe('FileTreePanel file switching', () => {
await waitForPath('/数据结构/红黑树.md')
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md')
expect(editorStore.content).toContain('# 红黑树')
expect(editorStore.currentNoteId).toBe('note-rbt')
await findNode('二叉搜索树.md').trigger('click')
await waitForPath('/数据结构/二叉搜索树.md')
expect(workspaceStore.activeFilePath).toBe('/数据结构/二叉搜索树.md')
expect(editorStore.content).toContain('# 二叉搜索树')
expect(editorStore.currentNoteId).toBe('note-bst')
})
it('creates a Markdown note inside the selected folder', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/workspace', component: { template: '<div />' } }],
})
await router.push('/workspace')
await router.isReady()
const workspaceStore = useWorkspaceStore()
await workspaceStore.openVault('C:/vault')
const createFile = vi.spyOn(workspaceService, 'createFile').mockResolvedValue({
id: 'note-new', note_id: 'note-new', name: '新笔记.md',
path: '/数据结构/新笔记.md', type: 'file',
})
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
await wrapper.findAll('.tree-node').find((node) => node.text().includes('数据结构'))!.trigger('click')
await wrapper.get('button[aria-label="新建笔记"]').trigger('click')
await wrapper.get('.new-item input').setValue('新笔记')
await wrapper.get('.new-item').trigger('submit')
await waitForPath('/数据结构/新笔记.md')
await vi.waitFor(() => {
expect(workspaceStore.activeFilePath).toBe('/数据结构/新笔记.md')
})
expect(createFile).toHaveBeenCalledWith('/数据结构', '新笔记.md', '# 新笔记\n\n')
expect(wrapper.findAll('.tree-node').some((node) => node.classes().includes('active') && node.text().includes('新笔记.md'))).toBe(true)
})
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { FileNode } from '@/contracts'
import * as workspaceService from '@/services/workspaceService'
@@ -15,9 +15,19 @@ const router = useRouter()
const newItemType = ref<'file' | 'folder' | null>(null)
const newItemName = ref('')
const parentPath = ref('/')
const selectedTreePath = ref(workspaceStore.activeFilePath ?? '/')
const selectedFolderPath = ref(
workspaceStore.activeFilePath ? containingFolder(workspaceStore.activeFilePath) : '/',
)
const contextTarget = ref<FileNode | null>(null)
const contextMenuPosition = ref({ x: 0, y: 0 })
watch(() => workspaceStore.activeFilePath, (path) => {
if (!path) return
selectedTreePath.value = path
selectedFolderPath.value = containingFolder(path)
})
function beginCreate(type: 'file' | 'folder', parent = '/') {
newItemType.value = type
newItemName.value = ''
@@ -31,19 +41,28 @@ async function createItem() {
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
const file = await workspaceService.createFile(parentPath.value, name, `# ${rawName}\n\n`)
workspaceStore.addFileToTree(parentPath.value, file)
selectedTreePath.value = file.path
selectedFolderPath.value = parentPath.value
await editorStore.loadFile(file.path)
workspaceStore.openFile(file.path)
await router.push('/workspace')
} else {
const folder = await workspaceService.createFolder(parentPath.value, rawName)
workspaceStore.addFileToTree(parentPath.value, folder)
selectedTreePath.value = folder.path
selectedFolderPath.value = folder.path
}
newItemType.value = null
newItemName.value = ''
}
async function openNode(node: FileNode) {
if (node.type === 'folder') return workspaceStore.toggleFolder(node.path)
selectedTreePath.value = node.path
if (node.type === 'folder') {
selectedFolderPath.value = node.path
return workspaceStore.toggleFolder(node.path)
}
selectedFolderPath.value = containingFolder(node.path)
//
const previousPath = workspaceStore.activeFilePath
const wasOpen = workspaceStore.openFiles.includes(node.path)
@@ -61,6 +80,8 @@ async function openNode(node: FileNode) {
function openContextMenu(event: MouseEvent, node: FileNode) {
event.preventDefault()
event.stopPropagation()
selectedTreePath.value = node.path
selectedFolderPath.value = node.type === 'folder' ? node.path : containingFolder(node.path)
contextTarget.value = node
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
}
@@ -79,6 +100,12 @@ async function renameTarget() {
await workspaceService.renameFile(oldPath, normalizedName)
workspaceStore.renamePath(oldPath, newPath, normalizedName)
editorStore.renameFilePath(oldPath, newPath)
if (selectedTreePath.value === oldPath || selectedTreePath.value.startsWith(`${oldPath}/`)) {
selectedTreePath.value = `${newPath}${selectedTreePath.value.slice(oldPath.length)}`
}
if (selectedFolderPath.value === oldPath || selectedFolderPath.value.startsWith(`${oldPath}/`)) {
selectedFolderPath.value = `${newPath}${selectedFolderPath.value.slice(oldPath.length)}`
}
}
closeContextMenu()
}
@@ -90,19 +117,28 @@ async function deleteTarget() {
await workspaceService.deleteFile(node.path)
const activeWasRemoved = workspaceStore.closePath(node.path)
workspaceStore.removeFromTree(node.path)
if (selectedTreePath.value === node.path || selectedTreePath.value.startsWith(`${node.path}/`)) {
selectedTreePath.value = containingFolder(node.path)
selectedFolderPath.value = selectedTreePath.value
}
if (activeWasRemoved) {
editorStore.closeFile()
if (workspaceStore.activeFilePath) await editorStore.loadFile(workspaceStore.activeFilePath)
}
closeContextMenu()
}
function containingFolder(path: string): string {
const separator = path.lastIndexOf('/')
return separator > 0 ? path.slice(0, separator) : '/'
}
</script>
<template>
<section class="file-tree-panel" @click="closeContextMenu">
<div class="toolbar">
<button type="button" title="新建笔记" aria-label="新建笔记" @click.stop="beginCreate('file')"><AppIcon :icon="DocumentAdd" /></button>
<button type="button" title="新建文件夹" aria-label="新建文件夹" @click.stop="beginCreate('folder')"><AppIcon :icon="FolderAdd" /></button>
<button type="button" title="新建笔记" aria-label="新建笔记" @click.stop="beginCreate('file', selectedFolderPath)"><AppIcon :icon="DocumentAdd" /></button>
<button type="button" title="新建文件夹" aria-label="新建文件夹" @click.stop="beginCreate('folder', selectedFolderPath)"><AppIcon :icon="FolderAdd" /></button>
</div>
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
<input v-model="newItemName" :placeholder="newItemType === 'file' ? '笔记名称' : '文件夹名称'" autofocus />
@@ -111,7 +147,7 @@ async function deleteTarget() {
</form>
<div class="tree">
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
:active-path="workspaceStore.activeFilePath" @open="openNode" @context-menu="openContextMenu" />
:active-path="selectedTreePath" @open="openNode" @context-menu="openContextMenu" />
</div>
<Teleport to="body">
<div v-if="contextTarget" class="context-menu"
+11 -7
View File
@@ -44,11 +44,17 @@ const routes = [
component: () => import('@/features/skills/SkillsView.vue'),
meta: { title: 'Skill 管理', requiresVault: true },
},
{
path: '/extensions/mcp',
name: 'mcp-servers',
component: () => import('@/features/mcp/McpServersView.vue'),
meta: { title: 'MCP 服务器', requiresVault: true },
},
{
path: '/extensions/plugins',
name: 'plugins',
component: () => import('@/features/plugins/PluginsView.vue'),
meta: { title: 'Plugin 管理', requiresVault: true },
meta: { title: 'Plugin 与 MCP', requiresVault: true },
},
{
path: '/themes',
@@ -69,17 +75,15 @@ const router = createRouter({
routes,
})
router.beforeEach((to, _from, next) => {
router.beforeEach((to) => {
const workspaceStore = useWorkspaceStore()
if (to.meta.requiresVault && !workspaceStore.hasVault) {
next({ path: '/' })
return
return { path: '/' }
}
if (to.path === '/' && workspaceStore.hasVault) {
next({ path: '/workspace' })
return
return { path: '/workspace' }
}
next()
return true
})
router.afterEach((to) => {
+1
View File
@@ -8,6 +8,7 @@ export * as chatService from './chatService'
export * as agentService from './agentService'
export * as skillService from './skillService'
export * as pluginService from './pluginService'
export * as mcpServerService from './mcpServerService'
export * as providerService from './providerService'
export * as taskService from './taskService'
export * as indexService from './indexService'
+18
View File
@@ -0,0 +1,18 @@
import apiClient from './apiClient'
import type { McpServer, McpServerInput, McpToolSummary, OperationResponse } from '@/contracts'
const base = '/api/mcp/servers'
export async function listMcpServers(): Promise<McpServer[]> {
return (await apiClient.get<{ items: McpServer[] }>(base)).items
}
export const createMcpServer = (input: McpServerInput) => apiClient.post<McpServer>(base, input)
export const updateMcpServer = (id: string, input: McpServerInput) => apiClient.put<McpServer>(`${base}/${id}`, input)
export const listMcpServerTools = async (id: string) => (await apiClient.get<{ items: McpToolSummary[] }>(`${base}/${id}/tools`)).items
export const deleteMcpServer = (id: string) => apiClient.delete<OperationResponse>(`${base}/${id}`)
export const trustMcpServer = (server: McpServer) => apiClient.post<McpServer>(`${base}/${server.server_id}/trust`, { command_digest: server.command_digest })
export const testMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/test`)
export const enableMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/enable`)
export const disableMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/disable`)
export const putMcpServerSecret = (id: string, key: string, secret: string, kind: 'environment' | 'header' = 'environment') => apiClient.put(`${base}/${id}/secrets/${encodeURIComponent(key)}?kind=${kind}`, { secret })
export const deleteMcpServerSecret = (id: string, key: string, kind: 'environment' | 'header' = 'environment') => apiClient.delete(`${base}/${id}/secrets/${encodeURIComponent(key)}?kind=${kind}`)
@@ -0,0 +1,85 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as pluginService from './pluginService'
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('pluginService contribution adapter', () => {
it('lists and executes Plugin Commands with scoped wire fields', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(jsonResponse({ items: [{ command_id: 'text-tools.uppercase-selection' }] }))
.mockResolvedValueOnce(jsonResponse({
command_id: 'text-tools.uppercase-selection',
status: 'completed',
effect: { type: 'notification', payload: { level: 'success', message: 'HELLO' } },
}))
const commands = await pluginService.listPluginCommands('command_palette')
const result = await pluginService.executePluginCommand(
'text-tools.uppercase-selection',
{},
{ note_id: 'note-1', selection: 'hello' },
)
expect(commands[0].command_id).toBe('text-tools.uppercase-selection')
if (result.effect.type !== 'notification') throw new Error('expected notification effect')
expect(result.effect.payload.message).toBe('HELLO')
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/plugin-contributions/commands?location=command_palette',
)
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({
arguments: {},
context: { note_id: 'note-1', selection: 'hello' },
})
})
it('uses separate Settings and Secret endpoints', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(jsonResponse({
plugin_id: 'text-tools', schema_version: 1, fields: [],
values: { result_limit: 10 }, secrets: { api_key: { configured: false } },
}))
.mockResolvedValueOnce(jsonResponse({
plugin_id: 'text-tools', schema_version: 1, fields: [],
values: { result_limit: 20 }, secrets: { api_key: { configured: false } },
}))
.mockResolvedValueOnce(jsonResponse({ plugin_id: 'text-tools', key: 'api_key', configured: true }))
.mockResolvedValueOnce(jsonResponse({ plugin_id: 'text-tools', key: 'api_key', configured: false }))
await pluginService.getPluginSettings('text-tools')
await pluginService.updatePluginSettings('text-tools', 1, { result_limit: 20 })
await pluginService.putPluginSecret('text-tools', 'api_key', 'request-only-secret')
await pluginService.deletePluginSecret('text-tools', 'api_key')
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/plugins/text-tools/settings',
'/api/plugins/text-tools/settings',
'/api/plugins/text-tools/settings/api_key/secret',
'/api/plugins/text-tools/settings/api_key/secret',
])
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({
schema_version: 1,
values: { result_limit: 20 },
})
expect(JSON.parse(String(fetchMock.mock.calls[2][1]?.body))).toEqual({
secret: 'request-only-secret',
})
expect(fetchMock.mock.calls[3][1]?.method).toBe('DELETE')
})
})
+62 -1
View File
@@ -1,5 +1,17 @@
import apiClient from './apiClient'
import type { ApiPlugin, OperationResponse, Plugin, PluginContribution, PluginHostStatus } from '@/contracts'
import type {
ApiPlugin,
OperationResponse,
Plugin,
PluginCommand,
PluginCommandContext,
PluginCommandLocation,
PluginCommandResult,
PluginContribution,
PluginHostStatus,
PluginSecretStatus,
PluginSettingsSchema,
} from '@/contracts'
function toPlugin(plugin: ApiPlugin): Plugin {
const { manifest } = plugin
@@ -62,6 +74,55 @@ export async function restartPluginHost(pluginId: string): Promise<OperationResp
return apiClient.post(`/api/plugins/${pluginId}/host/restart`)
}
export async function listPluginCommands(location?: PluginCommandLocation): Promise<PluginCommand[]> {
const query = location ? `?location=${encodeURIComponent(location)}` : ''
const response = await apiClient.get<{ items: PluginCommand[] }>(`/api/plugin-contributions/commands${query}`)
return response.items
}
export async function executePluginCommand(
commandId: string,
argumentsValue: Record<string, unknown> = {},
context: PluginCommandContext = {},
): Promise<PluginCommandResult> {
return apiClient.post(`/api/plugin-contributions/commands/${encodeURIComponent(commandId)}/execute`, {
arguments: argumentsValue,
context,
})
}
export async function getPluginSettings(pluginId: string): Promise<PluginSettingsSchema> {
return apiClient.get(`/api/plugins/${encodeURIComponent(pluginId)}/settings`)
}
export async function updatePluginSettings(
pluginId: string,
schemaVersion: number,
values: Record<string, unknown>,
): Promise<PluginSettingsSchema> {
return apiClient.put(`/api/plugins/${encodeURIComponent(pluginId)}/settings`, {
schema_version: schemaVersion,
values,
})
}
export async function putPluginSecret(
pluginId: string,
key: string,
secret: string,
): Promise<PluginSecretStatus> {
return apiClient.put(
`/api/plugins/${encodeURIComponent(pluginId)}/settings/${encodeURIComponent(key)}/secret`,
{ secret },
)
}
export async function deletePluginSecret(pluginId: string, key: string): Promise<PluginSecretStatus> {
return apiClient.delete(
`/api/plugins/${encodeURIComponent(pluginId)}/settings/${encodeURIComponent(key)}/secret`,
)
}
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/plugins/${pluginId}`)
}
@@ -73,7 +73,7 @@ describe('workspaceService backend adapter', () => {
const markdown = await workspaceService.readFileContent('/课程/操作系统.md')
await workspaceService.saveFileContent('/课程/操作系统.md', '# 已更新\n')
expect(vault).toEqual({ path: 'C:\\data\\vault', name: 'vault' })
expect(vault).toEqual({ vault_id: 'default', path: 'C:\\data\\vault', name: 'vault' })
expect(tree[0].children?.[0]).toMatchObject({
id: 'note-os', note_id: 'note-os', path: '/课程/操作系统.md', type: 'file',
})
+12 -2
View File
@@ -11,6 +11,7 @@ import * as noteService from './noteService'
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
export interface VaultInfo {
vault_id: string
path: string
name: string
}
@@ -81,13 +82,17 @@ export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
export async function getRecentVaults(): Promise<VaultInfo[]> {
const workspace = await getWorkspaceInfo()
return [{ path: workspace.path, name: workspace.name }]
return [{ vault_id: workspace.vault_id, path: workspace.path, name: workspace.name }]
}
export async function openVault(path: string): Promise<VaultInfo> {
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path })
cacheEntries(snapshot.items)
return { path: snapshot.workspace.path, name: snapshot.workspace.name }
return {
vault_id: snapshot.workspace.vault_id,
path: snapshot.workspace.path,
name: snapshot.workspace.name,
}
}
export async function createVault(path: string, name: string): Promise<VaultInfo> {
@@ -110,6 +115,11 @@ export async function readFileContent(filePath: string): Promise<string> {
return note.markdown
}
/** Resolve the backend note identity already associated with a workspace path. */
export async function getNoteId(filePath: string): Promise<string> {
return requireNoteId(filePath)
}
export async function saveFileContent(filePath: string, content: string): Promise<void> {
await noteService.updateNote(await requireNoteId(filePath), { markdown: content })
}
+5 -1
View File
@@ -88,9 +88,13 @@ export const useEditorStore = defineStore('editor', () => {
const previousStatus = saveStatus.value
saveStatus.value = 'saving'
try {
const loadedContent = await workspaceService.readFileContent(filePath)
const [loadedContent, loadedNoteId] = await Promise.all([
workspaceService.readFileContent(filePath),
workspaceService.getNoteId(filePath),
])
if (version !== loadVersion) return
currentFilePath.value = filePath
currentNoteId.value = loadedNoteId
content.value = loadedContent
saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString()
+5 -1
View File
@@ -5,13 +5,14 @@ import * as workspaceService from '@/services/workspaceService'
export const useWorkspaceStore = defineStore('workspace', () => {
const vaultPath = ref('')
const vaultId = ref('')
const vaultName = ref('')
const fileTree = ref<FileNode[]>([])
const openFiles = ref<string[]>([])
const activeFilePath = ref<string | null>(null)
const isLoading = ref(false)
const hasVault = ref(false)
const recentVaults = ref<{ path: string; name: string }[]>([])
const recentVaults = ref<workspaceService.VaultInfo[]>([])
const activeFile = computed(() => {
if (!activeFilePath.value) return null
@@ -66,6 +67,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
try {
const info = await workspaceService.openVault(path)
vaultPath.value = info.path
vaultId.value = info.vault_id
vaultName.value = info.name
fileTree.value = await workspaceService.getFileTree()
hasVault.value = true
@@ -80,6 +82,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
try {
const info = await workspaceService.createVault(path, name)
vaultPath.value = info.path
vaultId.value = info.vault_id
vaultName.value = info.name
fileTree.value = await workspaceService.getFileTree()
hasVault.value = true
@@ -144,6 +147,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
return {
vaultPath,
vaultId,
vaultName,
fileTree,
openFiles,