Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/757607106_nanobot.json
T

1 line
83 KiB
JSON

{"repo": "757607106/nanobot", "files": {"web-ui/src/mcp-page.test.tsx": {"2331de433d6389a4633ab93387a4b9ce34679af3": {"t": 1785746536.8402581, "n": 9795, "h": "43cd4849678b38d7", "c": "import { fireEvent, screen, waitFor } from '@testing-library/react'\nimport { MemoryRouter, Route, Routes } from 'react-router-dom'\nimport userEvent from '@testing-library/user-event'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport McpPage from './pages/mcp'\nimport { parseMappingInput } from './pages/mcp/utils'\nimport { testIds } from './testIds'\nimport { renderWithProviders } from './test/renderApp'\n\nconst mockApi = vi.hoisted(() => ({\n getAuthStatus: vi.fn(),\n getSetupStatus: vi.fn(),\n getMcpServers: vi.fn(),\n getMcpServer: vi.fn(),\n getConfig: vi.fn(),\n updateConfig: vi.fn(),\n updateMcpServer: vi.fn(),\n setMcpServerEnabled: vi.fn(),\n deleteMcpServer: vi.fn(),\n inspectMcpRepository: vi.fn(),\n installMcpRepository: vi.fn(),\n}))\n\nvi.mock('./api', () => ({\n ApiError: class MockApiError extends Error {\n statusCode = 0\n code?: string\n details?: unknown\n },\n api: mockApi,\n}))\n\nfunction makeConfig(mcpServers: Record<string, unknown> = {}) {\n return {\n agents: {\n defaults: {\n workspace: '/tmp/workspace',\n model: 'deepseek-chat',\n provider: 'deepseek',\n maxTokens: 4096,\n contextWindowTokens: 128000,\n temperature: 0.3,\n maxToolIterations: 12,\n reasoningEffort: 'medium',\n },\n },\n providers: {\n deepseek: {\n apiKey: 'sk-deepseek',\n apiBase: 'https://api.deepseek.com',\n extraHeaders: {},\n },\n },\n channels: {\n sendProgress: true,\n sendToolHints: true,\n },\n gateway: {\n host: '127.0.0.1',\n port: 18790,\n },\n tools: {\n restrictToWorkspace: true,\n web: {\n proxy: '',\n search: {\n apiKey: '',\n maxResults: 5,\n },\n },\n mcpServers,\n },\n }\n}\n\nfunction makeRegistry(items?: Array<Record<string, unknown>>) {\n const nextItems = (items ?? [\n {\n name: 'filesystem',\n displayName: 'Workspace Files',\n enabled: true,\n transport: 'stdio' as const,\n status: 'ready' as const,\n statusDetail: '配置结构完整,等待首次探测或运行时按需加载。',\n toolCount: 7,\n toolCountKnown: true,\n toolTimeout: 30,\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret' },\n url: null,\n headers: {},\n envCount: 1,\n headerCount: 0,\n sourceKind: 'repository' as const,\n sourceLabel: '仓库安装',\n repoUrl: 'https://github.com/modelcontextprotocol/servers',\n lastToolSyncAt: '2026-03-13T12:30:00Z',\n lastCheckedAt: '2026-03-13T12:31:00Z',\n lastProbeStatus: 'passed',\n toolNames: ['read_file', 'list_dir'],\n lastError: null,\n updatedAt: '2026-03-13T12:29:00Z',\n installDir: '/tmp/mcp-installs/modelcontextprotocol__servers',\n installMode: 'source',\n installSteps: ['npm ci'],\n requiredEnv: ['MCP_API_KEY'],\n optionalEnv: [],\n cloneUrl: 'https://github.com/modelcontextprotocol/servers.git',\n },\n ]) as Array<Record<string, unknown>>\n\n return {\n items: nextItems,\n summary: {\n total: nextItems.length,\n enabled: nextItems.filter((item) => item.enabled).length,\n disabled: nextItems.filter((item) => !item.enabled).length,\n ready: nextItems.filter((item) => item.status === 'ready').length,\n incomplete: nextItems.filter((item) => item.status === 'incomplete').length,\n knownToolCount: nextItems.reduce((sum, item) => sum + Number(item.toolCount || 0), 0),\n verifiedServers: nextItems.filter((item) => item.toolCountKnown).length,\n },\n }\n}\n\nfunction renderPage() {\n return renderWithProviders(\n <MemoryRouter\n initialEntries={['/mcp']}\n future={{\n v7_startTransition: true,\n v7_relativeSplatPath: true,\n }}\n >\n <Routes>\n <Route path=\"/mcp\" element={<McpPage />} />\n <Route path=\"/mcp/:serverName\" element={<McpPage />} />\n </Routes>\n </MemoryRouter>,\n )\n}\n\ndescribe('McpPage', () => {\n beforeEach(() => {\n window.localStorage.clear()\n mockApi.getAuthStatus.mockReset()\n mockApi.getSetupStatus.mockReset()\n mockApi.getMcpServers.mockReset()\n mockApi.getMcpServer.mockReset()\n mockApi.getConfig.mockReset()\n mockApi.updateConfig.mockReset()\n mockApi.updateMcpServer.mockReset()\n mockApi.setMcpServerEnabled.mockReset()\n mockApi.deleteMcpServer.mockReset()\n mockApi.inspectMcpRepository.mockReset()\n mockApi.installMcpRepository.mockReset()\n\n mockApi.getAuthStatus.mockResolvedValue({\n initialized: true,\n authenticated: true,\n username: 'admin',\n })\n mockApi.getSetupStatus.mockResolvedValue({\n completed: true,\n currentStep: 'done',\n completedAt: '2026-03-20T10:00:00Z',\n steps: [],\n })\n mockApi.getMcpServers.mockResolvedValue(makeRegistry())\n mockApi.getMcpServer.mockResolvedValue(makeRegistry().items[0])\n mockApi.getConfig.mockResolvedValue(makeConfig())\n mockApi.updateConfig.mockImplementation(async (config) => config)\n mockApi.updateMcpServer.mockResolvedValue({\n serverName: 'filesystem',\n entry: makeRegistry().items[0],\n config: makeConfig({\n filesystem: {\n enabled: true,\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret' },\n url: '',\n headers: {},\n toolTimeout: 30,\n },\n }),\n })\n })\n\n it('accepts pasted JSON objects for mapping fields', () => {\n expect(parseMappingInput('{\"Authorization\":\"Bearer token\"}', '请求头', ':')).toEqual({\n Authorization: 'Bearer token',\n })\n expect(parseMappingInput('{\"GITHUB_TOKEN\":\"token-123\"}', '环境变量', '=')).toEqual({\n GITHUB_TOKEN: 'token-123',\n })\n })\n\n it('renders the reference-style registry layout', async () => {\n renderPage()\n\n expect(await screen.findByText('MCP 扩展')).toBeInTheDocument()\n expect(screen.getByText('添加服务器')).toBeInTheDocument()\n expect(screen.getByText('Workspace Files')).toBeInTheDocument()\n expect(screen.getByText('filesystem')).toBeInTheDocument()\n })\n\n it('creates a manual server by saving the config payload', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry([])).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n name: 'github',\n displayName: 'github',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('暂无 MCP 服务器')).toBeInTheDocument()\n // Click the empty state button\n const addButton = await screen.findByText(/添加第一个服务器/)\n await user.click(addButton)\n await user.type(screen.getByPlaceholderText('唯一标识,如 weather-api'), 'github')\n await user.type(screen.getByPlaceholderText('uvx 或 npx'), 'npx')\n await user.type(screen.getByPlaceholderText('参数以空格分隔'), '-y @modelcontextprotocol/server-github')\n await user.type(screen.getByPlaceholderText(/KEY=value/), 'GITHUB_TOKEN=token-123')\n await user.click(screen.getByRole('button', { name: /保\\s*存/ }))\n\n await waitFor(() => {\n expect(mockApi.updateConfig).toHaveBeenCalledTimes(1)\n })\n\n const savedConfig = mockApi.updateConfig.mock.calls[0][0]\n expect(savedConfig.tools.mcpServers.github).toMatchObject({\n enabled: true,\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: {\n GITHUB_TOKEN: 'token-123',\n },\n toolTimeout: 30,\n })\n })\n\n\n\n it('edits an existing server through the update endpoint', async () => {\n const user = userEvent.setup()\n const item = {\n ...makeRegistry().items[0],\n displayName: 'Workspace Files Pro',\n command: 'node',\n env: { MCP_API_KEY: 'secret-2' },\n }\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry()).mockResolvedValue(makeRegistry([item]))\n mockApi.getMcpServer.mockResolvedValue(item)\n\n renderPage()\n\n expect(await screen.findByText('Workspace Files')).toBeInTheDocument()\n // \"编辑\" logic has moved to clicking the card and then drawer. Let's just click '配置' or view\n const card = screen.getByText('Workspace Files')\n await user.click(card)\n await user.clear(await screen.findByLabelText('展示名称'))\n await user.type(screen.getByLabelText('展示名称'), 'Workspace Files Pro')\n await user.clear(screen.getByLabelText('命令'))\n await user.type(screen.getByLabelText('命令'), 'node')\n // Open sensitive values to edit env\n await user.click(screen.getByText('查看敏感值'))\n // Wait for button text to change to ensure state updated\n expect(await screen.findByText('隐藏敏感值')).toBeInTheDocument()\n \n // antd's readOnly toggle + userEvent sometimes causes weird writability check bugs. Use fireEvent.\n const envInput = screen.getByTestId(testIds.mcp.detailEnv)\n fireEvent.change(envInput, { target: { value: '{\"MCP_API_KEY\":\"secret-2\"}' } })\n \n await user.click(await screen.findByText(/保存配置/))\n\n await waitFor(() => {\n expect(mockApi.updateMcpServer).toHaveBeenCalledTimes(1)\n })\n\n expect(mockApi.updateMcpServer.mock.calls[0][0]).toBe('filesystem')\n expect(mockApi.updateMcpServer.mock.calls[0][1]).toMatchObject({\n displayName: 'Workspace Files Pro',\n enabled: true,\n type: 'stdio',\n command: 'node',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret-2' },\n toolTimeout: 30,\n })\n })\n})\n"}, "fcda303637ba974c42f39eb85f4a466902b42681": {"t": 1785765438.1862512, "n": 9795, "h": "43cd4849678b38d7", "c": "import { fireEvent, screen, waitFor } from '@testing-library/react'\nimport { MemoryRouter, Route, Routes } from 'react-router-dom'\nimport userEvent from '@testing-library/user-event'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport McpPage from './pages/mcp'\nimport { parseMappingInput } from './pages/mcp/utils'\nimport { testIds } from './testIds'\nimport { renderWithProviders } from './test/renderApp'\n\nconst mockApi = vi.hoisted(() => ({\n getAuthStatus: vi.fn(),\n getSetupStatus: vi.fn(),\n getMcpServers: vi.fn(),\n getMcpServer: vi.fn(),\n getConfig: vi.fn(),\n updateConfig: vi.fn(),\n updateMcpServer: vi.fn(),\n setMcpServerEnabled: vi.fn(),\n deleteMcpServer: vi.fn(),\n inspectMcpRepository: vi.fn(),\n installMcpRepository: vi.fn(),\n}))\n\nvi.mock('./api', () => ({\n ApiError: class MockApiError extends Error {\n statusCode = 0\n code?: string\n details?: unknown\n },\n api: mockApi,\n}))\n\nfunction makeConfig(mcpServers: Record<string, unknown> = {}) {\n return {\n agents: {\n defaults: {\n workspace: '/tmp/workspace',\n model: 'deepseek-chat',\n provider: 'deepseek',\n maxTokens: 4096,\n contextWindowTokens: 128000,\n temperature: 0.3,\n maxToolIterations: 12,\n reasoningEffort: 'medium',\n },\n },\n providers: {\n deepseek: {\n apiKey: 'sk-deepseek',\n apiBase: 'https://api.deepseek.com',\n extraHeaders: {},\n },\n },\n channels: {\n sendProgress: true,\n sendToolHints: true,\n },\n gateway: {\n host: '127.0.0.1',\n port: 18790,\n },\n tools: {\n restrictToWorkspace: true,\n web: {\n proxy: '',\n search: {\n apiKey: '',\n maxResults: 5,\n },\n },\n mcpServers,\n },\n }\n}\n\nfunction makeRegistry(items?: Array<Record<string, unknown>>) {\n const nextItems = (items ?? [\n {\n name: 'filesystem',\n displayName: 'Workspace Files',\n enabled: true,\n transport: 'stdio' as const,\n status: 'ready' as const,\n statusDetail: '配置结构完整,等待首次探测或运行时按需加载。',\n toolCount: 7,\n toolCountKnown: true,\n toolTimeout: 30,\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret' },\n url: null,\n headers: {},\n envCount: 1,\n headerCount: 0,\n sourceKind: 'repository' as const,\n sourceLabel: '仓库安装',\n repoUrl: 'https://github.com/modelcontextprotocol/servers',\n lastToolSyncAt: '2026-03-13T12:30:00Z',\n lastCheckedAt: '2026-03-13T12:31:00Z',\n lastProbeStatus: 'passed',\n toolNames: ['read_file', 'list_dir'],\n lastError: null,\n updatedAt: '2026-03-13T12:29:00Z',\n installDir: '/tmp/mcp-installs/modelcontextprotocol__servers',\n installMode: 'source',\n installSteps: ['npm ci'],\n requiredEnv: ['MCP_API_KEY'],\n optionalEnv: [],\n cloneUrl: 'https://github.com/modelcontextprotocol/servers.git',\n },\n ]) as Array<Record<string, unknown>>\n\n return {\n items: nextItems,\n summary: {\n total: nextItems.length,\n enabled: nextItems.filter((item) => item.enabled).length,\n disabled: nextItems.filter((item) => !item.enabled).length,\n ready: nextItems.filter((item) => item.status === 'ready').length,\n incomplete: nextItems.filter((item) => item.status === 'incomplete').length,\n knownToolCount: nextItems.reduce((sum, item) => sum + Number(item.toolCount || 0), 0),\n verifiedServers: nextItems.filter((item) => item.toolCountKnown).length,\n },\n }\n}\n\nfunction renderPage() {\n return renderWithProviders(\n <MemoryRouter\n initialEntries={['/mcp']}\n future={{\n v7_startTransition: true,\n v7_relativeSplatPath: true,\n }}\n >\n <Routes>\n <Route path=\"/mcp\" element={<McpPage />} />\n <Route path=\"/mcp/:serverName\" element={<McpPage />} />\n </Routes>\n </MemoryRouter>,\n )\n}\n\ndescribe('McpPage', () => {\n beforeEach(() => {\n window.localStorage.clear()\n mockApi.getAuthStatus.mockReset()\n mockApi.getSetupStatus.mockReset()\n mockApi.getMcpServers.mockReset()\n mockApi.getMcpServer.mockReset()\n mockApi.getConfig.mockReset()\n mockApi.updateConfig.mockReset()\n mockApi.updateMcpServer.mockReset()\n mockApi.setMcpServerEnabled.mockReset()\n mockApi.deleteMcpServer.mockReset()\n mockApi.inspectMcpRepository.mockReset()\n mockApi.installMcpRepository.mockReset()\n\n mockApi.getAuthStatus.mockResolvedValue({\n initialized: true,\n authenticated: true,\n username: 'admin',\n })\n mockApi.getSetupStatus.mockResolvedValue({\n completed: true,\n currentStep: 'done',\n completedAt: '2026-03-20T10:00:00Z',\n steps: [],\n })\n mockApi.getMcpServers.mockResolvedValue(makeRegistry())\n mockApi.getMcpServer.mockResolvedValue(makeRegistry().items[0])\n mockApi.getConfig.mockResolvedValue(makeConfig())\n mockApi.updateConfig.mockImplementation(async (config) => config)\n mockApi.updateMcpServer.mockResolvedValue({\n serverName: 'filesystem',\n entry: makeRegistry().items[0],\n config: makeConfig({\n filesystem: {\n enabled: true,\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret' },\n url: '',\n headers: {},\n toolTimeout: 30,\n },\n }),\n })\n })\n\n it('accepts pasted JSON objects for mapping fields', () => {\n expect(parseMappingInput('{\"Authorization\":\"Bearer token\"}', '请求头', ':')).toEqual({\n Authorization: 'Bearer token',\n })\n expect(parseMappingInput('{\"GITHUB_TOKEN\":\"token-123\"}', '环境变量', '=')).toEqual({\n GITHUB_TOKEN: 'token-123',\n })\n })\n\n it('renders the reference-style registry layout', async () => {\n renderPage()\n\n expect(await screen.findByText('MCP 扩展')).toBeInTheDocument()\n expect(screen.getByText('添加服务器')).toBeInTheDocument()\n expect(screen.getByText('Workspace Files')).toBeInTheDocument()\n expect(screen.getByText('filesystem')).toBeInTheDocument()\n })\n\n it('creates a manual server by saving the config payload', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry([])).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n name: 'github',\n displayName: 'github',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('暂无 MCP 服务器')).toBeInTheDocument()\n // Click the empty state button\n const addButton = await screen.findByText(/添加第一个服务器/)\n await user.click(addButton)\n await user.type(screen.getByPlaceholderText('唯一标识,如 weather-api'), 'github')\n await user.type(screen.getByPlaceholderText('uvx 或 npx'), 'npx')\n await user.type(screen.getByPlaceholderText('参数以空格分隔'), '-y @modelcontextprotocol/server-github')\n await user.type(screen.getByPlaceholderText(/KEY=value/), 'GITHUB_TOKEN=token-123')\n await user.click(screen.getByRole('button', { name: /保\\s*存/ }))\n\n await waitFor(() => {\n expect(mockApi.updateConfig).toHaveBeenCalledTimes(1)\n })\n\n const savedConfig = mockApi.updateConfig.mock.calls[0][0]\n expect(savedConfig.tools.mcpServers.github).toMatchObject({\n enabled: true,\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: {\n GITHUB_TOKEN: 'token-123',\n },\n toolTimeout: 30,\n })\n })\n\n\n\n it('edits an existing server through the update endpoint', async () => {\n const user = userEvent.setup()\n const item = {\n ...makeRegistry().items[0],\n displayName: 'Workspace Files Pro',\n command: 'node',\n env: { MCP_API_KEY: 'secret-2' },\n }\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry()).mockResolvedValue(makeRegistry([item]))\n mockApi.getMcpServer.mockResolvedValue(item)\n\n renderPage()\n\n expect(await screen.findByText('Workspace Files')).toBeInTheDocument()\n // \"编辑\" logic has moved to clicking the card and then drawer. Let's just click '配置' or view\n const card = screen.getByText('Workspace Files')\n await user.click(card)\n await user.clear(await screen.findByLabelText('展示名称'))\n await user.type(screen.getByLabelText('展示名称'), 'Workspace Files Pro')\n await user.clear(screen.getByLabelText('命令'))\n await user.type(screen.getByLabelText('命令'), 'node')\n // Open sensitive values to edit env\n await user.click(screen.getByText('查看敏感值'))\n // Wait for button text to change to ensure state updated\n expect(await screen.findByText('隐藏敏感值')).toBeInTheDocument()\n \n // antd's readOnly toggle + userEvent sometimes causes weird writability check bugs. Use fireEvent.\n const envInput = screen.getByTestId(testIds.mcp.detailEnv)\n fireEvent.change(envInput, { target: { value: '{\"MCP_API_KEY\":\"secret-2\"}' } })\n \n await user.click(await screen.findByText(/保存配置/))\n\n await waitFor(() => {\n expect(mockApi.updateMcpServer).toHaveBeenCalledTimes(1)\n })\n\n expect(mockApi.updateMcpServer.mock.calls[0][0]).toBe('filesystem')\n expect(mockApi.updateMcpServer.mock.calls[0][1]).toMatchObject({\n displayName: 'Workspace Files Pro',\n enabled: true,\n type: 'stdio',\n command: 'node',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret-2' },\n toolTimeout: 30,\n })\n })\n})\n"}, "f8f52e6771deb03e0312da2dbd5797b8de2ef944": {"t": 1785765439.616944, "n": 11180, "h": "a13da132a407b976", "c": "import { screen, waitFor } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\nimport { MemoryRouter } from 'react-router-dom'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport McpPage from './pages/mcp'\nimport { parseMappingInput } from './pages/mcp/utils'\nimport { renderWithProviders } from './test/renderApp'\n\nconst mockApi = vi.hoisted(() => ({\n getAuthStatus: vi.fn(),\n getSetupStatus: vi.fn(),\n getMcpServers: vi.fn(),\n getConfig: vi.fn(),\n updateConfig: vi.fn(),\n updateMcpServer: vi.fn(),\n setMcpServerEnabled: vi.fn(),\n deleteMcpServer: vi.fn(),\n inspectMcpRepository: vi.fn(),\n installMcpRepository: vi.fn(),\n}))\n\nvi.mock('./api', () => ({\n ApiError: class MockApiError extends Error {\n statusCode = 0\n code?: string\n details?: unknown\n },\n api: mockApi,\n}))\n\nfunction makeConfig(mcpServers: Record<string, unknown> = {}) {\n return {\n agents: {\n defaults: {\n workspace: '/tmp/workspace',\n model: 'deepseek-chat',\n provider: 'deepseek',\n maxTokens: 4096,\n contextWindowTokens: 128000,\n temperature: 0.3,\n maxToolIterations: 12,\n reasoningEffort: 'medium',\n },\n },\n providers: {\n deepseek: {\n apiKey: 'sk-deepseek',\n apiBase: 'https://api.deepseek.com',\n extraHeaders: {},\n },\n },\n channels: {\n sendProgress: true,\n sendToolHints: true,\n },\n gateway: {\n host: '127.0.0.1',\n port: 18790,\n },\n tools: {\n restrictToWorkspace: true,\n web: {\n proxy: '',\n search: {\n apiKey: '',\n maxResults: 5,\n },\n },\n mcpServers,\n },\n }\n}\n\nfunction makeRegistry(items?: Array<Record<string, unknown>>) {\n const nextItems = (items ?? [\n {\n name: 'filesystem',\n displayName: 'Workspace Files',\n enabled: true,\n transport: 'stdio' as const,\n status: 'ready' as const,\n statusDetail: '配置结构完整,等待首次探测或运行时按需加载。',\n toolCount: 7,\n toolCountKnown: true,\n toolTimeout: 30,\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret' },\n url: null,\n headers: {},\n envCount: 1,\n headerCount: 0,\n sourceKind: 'repository' as const,\n sourceLabel: '仓库安装',\n repoUrl: 'https://github.com/modelcontextprotocol/servers',\n lastToolSyncAt: '2026-03-13T12:30:00Z',\n lastCheckedAt: '2026-03-13T12:31:00Z',\n lastProbeStatus: 'passed',\n toolNames: ['read_file', 'list_dir'],\n lastError: null,\n updatedAt: '2026-03-13T12:29:00Z',\n installDir: '/tmp/mcp-installs/modelcontextprotocol__servers',\n installMode: 'source',\n installSteps: ['npm ci'],\n requiredEnv: ['MCP_API_KEY'],\n optionalEnv: [],\n cloneUrl: 'https://github.com/modelcontextprotocol/servers.git',\n },\n ]) as Array<Record<string, unknown>>\n\n return {\n items: nextItems,\n summary: {\n total: nextItems.length,\n enabled: nextItems.filter((item) => item.enabled).length,\n disabled: nextItems.filter((item) => !item.enabled).length,\n ready: nextItems.filter((item) => item.status === 'ready').length,\n incomplete: nextItems.filter((item) => item.status === 'incomplete').length,\n knownToolCount: nextItems.reduce((sum, item) => sum + Number(item.toolCount || 0), 0),\n verifiedServers: nextItems.filter((item) => item.toolCountKnown).length,\n },\n }\n}\n\nfunction renderPage() {\n return renderWithProviders(\n <MemoryRouter\n future={{\n v7_startTransition: true,\n v7_relativeSplatPath: true,\n }}\n >\n <McpPage />\n </MemoryRouter>,\n )\n}\n\ndescribe('McpPage', () => {\n beforeEach(() => {\n window.localStorage.clear()\n mockApi.getAuthStatus.mockReset()\n mockApi.getSetupStatus.mockReset()\n mockApi.getMcpServers.mockReset()\n mockApi.getConfig.mockReset()\n mockApi.updateConfig.mockReset()\n mockApi.updateMcpServer.mockReset()\n mockApi.setMcpServerEnabled.mockReset()\n mockApi.deleteMcpServer.mockReset()\n mockApi.inspectMcpRepository.mockReset()\n mockApi.installMcpRepository.mockReset()\n\n mockApi.getAuthStatus.mockResolvedValue({\n initialized: true,\n authenticated: true,\n username: 'admin',\n })\n mockApi.getSetupStatus.mockResolvedValue({\n completed: true,\n currentStep: 'done',\n completedAt: '2026-03-20T10:00:00Z',\n steps: [],\n })\n mockApi.getMcpServers.mockResolvedValue(makeRegistry())\n mockApi.getConfig.mockResolvedValue(makeConfig())\n mockApi.updateConfig.mockImplementation(async (config) => config)\n mockApi.updateMcpServer.mockResolvedValue({\n serverName: 'filesystem',\n entry: makeRegistry().items[0],\n config: makeConfig({\n filesystem: {\n enabled: true,\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret' },\n url: '',\n headers: {},\n toolTimeout: 30,\n },\n }),\n })\n })\n\n it('accepts pasted JSON objects for mapping fields', () => {\n expect(parseMappingInput('{\"Authorization\":\"Bearer token\"}', '请求头', ':')).toEqual({\n Authorization: 'Bearer token',\n })\n expect(parseMappingInput('{\"GITHUB_TOKEN\":\"token-123\"}', '环境变量', '=')).toEqual({\n GITHUB_TOKEN: 'token-123',\n })\n })\n\n it('renders the reference-style registry layout', async () => {\n renderPage()\n\n expect(await screen.findByText('MCP 服务器')).toBeInTheDocument()\n expect(screen.getByLabelText('导入配置')).toBeInTheDocument()\n expect(screen.getByLabelText('添加 MCP 服务器')).toBeInTheDocument()\n expect(screen.getByText('Workspace Files')).toBeInTheDocument()\n expect(screen.getByText('filesystem')).toBeInTheDocument()\n })\n\n it('creates a manual server by saving the config payload', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry([])).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n name: 'github',\n displayName: 'github',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('暂无数据')).toBeInTheDocument()\n await user.click(screen.getByLabelText('添加 MCP 服务器'))\n await user.type(screen.getByPlaceholderText('例如 github'), 'github')\n await user.type(screen.getByLabelText('命令'), 'npx')\n await user.type(screen.getByLabelText('参数(可选)'), '-y @modelcontextprotocol/server-github')\n await user.type(screen.getByLabelText('环境变量(可选)'), 'GITHUB_TOKEN=token-123')\n await user.click(screen.getByRole('button', { name: /保\\s*存/ }))\n\n await waitFor(() => {\n expect(mockApi.updateConfig).toHaveBeenCalledTimes(1)\n })\n\n const savedConfig = mockApi.updateConfig.mock.calls[0][0]\n expect(savedConfig.tools.mcpServers.github).toMatchObject({\n enabled: true,\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: {\n GITHUB_TOKEN: 'token-123',\n },\n toolTimeout: 30,\n })\n })\n\n it('imports multiple servers from pasted JSON through config update', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry([])).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n name: 'github',\n displayName: 'github',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n {\n ...makeRegistry().items[0],\n name: 'remote-docs',\n displayName: 'remote-docs',\n transport: 'sse',\n command: '',\n args: [],\n env: {},\n url: 'https://example.com/sse',\n headers: { Authorization: 'Bearer token' },\n envCount: 0,\n headerCount: 1,\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('暂无数据')).toBeInTheDocument()\n await user.click(screen.getByLabelText('导入配置'))\n await user.click(screen.getByLabelText('MCP JSON 配置'))\n await user.paste(\n JSON.stringify({\n mcpServers: {\n github: {\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n },\n 'remote-docs': {\n url: 'https://example.com/sse',\n headers: { Authorization: 'Bearer token' },\n },\n },\n }),\n )\n await user.click(screen.getByRole('button', { name: '导入 2 个服务器' }))\n\n await waitFor(() => {\n expect(mockApi.updateConfig).toHaveBeenCalledTimes(1)\n })\n\n const savedConfig = mockApi.updateConfig.mock.calls[0][0]\n expect(savedConfig.tools.mcpServers.github).toMatchObject({\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n })\n expect(savedConfig.tools.mcpServers['remote-docs']).toMatchObject({\n type: 'sse',\n url: 'https://example.com/sse',\n headers: { Authorization: 'Bearer token' },\n })\n })\n\n it('edits an existing server through the update endpoint', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry()).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n displayName: 'Workspace Files Pro',\n command: 'node',\n env: { MCP_API_KEY: 'secret-2' },\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('Workspace Files')).toBeInTheDocument()\n await user.click(screen.getByLabelText('编辑 Workspace Files'))\n await user.clear(screen.getByPlaceholderText('显示名称'))\n await user.type(screen.getByPlaceholderText('显示名称'), 'Workspace Files Pro')\n await user.clear(screen.getByLabelText('命令'))\n await user.type(screen.getByLabelText('命令'), 'node')\n await user.clear(screen.getByLabelText('环境变量(可选)'))\n await user.type(screen.getByLabelText('环境变量(可选)'), 'MCP_API_KEY=secret-2')\n await user.click(screen.getByRole('button', { name: /保\\s*存/ }))\n\n await waitFor(() => {\n expect(mockApi.updateMcpServer).toHaveBeenCalledTimes(1)\n })\n\n expect(mockApi.updateMcpServer.mock.calls[0][0]).toBe('filesystem')\n expect(mockApi.updateMcpServer.mock.calls[0][1]).toMatchObject({\n displayName: 'Workspace Files Pro',\n enabled: true,\n type: 'stdio',\n command: 'node',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret-2' },\n toolTimeout: 30,\n })\n })\n})\n"}, "79740650638083a60ae45599b4852da094357e71": {"t": 1785765441.170491, "n": 11153, "h": "38d0dcae608e6a90", "c": "import { screen, waitFor } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\nimport { MemoryRouter } from 'react-router-dom'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport McpPage, { parseMappingInput } from './pages/McpPage'\nimport { renderWithProviders } from './test/renderApp'\n\nconst mockApi = vi.hoisted(() => ({\n getAuthStatus: vi.fn(),\n getSetupStatus: vi.fn(),\n getMcpServers: vi.fn(),\n getConfig: vi.fn(),\n updateConfig: vi.fn(),\n updateMcpServer: vi.fn(),\n setMcpServerEnabled: vi.fn(),\n deleteMcpServer: vi.fn(),\n inspectMcpRepository: vi.fn(),\n installMcpRepository: vi.fn(),\n}))\n\nvi.mock('./api', () => ({\n ApiError: class MockApiError extends Error {\n statusCode = 0\n code?: string\n details?: unknown\n },\n api: mockApi,\n}))\n\nfunction makeConfig(mcpServers: Record<string, unknown> = {}) {\n return {\n agents: {\n defaults: {\n workspace: '/tmp/workspace',\n model: 'deepseek-chat',\n provider: 'deepseek',\n maxTokens: 4096,\n contextWindowTokens: 128000,\n temperature: 0.3,\n maxToolIterations: 12,\n reasoningEffort: 'medium',\n },\n },\n providers: {\n deepseek: {\n apiKey: 'sk-deepseek',\n apiBase: 'https://api.deepseek.com',\n extraHeaders: {},\n },\n },\n channels: {\n sendProgress: true,\n sendToolHints: true,\n },\n gateway: {\n host: '127.0.0.1',\n port: 18790,\n },\n tools: {\n restrictToWorkspace: true,\n web: {\n proxy: '',\n search: {\n apiKey: '',\n maxResults: 5,\n },\n },\n mcpServers,\n },\n }\n}\n\nfunction makeRegistry(items?: Array<Record<string, unknown>>) {\n const nextItems = (items ?? [\n {\n name: 'filesystem',\n displayName: 'Workspace Files',\n enabled: true,\n transport: 'stdio' as const,\n status: 'ready' as const,\n statusDetail: '配置结构完整,等待首次探测或运行时按需加载。',\n toolCount: 7,\n toolCountKnown: true,\n toolTimeout: 30,\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret' },\n url: null,\n headers: {},\n envCount: 1,\n headerCount: 0,\n sourceKind: 'repository' as const,\n sourceLabel: '仓库安装',\n repoUrl: 'https://github.com/modelcontextprotocol/servers',\n lastToolSyncAt: '2026-03-13T12:30:00Z',\n lastCheckedAt: '2026-03-13T12:31:00Z',\n lastProbeStatus: 'passed',\n toolNames: ['read_file', 'list_dir'],\n lastError: null,\n updatedAt: '2026-03-13T12:29:00Z',\n installDir: '/tmp/mcp-installs/modelcontextprotocol__servers',\n installMode: 'source',\n installSteps: ['npm ci'],\n requiredEnv: ['MCP_API_KEY'],\n optionalEnv: [],\n cloneUrl: 'https://github.com/modelcontextprotocol/servers.git',\n },\n ]) as Array<Record<string, unknown>>\n\n return {\n items: nextItems,\n summary: {\n total: nextItems.length,\n enabled: nextItems.filter((item) => item.enabled).length,\n disabled: nextItems.filter((item) => !item.enabled).length,\n ready: nextItems.filter((item) => item.status === 'ready').length,\n incomplete: nextItems.filter((item) => item.status === 'incomplete').length,\n knownToolCount: nextItems.reduce((sum, item) => sum + Number(item.toolCount || 0), 0),\n verifiedServers: nextItems.filter((item) => item.toolCountKnown).length,\n },\n }\n}\n\nfunction renderPage() {\n return renderWithProviders(\n <MemoryRouter\n future={{\n v7_startTransition: true,\n v7_relativeSplatPath: true,\n }}\n >\n <McpPage />\n </MemoryRouter>,\n )\n}\n\ndescribe('McpPage', () => {\n beforeEach(() => {\n window.localStorage.clear()\n mockApi.getAuthStatus.mockReset()\n mockApi.getSetupStatus.mockReset()\n mockApi.getMcpServers.mockReset()\n mockApi.getConfig.mockReset()\n mockApi.updateConfig.mockReset()\n mockApi.updateMcpServer.mockReset()\n mockApi.setMcpServerEnabled.mockReset()\n mockApi.deleteMcpServer.mockReset()\n mockApi.inspectMcpRepository.mockReset()\n mockApi.installMcpRepository.mockReset()\n\n mockApi.getAuthStatus.mockResolvedValue({\n initialized: true,\n authenticated: true,\n username: 'admin',\n })\n mockApi.getSetupStatus.mockResolvedValue({\n completed: true,\n currentStep: 'done',\n completedAt: '2026-03-20T10:00:00Z',\n steps: [],\n })\n mockApi.getMcpServers.mockResolvedValue(makeRegistry())\n mockApi.getConfig.mockResolvedValue(makeConfig())\n mockApi.updateConfig.mockImplementation(async (config) => config)\n mockApi.updateMcpServer.mockResolvedValue({\n serverName: 'filesystem',\n entry: makeRegistry().items[0],\n config: makeConfig({\n filesystem: {\n enabled: true,\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret' },\n url: '',\n headers: {},\n toolTimeout: 30,\n },\n }),\n })\n })\n\n it('accepts pasted JSON objects for mapping fields', () => {\n expect(parseMappingInput('{\"Authorization\":\"Bearer token\"}', '请求头', ':')).toEqual({\n Authorization: 'Bearer token',\n })\n expect(parseMappingInput('{\"GITHUB_TOKEN\":\"token-123\"}', '环境变量', '=')).toEqual({\n GITHUB_TOKEN: 'token-123',\n })\n })\n\n it('renders the reference-style registry layout', async () => {\n renderPage()\n\n expect(await screen.findByText('MCP 服务器')).toBeInTheDocument()\n expect(screen.getByLabelText('导入配置')).toBeInTheDocument()\n expect(screen.getByLabelText('添加 MCP 服务器')).toBeInTheDocument()\n expect(screen.getByText('Workspace Files')).toBeInTheDocument()\n expect(screen.getByText('filesystem')).toBeInTheDocument()\n })\n\n it('creates a manual server by saving the config payload', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry([])).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n name: 'github',\n displayName: 'github',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('暂无数据')).toBeInTheDocument()\n await user.click(screen.getByLabelText('添加 MCP 服务器'))\n await user.type(screen.getByPlaceholderText('例如 github'), 'github')\n await user.type(screen.getByLabelText('命令'), 'npx')\n await user.type(screen.getByLabelText('参数(可选)'), '-y @modelcontextprotocol/server-github')\n await user.type(screen.getByLabelText('环境变量(可选)'), 'GITHUB_TOKEN=token-123')\n await user.click(screen.getByRole('button', { name: /保\\s*存/ }))\n\n await waitFor(() => {\n expect(mockApi.updateConfig).toHaveBeenCalledTimes(1)\n })\n\n const savedConfig = mockApi.updateConfig.mock.calls[0][0]\n expect(savedConfig.tools.mcpServers.github).toMatchObject({\n enabled: true,\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: {\n GITHUB_TOKEN: 'token-123',\n },\n toolTimeout: 30,\n })\n })\n\n it('imports multiple servers from pasted JSON through config update', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry([])).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n name: 'github',\n displayName: 'github',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n {\n ...makeRegistry().items[0],\n name: 'remote-docs',\n displayName: 'remote-docs',\n transport: 'sse',\n command: '',\n args: [],\n env: {},\n url: 'https://example.com/sse',\n headers: { Authorization: 'Bearer token' },\n envCount: 0,\n headerCount: 1,\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('暂无数据')).toBeInTheDocument()\n await user.click(screen.getByLabelText('导入配置'))\n await user.click(screen.getByLabelText('MCP JSON 配置'))\n await user.paste(\n JSON.stringify({\n mcpServers: {\n github: {\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n },\n 'remote-docs': {\n url: 'https://example.com/sse',\n headers: { Authorization: 'Bearer token' },\n },\n },\n }),\n )\n await user.click(screen.getByRole('button', { name: '导入 2 个服务器' }))\n\n await waitFor(() => {\n expect(mockApi.updateConfig).toHaveBeenCalledTimes(1)\n })\n\n const savedConfig = mockApi.updateConfig.mock.calls[0][0]\n expect(savedConfig.tools.mcpServers.github).toMatchObject({\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n })\n expect(savedConfig.tools.mcpServers['remote-docs']).toMatchObject({\n type: 'sse',\n url: 'https://example.com/sse',\n headers: { Authorization: 'Bearer token' },\n })\n })\n\n it('edits an existing server through the update endpoint', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry()).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n displayName: 'Workspace Files Pro',\n command: 'node',\n env: { MCP_API_KEY: 'secret-2' },\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('Workspace Files')).toBeInTheDocument()\n await user.click(screen.getByLabelText('编辑 Workspace Files'))\n await user.clear(screen.getByPlaceholderText('显示名称'))\n await user.type(screen.getByPlaceholderText('显示名称'), 'Workspace Files Pro')\n await user.clear(screen.getByLabelText('命令'))\n await user.type(screen.getByLabelText('命令'), 'node')\n await user.clear(screen.getByLabelText('环境变量(可选)'))\n await user.type(screen.getByLabelText('环境变量(可选)'), 'MCP_API_KEY=secret-2')\n await user.click(screen.getByRole('button', { name: /保\\s*存/ }))\n\n await waitFor(() => {\n expect(mockApi.updateMcpServer).toHaveBeenCalledTimes(1)\n })\n\n expect(mockApi.updateMcpServer.mock.calls[0][0]).toBe('filesystem')\n expect(mockApi.updateMcpServer.mock.calls[0][1]).toMatchObject({\n displayName: 'Workspace Files Pro',\n enabled: true,\n type: 'stdio',\n command: 'node',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret-2' },\n toolTimeout: 30,\n })\n })\n})\n"}, "49bfb38f24e7f5d4c842da32b5e70acb39ada600": {"t": 1785765442.7192638, "n": 10804, "h": "b1f41f95cea8d232", "c": "import { screen, waitFor } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\nimport { MemoryRouter } from 'react-router-dom'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport McpPage from './pages/McpPage'\nimport { renderWithProviders } from './test/renderApp'\n\nconst mockApi = vi.hoisted(() => ({\n getAuthStatus: vi.fn(),\n getSetupStatus: vi.fn(),\n getMcpServers: vi.fn(),\n getConfig: vi.fn(),\n updateConfig: vi.fn(),\n updateMcpServer: vi.fn(),\n setMcpServerEnabled: vi.fn(),\n deleteMcpServer: vi.fn(),\n inspectMcpRepository: vi.fn(),\n installMcpRepository: vi.fn(),\n}))\n\nvi.mock('./api', () => ({\n ApiError: class MockApiError extends Error {\n statusCode = 0\n code?: string\n details?: unknown\n },\n api: mockApi,\n}))\n\nfunction makeConfig(mcpServers: Record<string, unknown> = {}) {\n return {\n agents: {\n defaults: {\n workspace: '/tmp/workspace',\n model: 'deepseek-chat',\n provider: 'deepseek',\n maxTokens: 4096,\n contextWindowTokens: 128000,\n temperature: 0.3,\n maxToolIterations: 12,\n reasoningEffort: 'medium',\n },\n },\n providers: {\n deepseek: {\n apiKey: 'sk-deepseek',\n apiBase: 'https://api.deepseek.com',\n extraHeaders: {},\n },\n },\n channels: {\n sendProgress: true,\n sendToolHints: true,\n },\n gateway: {\n host: '127.0.0.1',\n port: 18790,\n },\n tools: {\n restrictToWorkspace: true,\n web: {\n proxy: '',\n search: {\n apiKey: '',\n maxResults: 5,\n },\n },\n mcpServers,\n },\n }\n}\n\nfunction makeRegistry(items?: Array<Record<string, unknown>>) {\n const nextItems = (items ?? [\n {\n name: 'filesystem',\n displayName: 'Workspace Files',\n enabled: true,\n transport: 'stdio' as const,\n status: 'ready' as const,\n statusDetail: '配置结构完整,等待首次探测或运行时按需加载。',\n toolCount: 7,\n toolCountKnown: true,\n toolTimeout: 30,\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret' },\n url: null,\n headers: {},\n envCount: 1,\n headerCount: 0,\n sourceKind: 'repository' as const,\n sourceLabel: '仓库安装',\n repoUrl: 'https://github.com/modelcontextprotocol/servers',\n lastToolSyncAt: '2026-03-13T12:30:00Z',\n lastCheckedAt: '2026-03-13T12:31:00Z',\n lastProbeStatus: 'passed',\n toolNames: ['read_file', 'list_dir'],\n lastError: null,\n updatedAt: '2026-03-13T12:29:00Z',\n installDir: '/tmp/mcp-installs/modelcontextprotocol__servers',\n installMode: 'source',\n installSteps: ['npm ci'],\n requiredEnv: ['MCP_API_KEY'],\n optionalEnv: [],\n cloneUrl: 'https://github.com/modelcontextprotocol/servers.git',\n },\n ]) as Array<Record<string, unknown>>\n\n return {\n items: nextItems,\n summary: {\n total: nextItems.length,\n enabled: nextItems.filter((item) => item.enabled).length,\n disabled: nextItems.filter((item) => !item.enabled).length,\n ready: nextItems.filter((item) => item.status === 'ready').length,\n incomplete: nextItems.filter((item) => item.status === 'incomplete').length,\n knownToolCount: nextItems.reduce((sum, item) => sum + Number(item.toolCount || 0), 0),\n verifiedServers: nextItems.filter((item) => item.toolCountKnown).length,\n },\n }\n}\n\nfunction renderPage() {\n return renderWithProviders(\n <MemoryRouter\n future={{\n v7_startTransition: true,\n v7_relativeSplatPath: true,\n }}\n >\n <McpPage />\n </MemoryRouter>,\n )\n}\n\ndescribe('McpPage', () => {\n beforeEach(() => {\n window.localStorage.clear()\n mockApi.getAuthStatus.mockReset()\n mockApi.getSetupStatus.mockReset()\n mockApi.getMcpServers.mockReset()\n mockApi.getConfig.mockReset()\n mockApi.updateConfig.mockReset()\n mockApi.updateMcpServer.mockReset()\n mockApi.setMcpServerEnabled.mockReset()\n mockApi.deleteMcpServer.mockReset()\n mockApi.inspectMcpRepository.mockReset()\n mockApi.installMcpRepository.mockReset()\n\n mockApi.getAuthStatus.mockResolvedValue({\n initialized: true,\n authenticated: true,\n username: 'admin',\n })\n mockApi.getSetupStatus.mockResolvedValue({\n completed: true,\n currentStep: 'done',\n completedAt: '2026-03-20T10:00:00Z',\n steps: [],\n })\n mockApi.getMcpServers.mockResolvedValue(makeRegistry())\n mockApi.getConfig.mockResolvedValue(makeConfig())\n mockApi.updateConfig.mockImplementation(async (config) => config)\n mockApi.updateMcpServer.mockResolvedValue({\n serverName: 'filesystem',\n entry: makeRegistry().items[0],\n config: makeConfig({\n filesystem: {\n enabled: true,\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret' },\n url: '',\n headers: {},\n toolTimeout: 30,\n },\n }),\n })\n })\n\n it('renders the reference-style registry layout', async () => {\n renderPage()\n\n expect(await screen.findByText('MCP 服务器')).toBeInTheDocument()\n expect(screen.getByLabelText('导入配置')).toBeInTheDocument()\n expect(screen.getByLabelText('添加 MCP 服务器')).toBeInTheDocument()\n expect(screen.getByText('Workspace Files')).toBeInTheDocument()\n expect(screen.getByText('filesystem')).toBeInTheDocument()\n })\n\n it('creates a manual server by saving the config payload', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry([])).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n name: 'github',\n displayName: 'github',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('暂无数据')).toBeInTheDocument()\n await user.click(screen.getByLabelText('添加 MCP 服务器'))\n await user.type(screen.getByPlaceholderText('例如 github'), 'github')\n await user.type(screen.getByLabelText('命令'), 'npx')\n await user.type(screen.getByLabelText('参数(可选)'), '-y @modelcontextprotocol/server-github')\n await user.type(screen.getByLabelText('环境变量(可选)'), 'GITHUB_TOKEN=token-123')\n await user.click(screen.getByRole('button', { name: /保\\s*存/ }))\n\n await waitFor(() => {\n expect(mockApi.updateConfig).toHaveBeenCalledTimes(1)\n })\n\n const savedConfig = mockApi.updateConfig.mock.calls[0][0]\n expect(savedConfig.tools.mcpServers.github).toMatchObject({\n enabled: true,\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: {\n GITHUB_TOKEN: 'token-123',\n },\n toolTimeout: 30,\n })\n })\n\n it('imports multiple servers from pasted JSON through config update', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry([])).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n name: 'github',\n displayName: 'github',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n {\n ...makeRegistry().items[0],\n name: 'remote-docs',\n displayName: 'remote-docs',\n transport: 'sse',\n command: '',\n args: [],\n env: {},\n url: 'https://example.com/sse',\n headers: { Authorization: 'Bearer token' },\n envCount: 0,\n headerCount: 1,\n toolNames: [],\n toolCount: null,\n toolCountKnown: false,\n status: 'incomplete',\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('暂无数据')).toBeInTheDocument()\n await user.click(screen.getByLabelText('导入配置'))\n await user.click(screen.getByLabelText('MCP JSON 配置'))\n await user.paste(\n JSON.stringify({\n mcpServers: {\n github: {\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n },\n 'remote-docs': {\n url: 'https://example.com/sse',\n headers: { Authorization: 'Bearer token' },\n },\n },\n }),\n )\n await user.click(screen.getByRole('button', { name: '导入 2 个服务器' }))\n\n await waitFor(() => {\n expect(mockApi.updateConfig).toHaveBeenCalledTimes(1)\n })\n\n const savedConfig = mockApi.updateConfig.mock.calls[0][0]\n expect(savedConfig.tools.mcpServers.github).toMatchObject({\n type: 'stdio',\n command: 'npx',\n args: ['-y', '@modelcontextprotocol/server-github'],\n env: { GITHUB_TOKEN: 'token-123' },\n })\n expect(savedConfig.tools.mcpServers['remote-docs']).toMatchObject({\n type: 'sse',\n url: 'https://example.com/sse',\n headers: { Authorization: 'Bearer token' },\n })\n })\n\n it('edits an existing server through the update endpoint', async () => {\n const user = userEvent.setup()\n mockApi.getMcpServers.mockResolvedValueOnce(makeRegistry()).mockResolvedValue(makeRegistry([\n {\n ...makeRegistry().items[0],\n displayName: 'Workspace Files Pro',\n command: 'node',\n env: { MCP_API_KEY: 'secret-2' },\n },\n ]))\n\n renderPage()\n\n expect(await screen.findByText('Workspace Files')).toBeInTheDocument()\n await user.click(screen.getByLabelText('编辑 Workspace Files'))\n await user.clear(screen.getByPlaceholderText('显示名称'))\n await user.type(screen.getByPlaceholderText('显示名称'), 'Workspace Files Pro')\n await user.clear(screen.getByLabelText('命令'))\n await user.type(screen.getByLabelText('命令'), 'node')\n await user.clear(screen.getByLabelText('环境变量(可选)'))\n await user.type(screen.getByLabelText('环境变量(可选)'), 'MCP_API_KEY=secret-2')\n await user.click(screen.getByRole('button', { name: /保\\s*存/ }))\n\n await waitFor(() => {\n expect(mockApi.updateMcpServer).toHaveBeenCalledTimes(1)\n })\n\n expect(mockApi.updateMcpServer.mock.calls[0][0]).toBe('filesystem')\n expect(mockApi.updateMcpServer.mock.calls[0][1]).toMatchObject({\n displayName: 'Workspace Files Pro',\n enabled: true,\n type: 'stdio',\n command: 'node',\n args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp/workspace'],\n env: { MCP_API_KEY: 'secret-2' },\n toolTimeout: 30,\n })\n })\n})\n"}}, "tests/web/test_services.py": {"2331de433d6389a4633ab93387a4b9ce34679af3": {"t": 1785747499.7432122, "n": 13281, "h": "bdc0cea00fa62960", "c": "from __future__ import annotations\n\nimport asyncio\nimport json\nimport shutil\nfrom pathlib import Path\nfrom types import SimpleNamespace\n\nimport httpx\nimport pytest\n\nfrom nanobot.config import loader as config_loader\nfrom nanobot.config.loader import save_config\nfrom nanobot.config.schema import Config, MCPServerConfig, ModelBindingConfig\nfrom nanobot.web.api import WebAppState\nfrom nanobot.web.runtime_services.agents import WebAgentRuntimeService\nfrom nanobot.web.services import (\n MCPRepositoryService,\n MCPServerService,\n WebAuthManager,\n WebChannelTestService,\n WebMCPRegistryManager,\n WebOperationsService,\n WebSetupManager,\n)\n\n\ndef _make_service_config(tmp_path, monkeypatch) -> tuple[Config, Path]:\n config_path = tmp_path / \"config.json\"\n workspace = tmp_path / \"workspace\"\n config = Config()\n config.agents.defaults.workspace = str(workspace)\n save_config(config, config_path)\n monkeypatch.setattr(config_loader, \"_current_config_path\", config_path)\n return config, config_path\n\n\ndef _write_fixture_mcp_repo(repo_dir: Path, *, package_name: str = \"@acme/filesystem-mcp\") -> None:\n (repo_dir / \"bin\").mkdir(parents=True, exist_ok=True)\n (repo_dir / \"bin\" / \"server.js\").write_text(\n \"#!/usr/bin/env node\\nconsole.log('fixture mcp')\\n\",\n encoding=\"utf-8\",\n )\n (repo_dir / \"package.json\").write_text(\n json.dumps(\n {\n \"name\": package_name,\n \"version\": \"0.1.0\",\n \"bin\": {\"filesystem-mcp\": \"bin/server.js\"},\n },\n indent=2,\n ensure_ascii=False,\n ) + \"\\n\",\n encoding=\"utf-8\",\n )\n (repo_dir / \"package-lock.json\").write_text(\n json.dumps({\"name\": package_name, \"lockfileVersion\": 3}, indent=2, ensure_ascii=False) + \"\\n\",\n encoding=\"utf-8\",\n )\n (repo_dir / \".env.example\").write_text(\"MCP_API_KEY=\\n\", encoding=\"utf-8\")\n\n\ndef test_web_auth_manager_persists_profile_and_invalidates_old_session(tmp_path, monkeypatch) -> None:\n _config, config_path = _make_service_config(tmp_path, monkeypatch)\n auth = WebAuthManager(config_path)\n\n initial_token = auth.bootstrap(\"owner\", \"bootstrap-pass-123\")\n assert auth.get_authenticated_user(initial_token) == \"owner\"\n\n profile, next_token = auth.update_profile(\n username=\"console-owner\",\n display_name=\"Console Owner\",\n email=\"owner@example.com\",\n )\n assert profile[\"username\"] == \"console-owner\"\n assert profile[\"displayName\"] == \"Console Owner\"\n assert profile[\"email\"] == \"owner@example.com\"\n assert next_token\n assert auth.get_authenticated_user(initial_token) is None\n assert auth.get_authenticated_user(next_token) == \"console-owner\"\n\n restarted = WebAuthManager(config_path)\n assert restarted.get_profile()[\"displayName\"] == \"Console Owner\"\n login_token = restarted.login(\"console-owner\", \"bootstrap-pass-123\")\n assert restarted.get_authenticated_user(login_token) == \"console-owner\"\n\n\ndef test_web_setup_manager_tracks_progress_without_route_wrapper(tmp_path, monkeypatch) -> None:\n config, config_path = _make_service_config(tmp_path, monkeypatch)\n setup = WebSetupManager(config_path)\n\n initial = setup.get_status(config)\n assert initial[\"currentStep\"] == \"provider\"\n assert initial[\"completed\"] is False\n\n config.agents.defaults.provider = \"deepseek\"\n config.agents.defaults.model = \"deepseek/deepseek-chat\"\n config.providers.deepseek.api_key = \"sk-setup-test\"\n after_provider = setup.mark_provider_configured(config)\n assert after_provider[\"currentStep\"] == \"channel\"\n\n after_channel = setup.mark_channel_skipped(config)\n assert after_channel[\"currentStep\"] == \"agent\"\n assert after_channel[\"steps\"][1][\"skipped\"] is True\n\n config.agents.defaults.max_tokens = 8192\n after_agent = setup.mark_agent_configured(config)\n assert after_agent[\"completed\"] is True\n assert WebSetupManager(config_path).get_status(config)[\"completed\"] is True\n\n\ndef test_web_mcp_repository_service_analyzes_and_installs_local_fixture(tmp_path, monkeypatch) -> None:\n config, config_path = _make_service_config(tmp_path, monkeypatch)\n registry = WebMCPRegistryManager(config_path)\n service = MCPRepositoryService(config_path, registry)\n fixture_repo = tmp_path / \"fixture-repo\"\n _write_fixture_mcp_repo(fixture_repo)\n\n monkeypatch.setattr(\n service,\n \"_clone_repository\",\n lambda _clone_url, target_dir: shutil.copytree(fixture_repo, target_dir),\n )\n monkeypatch.setattr(service, \"_run_install_step\", lambda _command, cwd, timeout: None)\n\n analysis = service.analyze_repository(\"https://github.com/acme/filesystem-mcp\")\n assert analysis[\"serverName\"] == \"filesystem-mcp\"\n assert analysis[\"requiredEnv\"] == [\"MCP_API_KEY\"]\n assert analysis[\"canInstall\"] is True\n\n config_payload = config.model_dump(mode=\"json\", by_alias=True)\n result = service.install_repository(\n \"https://github.com/acme/filesystem-mcp\",\n current_config=config_payload,\n update_config=lambda payload: payload,\n )\n assert result[\"serverName\"] == \"filesystem-mcp\"\n assert result[\"installDir\"]\n assert Path(result[\"installDir\"]).exists()\n assert result[\"entry\"][\"repoUrl\"] == \"https://github.com/acme/filesystem-mcp\"\n\n\ndef test_web_mcp_server_service_handles_blocked_probe_and_enable_toggle(tmp_path, monkeypatch) -> None:\n config, config_path = _make_service_config(tmp_path, monkeypatch)\n config.tools.mcp_servers[\"fixture-mcp\"] = MCPServerConfig(\n enabled=False,\n command=\"node\",\n args=[\"server.js\"],\n env={},\n )\n save_config(config, config_path)\n\n registry = WebMCPRegistryManager(config_path)\n registry.upsert_repository_install(\n server_name=\"fixture-mcp\",\n display_name=\"Fixture MCP\",\n repo_url=\"https://github.com/acme/fixture-mcp\",\n clone_url=\"https://github.com/acme/fixture-mcp.git\",\n install_dir=None,\n install_mode=\"source\",\n install_steps=[\"npm ci\"],\n required_env=[\"FIXTURE_TOKEN\"],\n optional_env=[],\n )\n service = MCPServerService(config_path, registry)\n\n blocked = service.probe_server(config, \"fixture-mcp\")\n assert blocked[\"status\"] == \"blocked\"\n assert blocked[\"missingEnv\"] == [\"FIXTURE_TOKEN\"]\n\n config_payload = config.model_dump(mode=\"json\", by_alias=True)\n toggled = service.set_enabled(\n \"fixture-mcp\",\n enabled=True,\n current_config=config_payload,\n update_config=lambda payload: payload,\n )\n assert toggled[\"enabled\"] is True\n assert toggled[\"entry\"][\"enabled\"] is True\n\n\ndef test_web_mcp_server_service_probe_surfaces_nested_http_errors(tmp_path, monkeypatch) -> None:\n config, config_path = _make_service_config(tmp_path, monkeypatch)\n config.tools.mcp_servers[\"remote-mcp\"] = MCPServerConfig(\n enabled=True,\n type=\"streamableHttp\",\n url=\"https://example.com/mcp\",\n headers={\"Authorization\": \"Bearer demo\"},\n )\n save_config(config, config_path)\n\n registry = WebMCPRegistryManager(config_path)\n service = MCPServerService(config_path, registry)\n\n async def fake_list_tools(_cfg):\n request = httpx.Request(\"POST\", \"https://example.com/mcp\")\n response = httpx.Response(401, request=request)\n try:\n response.raise_for_status()\n except httpx.HTTPStatusError as exc:\n raise ExceptionGroup(\"unhandled errors in a TaskGroup\", [exc]) from exc\n\n monkeypatch.setattr(service, \"_list_server_tools\", fake_list_tools)\n\n failed = service.probe_server(config, \"remote-mcp\")\n assert failed[\"status\"] == \"failed\"\n assert failed[\"error\"] == \"401 Unauthorized for https://example.com/mcp\"\n\n\ndef test_web_operations_service_reports_validation_failures_directly(tmp_path, monkeypatch) -> None:\n config, config_path = _make_service_config(tmp_path, monkeypatch)\n setup = WebSetupManager(config_path)\n registry = WebMCPRegistryManager(config_path)\n operations = WebOperationsService(setup, registry)\n\n validation = operations.run_validation(config=config)\n assert validation[\"summary\"][\"status\"] == \"blocked\"\n assert validation[\"summary\"][\"failures\"] >= 1\n assert any(item[\"category\"] == \"provider\" and item[\"status\"] == \"fail\" for item in validation[\"checks\"])\n\n\ndef test_web_channel_test_service_probes_telegram_without_http_route(tmp_path, monkeypatch) -> None:\n config, _config_path = _make_service_config(tmp_path, monkeypatch)\n config.channels.telegram.enabled = True\n config.channels.telegram.token = \"tg-token\"\n\n class FakeResponse:\n is_success = True\n status_code = 200\n content = b'{\"ok\": true}'\n\n @staticmethod\n def json():\n return {\n \"ok\": True,\n \"result\": {\n \"username\": \"nanobot_test_bot\",\n },\n }\n\n class FakeAsyncClient:\n def __init__(self, *args, **kwargs):\n _ = args, kwargs\n\n async def __aenter__(self):\n return self\n\n async def __aexit__(self, exc_type, exc, tb):\n _ = exc_type, exc, tb\n return False\n\n async def get(self, url: str):\n assert url.endswith(\"/getMe\")\n return FakeResponse()\n\n monkeypatch.setattr(\"nanobot.web.services.channel_testing.httpx.AsyncClient\", FakeAsyncClient)\n\n service = WebChannelTestService()\n result = asyncio.run(service.probe_channel(config=config, channel_name=\"telegram\"))\n assert result[\"status\"] == \"passed\"\n assert \"nanobot_test_bot\" in result[\"summary\"]\n\n\ndef test_web_channel_test_service_probes_qq_without_http_route(tmp_path, monkeypatch) -> None:\n config, _config_path = _make_service_config(tmp_path, monkeypatch)\n config.channels.qq.enabled = True\n config.channels.qq.app_id = \"app-123\"\n config.channels.qq.secret = \"qq-secret\"\n\n class FakeResponse:\n is_success = True\n status_code = 200\n content = b'{\"access_token\": \"token\", \"expires_in\": 7200}'\n\n @staticmethod\n def json():\n return {\n \"access_token\": \"token\",\n \"expires_in\": 7200,\n }\n\n class FakeAsyncClient:\n def __init__(self, *args, **kwargs):\n _ = args, kwargs\n\n async def __aenter__(self):\n return self\n\n async def __aexit__(self, exc_type, exc, tb):\n _ = exc_type, exc, tb\n return False\n\n async def post(self, url: str, json: dict[str, str]):\n assert url == \"https://bots.qq.com/app/getAppAccessToken\"\n assert json == {\n \"appId\": \"app-123\",\n \"clientSecret\": \"qq-secret\",\n }\n return FakeResponse()\n\n monkeypatch.setattr(\n \"nanobot.web.services.channel_testing.importlib.util.find_spec\",\n lambda name: object() if name == \"botpy\" else None,\n )\n monkeypatch.setattr(\"nanobot.web.services.channel_testing.httpx.AsyncClient\", FakeAsyncClient)\n\n service = WebChannelTestService()\n result = asyncio.run(service.probe_channel(config=config, channel_name=\"qq\"))\n assert result[\"status\"] == \"passed\"\n assert result[\"checks\"][0][\"status\"] == \"pass\"\n assert \"access token\" in result[\"detail\"].lower()\n\n\ndef test_web_channel_test_service_reports_wecom_preflight_without_http_route(tmp_path, monkeypatch) -> None:\n config, _config_path = _make_service_config(tmp_path, monkeypatch)\n config.channels.wecom.enabled = True\n config.channels.wecom.bot_id = \"bot-123\"\n config.channels.wecom.secret = \"wecom-secret\"\n\n monkeypatch.setattr(\n \"nanobot.web.services.channel_testing.importlib.util.find_spec\",\n lambda name: object() if name == \"wecom_aibot_sdk\" else None,\n )\n\n service = WebChannelTestService()\n result = asyncio.run(service.probe_channel(config=config, channel_name=\"wecom\"))\n assert result[\"status\"] == \"warning\"\n assert result[\"checks\"][0][\"status\"] == \"pass\"\n assert result[\"checks\"][1][\"status\"] == \"pass\"\n assert \"最小启动条件\" in result[\"summary\"]\n\n\ndef test_web_agent_runtime_service_applies_agent_provider_and_model_inference(tmp_path, monkeypatch) -> None:\n config, _config_path = _make_service_config(tmp_path, monkeypatch)\n config.agents.defaults.provider = \"deepseek\"\n config.agents.defaults.model = \"deepseek-chat\"\n config.model_bindings[\"kimi-cn\"] = ModelBindingConfig(\n provider=\"moonshot\",\n label=\"Kimi 国内\",\n model=\"kimi-k2.5\",\n api_key=\"sk-kimi-cn\",\n api_base=\"https://api.moonshot.cn/v1\",\n )\n\n runtime = WebAgentRuntimeService(SimpleNamespace(config=config))\n\n explicit_provider = runtime._build_agent_config({\"provider\": \"moonshot\", \"model\": \"kimi-k2.5\"})\n assert explicit_provider.agents.defaults.provider == \"moonshot\"\n assert explicit_provider.agents.defaults.model == \"kimi-k2.5\"\n\n bound_provider = runtime._build_agent_config({\"binding\": \"kimi-cn\"})\n assert bound_provider.agents.defaults.binding == \"kimi-cn\"\n assert bound_provider.agents.defaults.provider == \"moonshot\"\n assert bound_provider.agents.defaults.model == \"kimi-k2.5\"\n\n inferred_provider = runtime._build_agent_config({\"model\": \"glm-4.5\"})\n assert inferred_provider.agents.defaults.provider == \"zhipu\"\n assert inferred_provider.agents.defaults.model == \"glm-4.5\"\n\n\ndef test_web_app_state_document_center_updates_and_resets_without_http(tmp_path, monkeypatch) -> None:\n config, _config_path = _make_service_config(tmp_path, monkeypatch)\n state = WebAppState(config)\n try:\n before = state.get_document(\"AGENTS.md\")\n assert before[\"sourcePath\"].endswith(\"AGENTS.md\")\n\n updated = state.update_document(\"AGENTS.md\", \"# Workspace Agent\\n\\nDirect service test.\\n\")\n assert updated[\"content\"].startswith(\"# Workspace Agent\")\n\n reset = state.reset_document(\"AGENTS.md\")\n assert reset[\"content\"] != \"# Workspace Agent\\n\\nDirect service test.\\n\"\n finally:\n asyncio.run(state.shutdown_async())\n"}, "23a36323b664bf48fd86864fea272b7cdb00a2ab": {"t": 1785765439.0145543, "n": 13281, "h": "bdc0cea00fa62960", "c": "from __future__ import annotations\n\nimport asyncio\nimport json\nimport shutil\nfrom pathlib import Path\nfrom types import SimpleNamespace\n\nimport httpx\nimport pytest\n\nfrom nanobot.config import loader as config_loader\nfrom nanobot.config.loader import save_config\nfrom nanobot.config.schema import Config, MCPServerConfig, ModelBindingConfig\nfrom nanobot.web.api import WebAppState\nfrom nanobot.web.runtime_services.agents import WebAgentRuntimeService\nfrom nanobot.web.services import (\n MCPRepositoryService,\n MCPServerService,\n WebAuthManager,\n WebChannelTestService,\n WebMCPRegistryManager,\n WebOperationsService,\n WebSetupManager,\n)\n\n\ndef _make_service_config(tmp_path, monkeypatch) -> tuple[Config, Path]:\n config_path = tmp_path / \"config.json\"\n workspace = tmp_path / \"workspace\"\n config = Config()\n config.agents.defaults.workspace = str(workspace)\n save_config(config, config_path)\n monkeypatch.setattr(config_loader, \"_current_config_path\", config_path)\n return config, config_path\n\n\ndef _write_fixture_mcp_repo(repo_dir: Path, *, package_name: str = \"@acme/filesystem-mcp\") -> None:\n (repo_dir / \"bin\").mkdir(parents=True, exist_ok=True)\n (repo_dir / \"bin\" / \"server.js\").write_text(\n \"#!/usr/bin/env node\\nconsole.log('fixture mcp')\\n\",\n encoding=\"utf-8\",\n )\n (repo_dir / \"package.json\").write_text(\n json.dumps(\n {\n \"name\": package_name,\n \"version\": \"0.1.0\",\n \"bin\": {\"filesystem-mcp\": \"bin/server.js\"},\n },\n indent=2,\n ensure_ascii=False,\n ) + \"\\n\",\n encoding=\"utf-8\",\n )\n (repo_dir / \"package-lock.json\").write_text(\n json.dumps({\"name\": package_name, \"lockfileVersion\": 3}, indent=2, ensure_ascii=False) + \"\\n\",\n encoding=\"utf-8\",\n )\n (repo_dir / \".env.example\").write_text(\"MCP_API_KEY=\\n\", encoding=\"utf-8\")\n\n\ndef test_web_auth_manager_persists_profile_and_invalidates_old_session(tmp_path, monkeypatch) -> None:\n _config, config_path = _make_service_config(tmp_path, monkeypatch)\n auth = WebAuthManager(config_path)\n\n initial_token = auth.bootstrap(\"owner\", \"bootstrap-pass-123\")\n assert auth.get_authenticated_user(initial_token) == \"owner\"\n\n profile, next_token = auth.update_profile(\n username=\"console-owner\",\n display_name=\"Console Owner\",\n email=\"owner@example.com\",\n )\n assert profile[\"username\"] == \"console-owner\"\n assert profile[\"displayName\"] == \"Console Owner\"\n assert profile[\"email\"] == \"owner@example.com\"\n assert next_token\n assert auth.get_authenticated_user(initial_token) is None\n assert auth.get_authenticated_user(next_token) == \"console-owner\"\n\n restarted = WebAuthManager(config_path)\n assert restarted.get_profile()[\"displayName\"] == \"Console Owner\"\n login_token = restarted.login(\"console-owner\", \"bootstrap-pass-123\")\n assert restarted.get_authenticated_user(login_token) == \"console-owner\"\n\n\ndef test_web_setup_manager_tracks_progress_without_route_wrapper(tmp_path, monkeypatch) -> None:\n config, config_path = _make_service_config(tmp_path, monkeypatch)\n setup = WebSetupManager(config_path)\n\n initial = setup.get_status(config)\n assert initial[\"currentStep\"] == \"provider\"\n assert initial[\"completed\"] is False\n\n config.agents.defaults.provider = \"deepseek\"\n config.agents.defaults.model = \"deepseek/deepseek-chat\"\n config.providers.deepseek.api_key = \"sk-setup-test\"\n after_provider = setup.mark_provider_configured(config)\n assert after_provider[\"currentStep\"] == \"channel\"\n\n after_channel = setup.mark_channel_skipped(config)\n assert after_channel[\"currentStep\"] == \"agent\"\n assert after_channel[\"steps\"][1][\"skipped\"] is True\n\n config.agents.defaults.max_tokens = 8192\n after_agent = setup.mark_agent_configured(config)\n assert after_agent[\"completed\"] is True\n assert WebSetupManager(config_path).get_status(config)[\"completed\"] is True\n\n\ndef test_web_mcp_repository_service_analyzes_and_installs_local_fixture(tmp_path, monkeypatch) -> None:\n config, config_path = _make_service_config(tmp_path, monkeypatch)\n registry = WebMCPRegistryManager(config_path)\n service = MCPRepositoryService(config_path, registry)\n fixture_repo = tmp_path / \"fixture-repo\"\n _write_fixture_mcp_repo(fixture_repo)\n\n monkeypatch.setattr(\n service,\n \"_clone_repository\",\n lambda _clone_url, target_dir: shutil.copytree(fixture_repo, target_dir),\n )\n monkeypatch.setattr(service, \"_run_install_step\", lambda _command, cwd, timeout: None)\n\n analysis = service.analyze_repository(\"https://github.com/acme/filesystem-mcp\")\n assert analysis[\"serverName\"] == \"filesystem-mcp\"\n assert analysis[\"requiredEnv\"] == [\"MCP_API_KEY\"]\n assert analysis[\"canInstall\"] is True\n\n config_payload = config.model_dump(mode=\"json\", by_alias=True)\n result = service.install_repository(\n \"https://github.com/acme/filesystem-mcp\",\n current_config=config_payload,\n update_config=lambda payload: payload,\n )\n assert result[\"serverName\"] == \"filesystem-mcp\"\n assert result[\"installDir\"]\n assert Path(result[\"installDir\"]).exists()\n assert result[\"entry\"][\"repoUrl\"] == \"https://github.com/acme/filesystem-mcp\"\n\n\ndef test_web_mcp_server_service_handles_blocked_probe_and_enable_toggle(tmp_path, monkeypatch) -> None:\n config, config_path = _make_service_config(tmp_path, monkeypatch)\n config.tools.mcp_servers[\"fixture-mcp\"] = MCPServerConfig(\n enabled=False,\n command=\"node\",\n args=[\"server.js\"],\n env={},\n )\n save_config(config, config_path)\n\n registry = WebMCPRegistryManager(config_path)\n registry.upsert_repository_install(\n server_name=\"fixture-mcp\",\n display_name=\"Fixture MCP\",\n repo_url=\"https://github.com/acme/fixture-mcp\",\n clone_url=\"https://github.com/acme/fixture-mcp.git\",\n install_dir=None,\n install_mode=\"source\",\n install_steps=[\"npm ci\"],\n required_env=[\"FIXTURE_TOKEN\"],\n optional_env=[],\n )\n service = MCPServerService(config_path, registry)\n\n blocked = service.probe_server(config, \"fixture-mcp\")\n assert blocked[\"status\"] == \"blocked\"\n assert blocked[\"missingEnv\"] == [\"FIXTURE_TOKEN\"]\n\n config_payload = config.model_dump(mode=\"json\", by_alias=True)\n toggled = service.set_enabled(\n \"fixture-mcp\",\n enabled=True,\n current_config=config_payload,\n update_config=lambda payload: payload,\n )\n assert toggled[\"enabled\"] is True\n assert toggled[\"entry\"][\"enabled\"] is True\n\n\ndef test_web_mcp_server_service_probe_surfaces_nested_http_errors(tmp_path, monkeypatch) -> None:\n config, config_path = _make_service_config(tmp_path, monkeypatch)\n config.tools.mcp_servers[\"remote-mcp\"] = MCPServerConfig(\n enabled=True,\n type=\"streamableHttp\",\n url=\"https://example.com/mcp\",\n headers={\"Authorization\": \"Bearer demo\"},\n )\n save_config(config, config_path)\n\n registry = WebMCPRegistryManager(config_path)\n service = MCPServerService(config_path, registry)\n\n async def fake_list_tools(_cfg):\n request = httpx.Request(\"POST\", \"https://example.com/mcp\")\n response = httpx.Response(401, request=request)\n try:\n response.raise_for_status()\n except httpx.HTTPStatusError as exc:\n raise ExceptionGroup(\"unhandled errors in a TaskGroup\", [exc]) from exc\n\n monkeypatch.setattr(service, \"_list_server_tools\", fake_list_tools)\n\n failed = service.probe_server(config, \"remote-mcp\")\n assert failed[\"status\"] == \"failed\"\n assert failed[\"error\"] == \"401 Unauthorized for https://example.com/mcp\"\n\n\ndef test_web_operations_service_reports_validation_failures_directly(tmp_path, monkeypatch) -> None:\n config, config_path = _make_service_config(tmp_path, monkeypatch)\n setup = WebSetupManager(config_path)\n registry = WebMCPRegistryManager(config_path)\n operations = WebOperationsService(setup, registry)\n\n validation = operations.run_validation(config=config)\n assert validation[\"summary\"][\"status\"] == \"blocked\"\n assert validation[\"summary\"][\"failures\"] >= 1\n assert any(item[\"category\"] == \"provider\" and item[\"status\"] == \"fail\" for item in validation[\"checks\"])\n\n\ndef test_web_channel_test_service_probes_telegram_without_http_route(tmp_path, monkeypatch) -> None:\n config, _config_path = _make_service_config(tmp_path, monkeypatch)\n config.channels.telegram.enabled = True\n config.channels.telegram.token = \"tg-token\"\n\n class FakeResponse:\n is_success = True\n status_code = 200\n content = b'{\"ok\": true}'\n\n @staticmethod\n def json():\n return {\n \"ok\": True,\n \"result\": {\n \"username\": \"nanobot_test_bot\",\n },\n }\n\n class FakeAsyncClient:\n def __init__(self, *args, **kwargs):\n _ = args, kwargs\n\n async def __aenter__(self):\n return self\n\n async def __aexit__(self, exc_type, exc, tb):\n _ = exc_type, exc, tb\n return False\n\n async def get(self, url: str):\n assert url.endswith(\"/getMe\")\n return FakeResponse()\n\n monkeypatch.setattr(\"nanobot.web.services.channel_testing.httpx.AsyncClient\", FakeAsyncClient)\n\n service = WebChannelTestService()\n result = asyncio.run(service.probe_channel(config=config, channel_name=\"telegram\"))\n assert result[\"status\"] == \"passed\"\n assert \"nanobot_test_bot\" in result[\"summary\"]\n\n\ndef test_web_channel_test_service_probes_qq_without_http_route(tmp_path, monkeypatch) -> None:\n config, _config_path = _make_service_config(tmp_path, monkeypatch)\n config.channels.qq.enabled = True\n config.channels.qq.app_id = \"app-123\"\n config.channels.qq.secret = \"qq-secret\"\n\n class FakeResponse:\n is_success = True\n status_code = 200\n content = b'{\"access_token\": \"token\", \"expires_in\": 7200}'\n\n @staticmethod\n def json():\n return {\n \"access_token\": \"token\",\n \"expires_in\": 7200,\n }\n\n class FakeAsyncClient:\n def __init__(self, *args, **kwargs):\n _ = args, kwargs\n\n async def __aenter__(self):\n return self\n\n async def __aexit__(self, exc_type, exc, tb):\n _ = exc_type, exc, tb\n return False\n\n async def post(self, url: str, json: dict[str, str]):\n assert url == \"https://bots.qq.com/app/getAppAccessToken\"\n assert json == {\n \"appId\": \"app-123\",\n \"clientSecret\": \"qq-secret\",\n }\n return FakeResponse()\n\n monkeypatch.setattr(\n \"nanobot.web.services.channel_testing.importlib.util.find_spec\",\n lambda name: object() if name == \"botpy\" else None,\n )\n monkeypatch.setattr(\"nanobot.web.services.channel_testing.httpx.AsyncClient\", FakeAsyncClient)\n\n service = WebChannelTestService()\n result = asyncio.run(service.probe_channel(config=config, channel_name=\"qq\"))\n assert result[\"status\"] == \"passed\"\n assert result[\"checks\"][0][\"status\"] == \"pass\"\n assert \"access token\" in result[\"detail\"].lower()\n\n\ndef test_web_channel_test_service_reports_wecom_preflight_without_http_route(tmp_path, monkeypatch) -> None:\n config, _config_path = _make_service_config(tmp_path, monkeypatch)\n config.channels.wecom.enabled = True\n config.channels.wecom.bot_id = \"bot-123\"\n config.channels.wecom.secret = \"wecom-secret\"\n\n monkeypatch.setattr(\n \"nanobot.web.services.channel_testing.importlib.util.find_spec\",\n lambda name: object() if name == \"wecom_aibot_sdk\" else None,\n )\n\n service = WebChannelTestService()\n result = asyncio.run(service.probe_channel(config=config, channel_name=\"wecom\"))\n assert result[\"status\"] == \"warning\"\n assert result[\"checks\"][0][\"status\"] == \"pass\"\n assert result[\"checks\"][1][\"status\"] == \"pass\"\n assert \"最小启动条件\" in result[\"summary\"]\n\n\ndef test_web_agent_runtime_service_applies_agent_provider_and_model_inference(tmp_path, monkeypatch) -> None:\n config, _config_path = _make_service_config(tmp_path, monkeypatch)\n config.agents.defaults.provider = \"deepseek\"\n config.agents.defaults.model = \"deepseek-chat\"\n config.model_bindings[\"kimi-cn\"] = ModelBindingConfig(\n provider=\"moonshot\",\n label=\"Kimi 国内\",\n model=\"kimi-k2.5\",\n api_key=\"sk-kimi-cn\",\n api_base=\"https://api.moonshot.cn/v1\",\n )\n\n runtime = WebAgentRuntimeService(SimpleNamespace(config=config))\n\n explicit_provider = runtime._build_agent_config({\"provider\": \"moonshot\", \"model\": \"kimi-k2.5\"})\n assert explicit_provider.agents.defaults.provider == \"moonshot\"\n assert explicit_provider.agents.defaults.model == \"kimi-k2.5\"\n\n bound_provider = runtime._build_agent_config({\"binding\": \"kimi-cn\"})\n assert bound_provider.agents.defaults.binding == \"kimi-cn\"\n assert bound_provider.agents.defaults.provider == \"moonshot\"\n assert bound_provider.agents.defaults.model == \"kimi-k2.5\"\n\n inferred_provider = runtime._build_agent_config({\"model\": \"glm-4.5\"})\n assert inferred_provider.agents.defaults.provider == \"zhipu\"\n assert inferred_provider.agents.defaults.model == \"glm-4.5\"\n\n\ndef test_web_app_state_document_center_updates_and_resets_without_http(tmp_path, monkeypatch) -> None:\n config, _config_path = _make_service_config(tmp_path, monkeypatch)\n state = WebAppState(config)\n try:\n before = state.get_document(\"AGENTS.md\")\n assert before[\"sourcePath\"].endswith(\"AGENTS.md\")\n\n updated = state.update_document(\"AGENTS.md\", \"# Workspace Agent\\n\\nDirect service test.\\n\")\n assert updated[\"content\"].startswith(\"# Workspace Agent\")\n\n reset = state.reset_document(\"AGENTS.md\")\n assert reset[\"content\"] != \"# Workspace Agent\\n\\nDirect service test.\\n\"\n finally:\n asyncio.run(state.shutdown_async())\n"}}}}