添加了MCP客户端的超时配置功能,包括启动超时和工具调用超时参数。 改进了HTTP客户端和标准IO客户端的超时处理机制,确保请求在指定时间内完成或取消。 增加了对MCP服务器数量的限制,防止配置过多服务器导致系统不稳定。 增强了错误处理机制,当连接异常时能够正确清理资源并移除桥接主机。 添加了对大型MCP消息的大小验证,防止过大的请求导致系统问题。 优化了密钥更改后的处理流程,确保在修改密钥时停用服务器并要求重新测试。
225 lines
7.1 KiB
Python
225 lines
7.1 KiB
Python
import asyncio
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
from app.contracts import (
|
|
McpServerSecretStatus,
|
|
McpServerSecretWriteRequest,
|
|
ProviderCreateRequest,
|
|
ProviderType,
|
|
ProviderUpdateRequest,
|
|
TaskCreateRequest,
|
|
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:
|
|
response = asyncio.run(health())
|
|
|
|
assert response.model_dump() == {"status": "ok"}
|
|
|
|
|
|
def test_service_status() -> None:
|
|
response = asyncio.run(service_status())
|
|
|
|
assert response.name == "Notes Agent AI Core"
|
|
assert response.status == "ok"
|
|
|
|
|
|
def test_core_collections_are_typed() -> None:
|
|
notes = asyncio.run(list_notes(limit=20, offset=0, folder=None, tag=None))
|
|
skills = asyncio.run(list_skills())
|
|
plugins = asyncio.run(list_plugins())
|
|
providers = asyncio.run(list_providers())
|
|
index = asyncio.run(get_index_status())
|
|
|
|
assert notes.items == []
|
|
assert notes.page.limit == 20
|
|
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"
|
|
assert [provider.provider_id for provider in providers.items] == ["mock"]
|
|
assert index.status == "idle"
|
|
|
|
|
|
def test_provider_presets_include_openai_and_deepseek() -> None:
|
|
presets = asyncio.run(list_provider_presets())
|
|
by_id = {item.preset_id: item for item in presets.items}
|
|
|
|
assert by_id["openai"].base_url == "https://api.openai.com/v1"
|
|
assert by_id["deepseek"].base_url == "https://api.deepseek.com"
|
|
assert by_id["deepseek"].provider_type == ProviderType.openai_compatible
|
|
assert by_id["deepseek"].default_credential_id == "deepseek"
|
|
|
|
|
|
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())]
|
|
|
|
assert get_paths.index("/api/providers/presets") < get_paths.index("/api/providers/{provider_id}")
|
|
|
|
|
|
def test_openapi_contains_documented_frontend_interfaces() -> None:
|
|
from app.main import app
|
|
|
|
paths = app.openapi()["paths"]
|
|
expected_paths = {
|
|
"/api/notes/{note_id}",
|
|
"/api/search",
|
|
"/api/chat",
|
|
"/api/agent/runs",
|
|
"/api/agent/runs/{run_id}/cancel",
|
|
"/api/agent/runs/{run_id}/events",
|
|
"/api/agent/runs/{run_id}/trace",
|
|
"/api/skills",
|
|
"/api/plugins",
|
|
"/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}",
|
|
"/api/index/rebuild",
|
|
}
|
|
|
|
assert expected_paths <= paths.keys()
|
|
|
|
|
|
def test_provider_configuration_lifecycle() -> None:
|
|
created = asyncio.run(
|
|
create_provider(
|
|
ProviderCreateRequest(
|
|
provider_type=ProviderType.ollama,
|
|
name="Local Ollama",
|
|
base_url="http://127.0.0.1:11434",
|
|
default_model="qwen3:latest",
|
|
)
|
|
)
|
|
)
|
|
fetched = asyncio.run(get_provider(created.provider_id))
|
|
disabled = asyncio.run(
|
|
update_provider(created.provider_id, ProviderUpdateRequest(enabled=False))
|
|
)
|
|
deleted = asyncio.run(delete_provider(created.provider_id))
|
|
|
|
assert fetched.provider_type == ProviderType.ollama
|
|
assert disabled.enabled is False
|
|
assert deleted.resource_id == created.provider_id
|
|
|
|
|
|
def test_provider_patch_can_clear_nullable_fields() -> None:
|
|
created = asyncio.run(
|
|
create_provider(
|
|
ProviderCreateRequest(
|
|
provider_type=ProviderType.ollama,
|
|
name="Clearable",
|
|
base_url="http://127.0.0.1:11434",
|
|
default_model="qwen",
|
|
credential_id="unused",
|
|
)
|
|
)
|
|
)
|
|
try:
|
|
cleared = asyncio.run(
|
|
update_provider(
|
|
created.provider_id,
|
|
ProviderUpdateRequest(
|
|
base_url=None, default_model=None, credential_id=None
|
|
),
|
|
)
|
|
)
|
|
assert cleared.base_url is None
|
|
assert cleared.default_model is None
|
|
assert cleared.credential_id is None
|
|
finally:
|
|
asyncio.run(delete_provider(created.provider_id))
|
|
|
|
|
|
def test_task_lifecycle_is_persistent() -> None:
|
|
created = asyncio.run(create_task(TaskCreateRequest(title="审阅修复")))
|
|
fetched = asyncio.run(get_task(created.task_id))
|
|
updated = asyncio.run(
|
|
update_task(created.task_id, TaskUpdateRequest(status=TaskStatus.done))
|
|
)
|
|
listed = asyncio.run(list_tasks(limit=20, offset=0))
|
|
deleted = asyncio.run(delete_task(created.task_id))
|
|
|
|
assert fetched.title == "审阅修复"
|
|
assert updated.status == TaskStatus.done
|
|
assert any(item.task_id == created.task_id for item in listed.items)
|
|
assert deleted.resource_id == created.task_id
|