mirror of
https://github.com/ONLYOFFICE/desktop-sdk.git
synced 2026-03-31 10:23:12 +08:00
Merge pull request 'AIAgent: add support HTTP custom servers' (#54) from feature/http-servers into release/v9.3.0
Reviewed-on: https://git.onlyoffice.com/ONLYOFFICE/desktop-sdk/pulls/54
This commit is contained in:
File diff suppressed because one or more lines are too long
@ -890,6 +890,177 @@ test.describe('MCP Servers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('HTTP MCP Servers', () => {
|
||||
// Note: In e2e tests, the onlyoffice-proxy:// scheme is not supported,
|
||||
// so HTTP MCP servers won't actually connect. These tests verify config
|
||||
// validation and persistence only (not server interactions).
|
||||
|
||||
test('should accept HTTP MCP server config with url property', async ({ page, settingsPage }) => {
|
||||
await setupMCPServersTab(page, settingsPage);
|
||||
await settingsPage.openMCPConfigDialog();
|
||||
|
||||
// Add HTTP MCP server config
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'http-server': {
|
||||
url: 'https://api.example.com/mcp',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await settingsPage.clearMCPConfigEditor();
|
||||
await page.keyboard.type(JSON.stringify(config));
|
||||
|
||||
// Save button should be enabled for valid HTTP config
|
||||
await expect(settingsPage.mcpConfigDialog.saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('should validate HTTP server config with headers', async ({ page, settingsPage }) => {
|
||||
await setupMCPServersTab(page, settingsPage);
|
||||
await settingsPage.openMCPConfigDialog();
|
||||
|
||||
// Test complex HTTP config with multiple headers
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'github-copilot': {
|
||||
url: 'https://api.github.com/copilot/mcp',
|
||||
headers: {
|
||||
Authorization: 'Bearer ghp_xxxxx',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await settingsPage.clearMCPConfigEditor();
|
||||
await page.keyboard.type(JSON.stringify(config));
|
||||
|
||||
// Should be valid
|
||||
await expect(settingsPage.mcpConfigDialog.saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('should accept mixed HTTP and stdio servers in config', async ({ page, settingsPage }) => {
|
||||
await setupMCPServersTab(page, settingsPage);
|
||||
await settingsPage.openMCPConfigDialog();
|
||||
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'stdio-server': {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
},
|
||||
'http-server': {
|
||||
url: 'https://api.example.com/mcp',
|
||||
},
|
||||
},
|
||||
};
|
||||
await settingsPage.clearMCPConfigEditor();
|
||||
await page.keyboard.type(JSON.stringify(config));
|
||||
|
||||
// Should be valid - both stdio and HTTP configs are accepted
|
||||
await expect(settingsPage.mcpConfigDialog.saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('should persist HTTP server config in localStorage', async ({ page, settingsPage }) => {
|
||||
await setupMCPServersTab(page, settingsPage);
|
||||
await settingsPage.openMCPConfigDialog();
|
||||
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'persist-http-server': {
|
||||
url: 'https://persist.example.com/mcp',
|
||||
headers: { 'X-Custom': 'header-value' },
|
||||
},
|
||||
},
|
||||
};
|
||||
await settingsPage.clearMCPConfigEditor();
|
||||
await page.keyboard.type(JSON.stringify(config));
|
||||
|
||||
await settingsPage.mcpConfigDialog.saveButton.click();
|
||||
await settingsPage.dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
|
||||
// Check localStorage directly
|
||||
const storedData = await page.evaluate(() => {
|
||||
return localStorage.getItem('mcpServers');
|
||||
});
|
||||
|
||||
expect(storedData).not.toBeNull();
|
||||
const parsedData = JSON.parse(storedData!);
|
||||
expect(parsedData.mcpServers['persist-http-server']).toBeDefined();
|
||||
expect(parsedData.mcpServers['persist-http-server'].url).toBe('https://persist.example.com/mcp');
|
||||
expect(parsedData.mcpServers['persist-http-server'].headers['X-Custom']).toBe('header-value');
|
||||
});
|
||||
|
||||
test('should persist HTTP server config after page reload', async ({ page, settingsPage }) => {
|
||||
await setupMCPServersTab(page, settingsPage);
|
||||
await settingsPage.openMCPConfigDialog();
|
||||
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'reload-http-server': {
|
||||
url: 'https://reload.example.com/mcp',
|
||||
},
|
||||
},
|
||||
};
|
||||
await settingsPage.clearMCPConfigEditor();
|
||||
await page.keyboard.type(JSON.stringify(config));
|
||||
|
||||
await settingsPage.mcpConfigDialog.saveButton.click();
|
||||
await settingsPage.dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /settings/i }).first().click();
|
||||
await settingsPage.goToMCPServersTab();
|
||||
|
||||
// Open config dialog and verify HTTP config is persisted
|
||||
await settingsPage.openMCPConfigDialog();
|
||||
await expect(settingsPage.mcpConfigDialog.jsonEditor).toContainText('reload-http-server');
|
||||
await expect(settingsPage.mcpConfigDialog.jsonEditor).toContainText('https://reload.example.com/mcp');
|
||||
});
|
||||
|
||||
test('should store mixed servers config correctly', async ({ page, settingsPage }) => {
|
||||
await setupMCPServersTab(page, settingsPage);
|
||||
await settingsPage.openMCPConfigDialog();
|
||||
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'my-stdio': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { DEBUG: 'true' },
|
||||
},
|
||||
'my-http': {
|
||||
url: 'https://api.example.com/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
},
|
||||
};
|
||||
await settingsPage.clearMCPConfigEditor();
|
||||
await page.keyboard.type(JSON.stringify(config));
|
||||
|
||||
await settingsPage.mcpConfigDialog.saveButton.click();
|
||||
await settingsPage.dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
|
||||
// Check localStorage contains both
|
||||
const storedData = await page.evaluate(() => {
|
||||
return localStorage.getItem('mcpServers');
|
||||
});
|
||||
|
||||
const parsedData = JSON.parse(storedData!);
|
||||
|
||||
// Verify stdio server
|
||||
expect(parsedData.mcpServers['my-stdio'].command).toBe('node');
|
||||
expect(parsedData.mcpServers['my-stdio'].args).toEqual(['server.js']);
|
||||
|
||||
// Verify HTTP server
|
||||
expect(parsedData.mcpServers['my-http'].url).toBe('https://api.example.com/mcp');
|
||||
expect(parsedData.mcpServers['my-http'].headers.Authorization).toBe('Bearer token');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Tool Capacity Management', () => {
|
||||
test('should auto-disable excess tools when server has more than available capacity', async ({ page, settingsPage }) => {
|
||||
await setupMCPServersTab(page, settingsPage);
|
||||
|
||||
@ -2,16 +2,6 @@ import type { TProcess } from "./lib/types";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AscSimpleRequest: {
|
||||
createRequest: (options: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
complete: (e: { responseText: string; responseStatus: number }) => void;
|
||||
error: (e: { statusCode: number }) => void;
|
||||
}) => void;
|
||||
};
|
||||
AscDesktopEditor: {
|
||||
getOfficeFileType: (file: string) => number;
|
||||
getToolFunctions: () => string;
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
} from "../handlers";
|
||||
|
||||
describe("handleThoughtContent", () => {
|
||||
|
||||
it("should return unchanged message for empty text", () => {
|
||||
const message = createEmptyMessage();
|
||||
const result = handleThoughtContent(message, "");
|
||||
|
||||
@ -259,6 +259,8 @@ export const createMockResetFn = (
|
||||
): (() => void) => {
|
||||
return () => {
|
||||
vi.clearAllMocks();
|
||||
mocks.forEach((mock) => mock.mockReset());
|
||||
mocks.forEach((mock) => {
|
||||
mock.mockReset();
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import type { TMCPItem, TProcess } from "@/lib/types";
|
||||
|
||||
type THttpServer = {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
abortController?: AbortController;
|
||||
};
|
||||
|
||||
const getParams = (config: Record<string, unknown>) => {
|
||||
let command = "";
|
||||
const env: Record<string, string> = {};
|
||||
@ -26,12 +32,57 @@ const getParams = (config: Record<string, unknown>) => {
|
||||
return { commandLine, env };
|
||||
};
|
||||
|
||||
const isHttpServer = (config: Record<string, unknown>): boolean => {
|
||||
return typeof config.url === "string";
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse SSE-formatted response text to extract JSON-RPC messages.
|
||||
* SSE format: "event: message\ndata: {...json...}\n\n"
|
||||
*/
|
||||
const parseSSEResponse = (text: string): unknown[] => {
|
||||
const results: unknown[] = [];
|
||||
const lines = text.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const jsonStr = line.slice(6); // Remove "data: " prefix
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
results.push(parsed);
|
||||
} catch {
|
||||
// Skip non-JSON data lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no SSE format found, try parsing the whole text as JSON
|
||||
if (results.length === 0 && text.trim()) {
|
||||
try {
|
||||
results.push(JSON.parse(text));
|
||||
} catch {
|
||||
// Not valid JSON either
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const getHttpParams = (
|
||||
config: Record<string, unknown>
|
||||
): { url: string; headers: Record<string, string> } => {
|
||||
const url = config.url as string;
|
||||
const headers = (config.headers as Record<string, string>) || {};
|
||||
return { url, headers };
|
||||
};
|
||||
|
||||
class CustomServers {
|
||||
customServers: Record<string, Record<string, unknown>>;
|
||||
startedCustomServers: Record<string, string>;
|
||||
initedCustomServers: Record<string, boolean>;
|
||||
stoppedCustomServers: string[];
|
||||
customServersProcesses: Record<string, TProcess>;
|
||||
httpServers: Record<string, THttpServer>;
|
||||
customServersLogs: Record<string, string[]>;
|
||||
tools: Record<string, TMCPItem[]>;
|
||||
|
||||
@ -40,6 +91,7 @@ class CustomServers {
|
||||
this.startedCustomServers = {};
|
||||
this.initedCustomServers = {};
|
||||
this.customServersProcesses = {};
|
||||
this.httpServers = {};
|
||||
this.customServersLogs = {};
|
||||
this.tools = {};
|
||||
this.stoppedCustomServers = [];
|
||||
@ -120,32 +172,11 @@ class CustomServers {
|
||||
Object.entries(this.customServers).forEach(([type, config]) => {
|
||||
servers.push(type);
|
||||
|
||||
const { commandLine, env } = getParams(config);
|
||||
|
||||
if (
|
||||
this.startedCustomServers[type] &&
|
||||
this.startedCustomServers[type] === commandLine
|
||||
) {
|
||||
return;
|
||||
if (isHttpServer(config)) {
|
||||
this.startHttpServer(type, config);
|
||||
} else {
|
||||
this.startStdioServer(type, config);
|
||||
}
|
||||
|
||||
if (this.customServersProcesses[type]) {
|
||||
this.customServersProcesses[type].end();
|
||||
}
|
||||
|
||||
this.customServersLogs[type] = [
|
||||
`${new Date().toLocaleString()}: ${commandLine}\n`,
|
||||
];
|
||||
|
||||
const process = new window.ExternalProcess(commandLine, env);
|
||||
|
||||
process.onprocess = this.onProcess.bind(this, type);
|
||||
|
||||
this.customServersProcesses[type] = process;
|
||||
|
||||
process.start();
|
||||
|
||||
this.initCustomServer(type);
|
||||
});
|
||||
|
||||
// remove deleted servers
|
||||
@ -154,40 +185,144 @@ class CustomServers {
|
||||
this.deleteCustomServer(type);
|
||||
}
|
||||
});
|
||||
|
||||
// remove deleted HTTP servers
|
||||
Object.keys(this.httpServers).forEach((type) => {
|
||||
if (!servers.includes(type)) {
|
||||
this.deleteCustomServer(type);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
startHttpServer = (type: string, config: Record<string, unknown>) => {
|
||||
const { url, headers } = getHttpParams(config);
|
||||
|
||||
if (this.startedCustomServers[type] === url) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop existing HTTP server if running
|
||||
if (this.httpServers[type]?.abortController) {
|
||||
this.httpServers[type].abortController.abort();
|
||||
}
|
||||
|
||||
this.customServersLogs[type] = [
|
||||
`${new Date().toLocaleString()}: HTTP MCP ${url}\n`,
|
||||
];
|
||||
|
||||
this.httpServers[type] = {
|
||||
url,
|
||||
headers,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
|
||||
this.startedCustomServers[type] = url;
|
||||
this.initHttpServer(type);
|
||||
};
|
||||
|
||||
startStdioServer = (type: string, config: Record<string, unknown>) => {
|
||||
const { commandLine, env } = getParams(config);
|
||||
|
||||
if (
|
||||
this.startedCustomServers[type] &&
|
||||
this.startedCustomServers[type] === commandLine
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.customServersProcesses[type]) {
|
||||
this.customServersProcesses[type].end();
|
||||
}
|
||||
|
||||
this.customServersLogs[type] = [
|
||||
`${new Date().toLocaleString()}: ${commandLine}\n`,
|
||||
];
|
||||
|
||||
const process = new window.ExternalProcess(commandLine, env);
|
||||
|
||||
process.onprocess = this.onProcess.bind(this, type);
|
||||
|
||||
this.customServersProcesses[type] = process;
|
||||
|
||||
process.start();
|
||||
|
||||
this.startedCustomServers[type] = commandLine;
|
||||
this.initCustomServer(type);
|
||||
};
|
||||
|
||||
restartCustomServer = (type: string) => {
|
||||
Object.entries(this.customServers).forEach(([serverType, config]) => {
|
||||
if (type !== serverType) return;
|
||||
|
||||
this.customServersProcesses[type].end();
|
||||
|
||||
const { commandLine, env } = getParams(config);
|
||||
|
||||
this.customServersLogs[type] = [
|
||||
`${new Date().toLocaleString()}: ${commandLine}\n`,
|
||||
];
|
||||
|
||||
this.tools[type] = [];
|
||||
|
||||
const process = new window.ExternalProcess(commandLine, env);
|
||||
|
||||
process.onprocess = this.onProcess.bind(this, type);
|
||||
|
||||
this.customServersProcesses[type] = process;
|
||||
|
||||
process.start();
|
||||
|
||||
this.initCustomServer(type);
|
||||
window.dispatchEvent(new CustomEvent("tools-changed"));
|
||||
if (isHttpServer(config)) {
|
||||
this.restartHttpServer(type, config);
|
||||
} else {
|
||||
this.restartStdioServer(type, config);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
restartHttpServer = (type: string, config: Record<string, unknown>) => {
|
||||
// Stop existing connection
|
||||
if (this.httpServers[type]?.abortController) {
|
||||
this.httpServers[type].abortController.abort();
|
||||
}
|
||||
|
||||
const { url, headers } = getHttpParams(config);
|
||||
|
||||
this.customServersLogs[type] = [
|
||||
`${new Date().toLocaleString()}: HTTP MCP ${url}\n`,
|
||||
];
|
||||
|
||||
this.tools[type] = [];
|
||||
this.initedCustomServers[type] = false;
|
||||
|
||||
this.httpServers[type] = {
|
||||
url,
|
||||
headers,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
|
||||
this.initHttpServer(type);
|
||||
window.dispatchEvent(new CustomEvent("tools-changed"));
|
||||
};
|
||||
|
||||
restartStdioServer = (type: string, config: Record<string, unknown>) => {
|
||||
this.customServersProcesses[type].end();
|
||||
|
||||
const { commandLine, env } = getParams(config);
|
||||
|
||||
this.customServersLogs[type] = [
|
||||
`${new Date().toLocaleString()}: ${commandLine}\n`,
|
||||
];
|
||||
|
||||
this.tools[type] = [];
|
||||
|
||||
const process = new window.ExternalProcess(commandLine, env);
|
||||
|
||||
process.onprocess = this.onProcess.bind(this, type);
|
||||
|
||||
this.customServersProcesses[type] = process;
|
||||
|
||||
process.start();
|
||||
|
||||
this.initCustomServer(type);
|
||||
window.dispatchEvent(new CustomEvent("tools-changed"));
|
||||
};
|
||||
|
||||
deleteCustomServer = (type: string) => {
|
||||
// Stop stdio process if exists
|
||||
if (this.customServersProcesses[type]) {
|
||||
this.customServersProcesses[type].end();
|
||||
delete this.customServersProcesses[type];
|
||||
}
|
||||
// Stop HTTP connection if exists
|
||||
if (this.httpServers[type]) {
|
||||
if (this.httpServers[type].abortController) {
|
||||
this.httpServers[type].abortController.abort();
|
||||
}
|
||||
delete this.httpServers[type];
|
||||
}
|
||||
if (this.customServersLogs[type]) {
|
||||
delete this.customServersLogs[type];
|
||||
}
|
||||
@ -203,6 +338,63 @@ class CustomServers {
|
||||
window.dispatchEvent(new CustomEvent("tools-changed"));
|
||||
};
|
||||
|
||||
initHttpServer = async (type: string) => {
|
||||
const server = this.httpServers[type];
|
||||
if (!server) return;
|
||||
|
||||
try {
|
||||
// Send initialize request
|
||||
const initRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: `init-${type}`,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
clientInfo: {
|
||||
name: "ai-agent",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`onlyoffice-proxy://${server.url}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...server.headers,
|
||||
},
|
||||
body: JSON.stringify(initRequest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const results = parseSSEResponse(text);
|
||||
|
||||
for (const result of results) {
|
||||
this.onProcess(type, 0, JSON.stringify(result));
|
||||
|
||||
const msg = result as { jsonrpc?: string; id?: string };
|
||||
if (msg.jsonrpc === "2.0" && msg.id?.includes(`init-${type}`)) {
|
||||
this.initedCustomServers[type] = true;
|
||||
this.stoppedCustomServers = this.stoppedCustomServers.filter(
|
||||
(s) => s !== type
|
||||
);
|
||||
await this.getToolsFromHttpMCP(type);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error initializing HTTP MCP server ${type}:`, error);
|
||||
this.onProcess(type, 2, `Connection failed: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
initCustomServer = (type: string) => {
|
||||
const process = this.customServersProcesses[type];
|
||||
|
||||
@ -246,6 +438,42 @@ class CustomServers {
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
getToolsFromHttpMCP = async (type: string) => {
|
||||
const server = this.httpServers[type];
|
||||
if (!server) return;
|
||||
|
||||
try {
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
id: `tools-${type}-${Date.now()}`,
|
||||
method: "tools/list",
|
||||
params: {},
|
||||
};
|
||||
|
||||
const response = await fetch(`onlyoffice-proxy://${server.url}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...server.headers,
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const results = parseSSEResponse(text);
|
||||
|
||||
for (const result of results) {
|
||||
this.onProcess(type, 0, JSON.stringify(result));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting tools from HTTP MCP server ${type}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
getToolsFromMCP = async (type: string) => {
|
||||
// Get all running custom server processes
|
||||
|
||||
@ -273,6 +501,93 @@ class CustomServers {
|
||||
serverType: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>
|
||||
): Promise<unknown> => {
|
||||
// Check if it's an HTTP server
|
||||
if (this.httpServers[serverType]) {
|
||||
return this.callToolFromHttpMCP(serverType, toolName, args);
|
||||
}
|
||||
|
||||
// Otherwise use stdio
|
||||
return this.callToolFromStdioMCP(serverType, toolName, args);
|
||||
};
|
||||
|
||||
callToolFromHttpMCP = async (
|
||||
serverType: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>
|
||||
): Promise<unknown> => {
|
||||
const server = this.httpServers[serverType];
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`HTTP MCP server ${serverType} is not running`);
|
||||
}
|
||||
|
||||
// Check if tool exists
|
||||
const serverTools = this.tools[serverType] || [];
|
||||
const tool = serverTools.find((t) => t.name === toolName);
|
||||
|
||||
if (!tool) {
|
||||
throw new Error(`Tool ${toolName} not found on server ${serverType}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
id: `call-${serverType}-${toolName}-${Date.now()}`,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`onlyoffice-proxy://${server.url}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...server.headers,
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const results = parseSSEResponse(text);
|
||||
|
||||
for (const result of results) {
|
||||
this.onProcess(serverType, 0, JSON.stringify(result));
|
||||
|
||||
const msg = result as {
|
||||
error?: { code: number; message: string };
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
if (msg.error) {
|
||||
throw new Error(
|
||||
`MCP tool error (${msg.error.code}): ${msg.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.result !== undefined) {
|
||||
return JSON.stringify(msg.result);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No result in response");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Error calling HTTP MCP tool ${toolName} on server ${serverType}: ${error}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
callToolFromStdioMCP = async (
|
||||
serverType: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>
|
||||
): Promise<unknown> => {
|
||||
const process = this.customServersProcesses[serverType];
|
||||
|
||||
|
||||
@ -47,13 +47,9 @@ class WebSearch {
|
||||
webSearch = async (args: Record<string, unknown>) => {
|
||||
if (this.webSearchData?.provider === "Exa") {
|
||||
try {
|
||||
const result = await new Promise<{
|
||||
error?: number;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}>((resolve) => {
|
||||
window.AscSimpleRequest.createRequest({
|
||||
url: "https://api.exa.ai/search",
|
||||
const response = await fetch(
|
||||
"onlyoffice-proxy://https://api.exa.ai/search",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@ -65,27 +61,22 @@ class WebSearch {
|
||||
numResults: 5,
|
||||
livecrawl: "preferred",
|
||||
}),
|
||||
complete: (e: { responseText: string }) => {
|
||||
const parsedData = JSON.parse(e.responseText);
|
||||
const data = parsedData.error
|
||||
? {
|
||||
error: parsedData.error,
|
||||
}
|
||||
: parsedData.results;
|
||||
resolve({ data });
|
||||
},
|
||||
error: (e: { statusCode: number }) => {
|
||||
console.log("Request failed with status:", e.statusCode);
|
||||
if (e.statusCode === -102) e.statusCode = 404;
|
||||
resolve({
|
||||
error: e.statusCode,
|
||||
message: `Network error: ${e.statusCode}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return JSON.stringify(result);
|
||||
if (!response.ok) {
|
||||
return JSON.stringify({
|
||||
error: response.status,
|
||||
message: `Network error: ${response.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
const parsedData = await response.json();
|
||||
const data = parsedData.error
|
||||
? { error: parsedData.error }
|
||||
: parsedData.results;
|
||||
|
||||
return JSON.stringify({ data });
|
||||
} catch (e) {
|
||||
console.error("WebSearch error:", e);
|
||||
return JSON.stringify({ error: e });
|
||||
@ -97,13 +88,9 @@ class WebSearch {
|
||||
webCrawling = async (args: Record<string, unknown>) => {
|
||||
if (this.webSearchData?.provider === "Exa") {
|
||||
try {
|
||||
const result = await new Promise<{
|
||||
error?: number;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}>((resolve) => {
|
||||
window.AscSimpleRequest.createRequest({
|
||||
url: "https://api.exa.ai/contents",
|
||||
const response = await fetch(
|
||||
"onlyoffice-proxy://https://api.exa.ai/contents",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@ -113,29 +100,25 @@ class WebSearch {
|
||||
urls: args.urls,
|
||||
text: true,
|
||||
}),
|
||||
complete: (e: { responseText: string }) => {
|
||||
const parsedData = JSON.parse(e.responseText);
|
||||
const data = parsedData.error
|
||||
? {
|
||||
error: parsedData.error,
|
||||
}
|
||||
: parsedData.results;
|
||||
resolve({ data });
|
||||
},
|
||||
error: (e: { statusCode: number }) => {
|
||||
console.log("Request failed with status:", e.statusCode);
|
||||
if (e.statusCode === -102) e.statusCode = 404;
|
||||
resolve({
|
||||
error: e.statusCode,
|
||||
message: `Network error: ${e.statusCode}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return JSON.stringify(result);
|
||||
if (!response.ok) {
|
||||
return JSON.stringify({
|
||||
error: response.status,
|
||||
message: `Network error: ${response.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
const parsedData = await response.json();
|
||||
const data = parsedData.error
|
||||
? { error: parsedData.error }
|
||||
: parsedData.results;
|
||||
|
||||
return JSON.stringify({ data });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return JSON.stringify({ error: e });
|
||||
}
|
||||
}
|
||||
return JSON.stringify(args);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ import { WebSearch, type WebSearchData } from "../WebSearch";
|
||||
// Mock Setup
|
||||
// =============================================================================
|
||||
|
||||
const mockCreateRequest = vi.fn();
|
||||
const mockFetch = vi.fn();
|
||||
const mockDispatchEvent = vi.fn();
|
||||
|
||||
const createMockLocalStorage = () => {
|
||||
@ -29,7 +29,6 @@ const mockLocalStorage = createMockLocalStorage();
|
||||
// Mock window object for Node environment
|
||||
const mockWindow = {
|
||||
localStorage: mockLocalStorage,
|
||||
AscSimpleRequest: { createRequest: mockCreateRequest },
|
||||
dispatchEvent: mockDispatchEvent,
|
||||
CustomEvent: class CustomEvent {
|
||||
type: string;
|
||||
@ -42,6 +41,7 @@ const mockWindow = {
|
||||
vi.stubGlobal("window", mockWindow);
|
||||
vi.stubGlobal("localStorage", mockLocalStorage);
|
||||
vi.stubGlobal("CustomEvent", mockWindow.CustomEvent);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
describe("WebSearch", () => {
|
||||
let webSearch: WebSearch;
|
||||
@ -175,7 +175,7 @@ describe("WebSearch", () => {
|
||||
const result = await webSearch.webSearch({ query: "test" });
|
||||
|
||||
expect(result).toBe(JSON.stringify({ query: "test" }));
|
||||
expect(mockCreateRequest).not.toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return args as JSON when no provider configured", async () => {
|
||||
@ -187,15 +187,16 @@ describe("WebSearch", () => {
|
||||
it("should make Exa API request with correct parameters", async () => {
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "test-api-key" });
|
||||
|
||||
mockCreateRequest.mockImplementation((options) => {
|
||||
options.complete({ responseText: JSON.stringify({ results: [] }) });
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ results: [] }),
|
||||
});
|
||||
|
||||
await webSearch.webSearch({ query: "test query" });
|
||||
|
||||
expect(mockCreateRequest).toHaveBeenCalledWith(
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"onlyoffice-proxy://https://api.exa.ai/search",
|
||||
expect.objectContaining({
|
||||
url: "https://api.exa.ai/search",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@ -204,7 +205,7 @@ describe("WebSearch", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const callBody = JSON.parse(mockCreateRequest.mock.calls[0][0].body);
|
||||
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(callBody).toEqual({
|
||||
query: "test query",
|
||||
text: true,
|
||||
@ -217,10 +218,9 @@ describe("WebSearch", () => {
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "key" });
|
||||
|
||||
const mockResults = [{ title: "Result 1", url: "https://example.com" }];
|
||||
mockCreateRequest.mockImplementation((options) => {
|
||||
options.complete({
|
||||
responseText: JSON.stringify({ results: mockResults }),
|
||||
});
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ results: mockResults }),
|
||||
});
|
||||
|
||||
const result = await webSearch.webSearch({ query: "test" });
|
||||
@ -233,10 +233,9 @@ describe("WebSearch", () => {
|
||||
it("should handle API error response", async () => {
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "key" });
|
||||
|
||||
mockCreateRequest.mockImplementation((options) => {
|
||||
options.complete({
|
||||
responseText: JSON.stringify({ error: 401 }),
|
||||
});
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ error: 401 }),
|
||||
});
|
||||
|
||||
const result = await webSearch.webSearch({ query: "test" });
|
||||
@ -246,11 +245,12 @@ describe("WebSearch", () => {
|
||||
expect(parsed.data).toEqual({ error: 401 });
|
||||
});
|
||||
|
||||
it("should handle network error", async () => {
|
||||
it("should handle network error (non-ok response)", async () => {
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "key" });
|
||||
|
||||
mockCreateRequest.mockImplementation((options) => {
|
||||
options.error({ statusCode: 500 });
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
const result = await webSearch.webSearch({ query: "test" });
|
||||
@ -260,32 +260,41 @@ describe("WebSearch", () => {
|
||||
expect(parsed.message).toBe("Network error: 500");
|
||||
});
|
||||
|
||||
it("should convert -102 status code to 404", async () => {
|
||||
it("should handle fetch exception", async () => {
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "key" });
|
||||
|
||||
mockCreateRequest.mockImplementation((options) => {
|
||||
options.error({ statusCode: -102 });
|
||||
});
|
||||
mockFetch.mockRejectedValue(new Error("Network failure"));
|
||||
|
||||
const result = await webSearch.webSearch({ query: "test" });
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed.error).toBe(404);
|
||||
expect(parsed.message).toBe("Network error: 404");
|
||||
expect(parsed.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle invalid JSON response", async () => {
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "key" });
|
||||
|
||||
mockCreateRequest.mockImplementation((options) => {
|
||||
options.complete({ responseText: "invalid json" });
|
||||
it("should use empty string for x-api-key when key is undefined", async () => {
|
||||
// Line 56: test ?? "" fallback when key is undefined
|
||||
// Force the data to have undefined key by casting
|
||||
webSearch.setWebSearchData({
|
||||
provider: "Exa",
|
||||
key: undefined as unknown as string,
|
||||
});
|
||||
|
||||
const result = await webSearch.webSearch({ query: "test" });
|
||||
const parsed = JSON.parse(result);
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ results: [] }),
|
||||
});
|
||||
|
||||
// expect(parsed.error).toBe(500);
|
||||
expect(parsed.error).toStrictEqual({});
|
||||
await webSearch.webSearch({ query: "test" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"onlyoffice-proxy://https://api.exa.ai/search",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": "",
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -302,26 +311,27 @@ describe("WebSearch", () => {
|
||||
});
|
||||
|
||||
expect(result).toBe(JSON.stringify({ urls: ["https://example.com"] }));
|
||||
expect(mockCreateRequest).not.toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should make Exa API request to contents endpoint", async () => {
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "test-key" });
|
||||
|
||||
mockCreateRequest.mockImplementation((options) => {
|
||||
options.complete({ responseText: JSON.stringify({ results: [] }) });
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ results: [] }),
|
||||
});
|
||||
|
||||
await webSearch.webCrawling({ urls: ["https://example.com"] });
|
||||
|
||||
expect(mockCreateRequest).toHaveBeenCalledWith(
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"onlyoffice-proxy://https://api.exa.ai/contents",
|
||||
expect.objectContaining({
|
||||
url: "https://api.exa.ai/contents",
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
|
||||
const callBody = JSON.parse(mockCreateRequest.mock.calls[0][0].body);
|
||||
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(callBody).toEqual({
|
||||
urls: ["https://example.com"],
|
||||
text: true,
|
||||
@ -334,10 +344,9 @@ describe("WebSearch", () => {
|
||||
const mockResults = [
|
||||
{ url: "https://example.com", text: "Page content" },
|
||||
];
|
||||
mockCreateRequest.mockImplementation((options) => {
|
||||
options.complete({
|
||||
responseText: JSON.stringify({ results: mockResults }),
|
||||
});
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ results: mockResults }),
|
||||
});
|
||||
|
||||
const result = await webSearch.webCrawling({
|
||||
@ -348,11 +357,12 @@ describe("WebSearch", () => {
|
||||
expect(parsed.data).toEqual(mockResults);
|
||||
});
|
||||
|
||||
it("should handle network error", async () => {
|
||||
it("should handle network error (non-ok response)", async () => {
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "key" });
|
||||
|
||||
mockCreateRequest.mockImplementation((options) => {
|
||||
options.error({ statusCode: 503 });
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
});
|
||||
|
||||
const result = await webSearch.webCrawling({
|
||||
@ -362,6 +372,61 @@ describe("WebSearch", () => {
|
||||
|
||||
expect(parsed.error).toBe(503);
|
||||
});
|
||||
|
||||
it("should handle fetch exception", async () => {
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "key" });
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("Network failure"));
|
||||
|
||||
const result = await webSearch.webCrawling({
|
||||
urls: ["https://example.com"],
|
||||
});
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle API error response in crawling", async () => {
|
||||
// Line 114: test parsedData.error branch in webCrawling
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "key" });
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ error: "Invalid URL" }),
|
||||
});
|
||||
|
||||
const result = await webSearch.webCrawling({
|
||||
urls: ["https://example.com"],
|
||||
});
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed.data).toEqual({ error: "Invalid URL" });
|
||||
});
|
||||
|
||||
it("should use empty string for x-api-key when key is undefined", async () => {
|
||||
// Line 97: test ?? "" fallback when key is undefined
|
||||
webSearch.setWebSearchData({
|
||||
provider: "Exa",
|
||||
key: undefined as unknown as string,
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ results: [] }),
|
||||
});
|
||||
|
||||
await webSearch.webCrawling({ urls: ["https://example.com"] });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"onlyoffice-proxy://https://api.exa.ai/contents",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": "",
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
@ -371,8 +436,9 @@ describe("WebSearch", () => {
|
||||
describe("callTools", () => {
|
||||
beforeEach(() => {
|
||||
webSearch.setWebSearchData({ provider: "Exa", key: "key" });
|
||||
mockCreateRequest.mockImplementation((options) => {
|
||||
options.complete({ responseText: JSON.stringify({ results: [] }) });
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ results: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
@ -381,10 +447,9 @@ describe("WebSearch", () => {
|
||||
query: "test",
|
||||
});
|
||||
|
||||
expect(mockCreateRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.exa.ai/search",
|
||||
})
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"onlyoffice-proxy://https://api.exa.ai/search",
|
||||
expect.anything()
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
@ -394,10 +459,9 @@ describe("WebSearch", () => {
|
||||
urls: ["https://example.com"],
|
||||
});
|
||||
|
||||
expect(mockCreateRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.exa.ai/contents",
|
||||
})
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"onlyoffice-proxy://https://api.exa.ai/contents",
|
||||
expect.anything()
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
@ -406,7 +470,7 @@ describe("WebSearch", () => {
|
||||
const result = await webSearch.callTools("unknown_tool", {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockCreateRequest).not.toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,269 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Use vi.hoisted to ensure mocks are available when vi.mock is hoisted
|
||||
const {
|
||||
localStorageMock,
|
||||
desktopEditorGetToolsMock,
|
||||
desktopEditorCallToolsMock,
|
||||
webSearchGetToolsMock,
|
||||
webSearchCallToolsMock,
|
||||
webSearchSetDataMock,
|
||||
webSearchGetDataMock,
|
||||
webSearchGetEnabledMock,
|
||||
customServersGetToolsMock,
|
||||
customServersCallToolMock,
|
||||
customServersGetServerTypeMock,
|
||||
customServersSetMock,
|
||||
customServersStartMock,
|
||||
customServersRestartMock,
|
||||
customServersDeleteMock,
|
||||
} = vi.hoisted(() => {
|
||||
// Create localStorage mock and attach to global before any imports
|
||||
const storageMock = {
|
||||
store: {} as Record<string, string>,
|
||||
getItem: vi.fn((key: string) => storageMock.store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
storageMock.store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete storageMock.store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
storageMock.store = {};
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(global, "localStorage", {
|
||||
value: storageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock window for CustomServers
|
||||
Object.defineProperty(global, "window", {
|
||||
value: {
|
||||
AscDesktopEditor: undefined,
|
||||
dispatchEvent: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
return {
|
||||
localStorageMock: storageMock,
|
||||
desktopEditorGetToolsMock: vi.fn(),
|
||||
desktopEditorCallToolsMock: vi.fn(),
|
||||
webSearchGetToolsMock: vi.fn(),
|
||||
webSearchCallToolsMock: vi.fn(),
|
||||
webSearchSetDataMock: vi.fn(),
|
||||
webSearchGetDataMock: vi.fn(),
|
||||
webSearchGetEnabledMock: vi.fn(),
|
||||
customServersGetToolsMock: vi.fn(),
|
||||
customServersCallToolMock: vi.fn(),
|
||||
customServersGetServerTypeMock: vi.fn(),
|
||||
customServersSetMock: vi.fn(),
|
||||
customServersStartMock: vi.fn(),
|
||||
customServersRestartMock: vi.fn(),
|
||||
customServersDeleteMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("../DesktopEditor", () => ({
|
||||
DesktopEditorTool: class {
|
||||
getTools = desktopEditorGetToolsMock;
|
||||
callTools = desktopEditorCallToolsMock;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../WebSearch", () => ({
|
||||
WebSearch: class {
|
||||
getTools = webSearchGetToolsMock;
|
||||
callTools = webSearchCallToolsMock;
|
||||
setWebSearchData = webSearchSetDataMock;
|
||||
getWebSearchData = webSearchGetDataMock;
|
||||
getWebSearchEnabled = webSearchGetEnabledMock;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../CustomServers", () => ({
|
||||
CustomServers: class {
|
||||
getTools = customServersGetToolsMock;
|
||||
callToolFromMCP = customServersCallToolMock;
|
||||
getServerType = customServersGetServerTypeMock;
|
||||
setCustomServers = customServersSetMock;
|
||||
startCustomServers = customServersStartMock;
|
||||
restartCustomServer = customServersRestartMock;
|
||||
deleteCustomServer = customServersDeleteMock;
|
||||
customServers = { "test-server": {} };
|
||||
stoppedCustomServers: string[] = [];
|
||||
customServersLogs = { "test-server": "logs" };
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import servers from "../index";
|
||||
|
||||
describe("Servers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
|
||||
// Setup default mock return values
|
||||
desktopEditorGetToolsMock.mockResolvedValue([{ name: "desktop_tool" }]);
|
||||
desktopEditorCallToolsMock.mockReturnValue("desktop result");
|
||||
webSearchGetToolsMock.mockResolvedValue([{ name: "web_search" }]);
|
||||
webSearchCallToolsMock.mockResolvedValue("search result");
|
||||
webSearchGetDataMock.mockReturnValue({ provider: "test", key: "test-key" });
|
||||
webSearchGetEnabledMock.mockReturnValue(true);
|
||||
customServersGetToolsMock.mockResolvedValue({
|
||||
"custom-server": [{ name: "custom_tool" }],
|
||||
});
|
||||
customServersCallToolMock.mockResolvedValue("mcp result");
|
||||
customServersGetServerTypeMock.mockReturnValue("custom-server");
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with empty allowAlways when localStorage is empty", async () => {
|
||||
// The servers singleton is already created, but we can test checkAllowAlways
|
||||
expect(servers.checkAllowAlways("some-type", "some-name")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAllowAlways", () => {
|
||||
it("should return true for web-search type", () => {
|
||||
expect(servers.checkAllowAlways("web-search", "any-name")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for unknown tools", () => {
|
||||
expect(servers.checkAllowAlways("unknown", "tool")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for tools in allowAlways list", () => {
|
||||
servers.setAllowAlways(true, "custom", "mytool");
|
||||
expect(servers.checkAllowAlways("custom", "mytool")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAllowAlways", () => {
|
||||
it("should ignore web-search type", () => {
|
||||
servers.setAllowAlways(true, "web-search", "search");
|
||||
// web-search is always allowed, so this should be a no-op
|
||||
expect(localStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should add tool to allowAlways when value is true", () => {
|
||||
servers.setAllowAlways(true, "mcp", "tool1");
|
||||
expect(servers.checkAllowAlways("mcp", "tool1")).toBe(true);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remove tool from allowAlways when value is false", () => {
|
||||
servers.setAllowAlways(true, "mcp", "tool2");
|
||||
servers.setAllowAlways(false, "mcp", "tool2");
|
||||
expect(servers.checkAllowAlways("mcp", "tool2")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTools", () => {
|
||||
it("should return tools from all servers", async () => {
|
||||
const tools = await servers.getTools();
|
||||
|
||||
expect(tools).toHaveProperty("desktop-editor");
|
||||
expect(tools).toHaveProperty("web-search");
|
||||
expect(tools).toHaveProperty("custom-server");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callTools", () => {
|
||||
it("should call desktop editor tools", async () => {
|
||||
const result = await servers.callTools("desktop-editor", "tool", {});
|
||||
expect(result).toBe("desktop result");
|
||||
});
|
||||
|
||||
it("should call web search tools", async () => {
|
||||
const result = await servers.callTools("web-search", "web_search", {});
|
||||
expect(result).toBe("search result");
|
||||
});
|
||||
|
||||
it("should call MCP server tools for other types", async () => {
|
||||
const result = await servers.callTools(
|
||||
"custom-server",
|
||||
"custom_tool",
|
||||
{}
|
||||
);
|
||||
expect(result).toBe("mcp result");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getServerType", () => {
|
||||
it("should return desktop-editor for desktop-editor tools", () => {
|
||||
expect(servers.getServerType("desktop-editor_tool")).toBe(
|
||||
"desktop-editor"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return web-search for web-search tools", () => {
|
||||
expect(servers.getServerType("web-search_tool")).toBe("web-search");
|
||||
});
|
||||
|
||||
it("should delegate to customServers for other tools", () => {
|
||||
expect(servers.getServerType("other_tool")).toBe("custom-server");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom server management", () => {
|
||||
it("should set custom servers", () => {
|
||||
servers.setCustomServers({ mcpServers: { test: {} } });
|
||||
expect(customServersSetMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should start custom servers", () => {
|
||||
servers.startCustomServers();
|
||||
expect(customServersStartMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should restart custom server", () => {
|
||||
servers.restartCustomServer("test");
|
||||
expect(customServersRestartMock).toHaveBeenCalledWith("test");
|
||||
});
|
||||
|
||||
it("should delete custom server", () => {
|
||||
servers.deleteCustomServer("test");
|
||||
expect(customServersDeleteMock).toHaveBeenCalledWith("test");
|
||||
});
|
||||
|
||||
it("should get custom servers", () => {
|
||||
const result = servers.getCustomServers();
|
||||
expect(result).toEqual({ "test-server": {} });
|
||||
});
|
||||
|
||||
it("should get stopped custom servers", () => {
|
||||
const result = servers.getCustomServersStoped();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should get custom servers logs", () => {
|
||||
const result = servers.getCustomServersLogs();
|
||||
expect(result).toEqual({ "test-server": "logs" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("web search management", () => {
|
||||
it("should set web search data", () => {
|
||||
servers.setWebSearchData({ provider: "test", key: "test-key" });
|
||||
expect(webSearchSetDataMock).toHaveBeenCalledWith({
|
||||
provider: "test",
|
||||
key: "test-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("should get web search data", () => {
|
||||
const result = servers.getWebSearchData();
|
||||
expect(result).toEqual({ provider: "test", key: "test-key" });
|
||||
});
|
||||
|
||||
it("should get web search enabled status", () => {
|
||||
const result = servers.getWebSearchEnabled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user