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:
Oleg Korshul
2026-01-15 12:07:05 +00:00
10 changed files with 2014 additions and 985 deletions

View File

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

View File

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

View File

@ -10,7 +10,6 @@ import {
} from "../handlers";
describe("handleThoughtContent", () => {
it("should return unchanged message for empty text", () => {
const message = createEmptyMessage();
const result = handleThoughtContent(message, "");

View File

@ -259,6 +259,8 @@ export const createMockResetFn = (
): (() => void) => {
return () => {
vi.clearAllMocks();
mocks.forEach((mock) => mock.mockReset());
mocks.forEach((mock) => {
mock.mockReset();
});
};
};

View File

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

View File

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

View File

@ -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();
});
});

View File

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