Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default [
...tseslint.configs.recommended.rules,
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"no-undef": "off", // https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
},
},
{
Expand Down
74 changes: 68 additions & 6 deletions scripts/__tests__/start-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadOpenApiSpec, ValidationError } from "../../src/init-server";
import { ensureAnytypeRunning } from "../../src/utils/anytype-launcher";
import { overrideSpecPath } from "../../src/utils/base-url";

// Reset specPathOverride after each test to avoid inter-test contamination
afterEach(() => overrideSpecPath(undefined));

// Mock fs and axios
// Mock fs, axios, and the launcher
vi.mock("node:fs");
vi.mock("axios");
vi.mock("@modelcontextprotocol/sdk/server/stdio.js");
vi.mock("../../src/utils/anytype-launcher", () => ({
ensureAnytypeRunning: vi.fn().mockResolvedValue(false),
}));

// Create a mock Server class with proper prototype methods
const mockSetRequestHandler = vi.fn();
Expand Down Expand Up @@ -96,7 +100,10 @@ describe("loadOpenApiSpec", () => {

await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith("Failed to read OpenAPI specification file:", expect.any(String));
expect(console.error).toHaveBeenCalledWith(
"Failed to read OpenAPI specification file from ./non-existent.json:",
"ENOENT: no such file or directory",
);
expect(mockExit).toHaveBeenCalledWith(1);
});

Expand All @@ -110,7 +117,10 @@ describe("loadOpenApiSpec", () => {

await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith("Failed to parse OpenAPI specification:", expect.any(String));
expect(console.error).toHaveBeenCalledWith(
"Failed to parse OpenAPI specification from ./invalid.json:",
expect.any(String),
);
expect(mockExit).toHaveBeenCalledWith(1);
});

Expand All @@ -136,7 +146,10 @@ describe("loadOpenApiSpec", () => {

await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith("Failed to parse OpenAPI specification:", expect.any(String));
expect(console.error).toHaveBeenCalledWith(
"Failed to parse OpenAPI specification from ./invalid.yaml:",
expect.any(String),
);
expect(mockExit).toHaveBeenCalledWith(1);
});
});
Expand All @@ -163,7 +176,10 @@ describe("loadOpenApiSpec", () => {

await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith("Failed to fetch OpenAPI specification from URL:", "Network Error");
expect(console.error).toHaveBeenCalledWith(
"Failed to fetch OpenAPI specification from http://example.com/api-spec.json:",
"Network Error",
);
expect(mockExit).toHaveBeenCalledWith(1);
});

Expand All @@ -177,7 +193,10 @@ describe("loadOpenApiSpec", () => {

await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith("Failed to parse OpenAPI specification:", expect.any(String));
expect(console.error).toHaveBeenCalledWith(
"Failed to parse OpenAPI specification from http://example.com/api-spec.json:",
expect.any(String),
);
expect(mockExit).toHaveBeenCalledWith(1);
});

Expand All @@ -193,6 +212,49 @@ describe("loadOpenApiSpec", () => {
expect(axios.get).toHaveBeenCalledWith("http://example.com/api-spec.yaml");
});
});

describe("ECONNREFUSED auto-launch retry", () => {
const econnRefused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:31009"), { code: "ECONNREFUSED" });

it("launches Anytype and retries when ECONNREFUSED on a local URL", async () => {
vi.mocked(ensureAnytypeRunning).mockResolvedValue(true);
// First call throws, second succeeds after launch
vi.mocked(axios.get).mockRejectedValueOnce(econnRefused).mockResolvedValueOnce({ data: validOpenApiSpec });
overrideSpecPath("http://127.0.0.1:31009/docs/openapi.json");

const result = await loadOpenApiSpec();

expect(ensureAnytypeRunning).toHaveBeenCalledWith("http://127.0.0.1:31009");
expect(result).toEqual(validOpenApiSpec);
});

it("exits with 1 when ECONNREFUSED and ensureAnytypeRunning returns false", async () => {
vi.mocked(ensureAnytypeRunning).mockResolvedValue(false);
vi.mocked(axios.get).mockRejectedValueOnce(econnRefused);
overrideSpecPath("http://127.0.0.1:31009/docs/openapi.json");

const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Cannot connect to Anytype API"));
expect(mockExit).toHaveBeenCalledWith(1);
});

it("exits with 1 when Anytype launches but API is still unreachable on retry", async () => {
vi.mocked(ensureAnytypeRunning).mockResolvedValue(true);
vi.mocked(axios.get).mockRejectedValue(econnRefused); // both calls fail
overrideSpecPath("http://127.0.0.1:31009/docs/openapi.json");

const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith(
"Failed to parse OpenAPI specification from http://127.0.0.1:31009/docs/openapi.json:",
expect.any(String),
);
expect(mockExit).toHaveBeenCalledWith(1);
});
});
});

describe("main", () => {
Expand Down
80 changes: 79 additions & 1 deletion src/client/__tests__/http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Headers } from "node-fetch";
import OpenAPIClientAxios from "openapi-client-axios";
import { OpenAPIV3 } from "openapi-types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HttpClient } from "../http-client";
import { HttpClient, HttpClientConnectionError } from "../http-client";

function makeAxiosError(status: number, statusText: string, data: any, headers: Record<string, string> = {}) {
const err = new axios.AxiosError(statusText);
Expand Down Expand Up @@ -533,4 +533,82 @@ describe("HttpClient", () => {
// Additional check to ensure headers are correctly processed
expect(response.headers.get("content-type")).toBe("application/json");
});

// ─── HttpClientConnectionError ────────────────────────────────────────────

describe("connection errors", () => {
function makeConnectionError(code: string) {
const err = new axios.AxiosError(code);
err.code = code;
// No `response` — transport-level failure
err.isAxiosError = true;
return err;
}

it.each([["ECONNREFUSED"], ["ENOTFOUND"], ["ETIMEDOUT"]])(
"throws HttpClientConnectionError for %s",
async (code) => {
const mockAxiosInstance = {
testOperation: vi.fn().mockRejectedValue(makeConnectionError(code)),
};
const MockClient = vi.fn(function (this: any) {
this.init = vi.fn().mockResolvedValue(mockAxiosInstance);
this.axiosConfigDefaults = { baseURL: "http://127.0.0.1:31009" };
return this;
});
vi.mocked(OpenAPIClientAxios).mockImplementation(MockClient as any);

const client = new HttpClient(mockConfig, mockOpenApiSpec);
const operation = mockOpenApiSpec.paths["/test"]?.post as OpenAPIV3.OperationObject & {
method: string;
path: string;
};

await expect(client.executeOperation(operation, {})).rejects.toThrow(HttpClientConnectionError);
},
);

it("error message includes the base URL", async () => {
const mockAxiosInstance = {
testOperation: vi.fn().mockRejectedValue(makeConnectionError("ECONNREFUSED")),
};
const MockClient = vi.fn(function (this: any) {
this.init = vi.fn().mockResolvedValue(mockAxiosInstance);
this.axiosConfigDefaults = { baseURL: "http://127.0.0.1:31009" };
return this;
});
vi.mocked(OpenAPIClientAxios).mockImplementation(MockClient as any);

const client = new HttpClient({ baseUrl: "http://127.0.0.1:31009", headers: {} }, mockOpenApiSpec);
const operation = mockOpenApiSpec.paths["/test"]?.post as OpenAPIV3.OperationObject & {
method: string;
path: string;
};

const err = await client.executeOperation(operation, {}).catch((e) => e);
expect(err).toBeInstanceOf(HttpClientConnectionError);
expect(err.message).toMatch(/127\.0\.0\.1/);
});

it("does NOT throw HttpClientConnectionError when the server returns an HTTP error status", async () => {
// An HTTP 503 has a response object — must become HttpClientError, not connection error.
const httpErr = makeAxiosError(503, "Service Unavailable", { message: "down" });
const mockAxiosInstance = { testOperation: vi.fn().mockRejectedValue(httpErr) };
const MockClient = vi.fn(function (this: any) {
this.init = vi.fn().mockResolvedValue(mockAxiosInstance);
return this;
});
vi.mocked(OpenAPIClientAxios).mockImplementation(MockClient as any);

const client = new HttpClient(mockConfig, mockOpenApiSpec);
const operation = mockOpenApiSpec.paths["/test"]?.post as OpenAPIV3.OperationObject & {
method: string;
path: string;
};

const err = await client.executeOperation(operation, {}).catch((e) => e);
expect(err).not.toBeInstanceOf(HttpClientConnectionError);
expect(err.status).toBe(503);
});
});
});
21 changes: 21 additions & 0 deletions src/client/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ export class HttpClientError extends Error {
}
}

/** Thrown when the upstream API is unreachable (ECONNREFUSED, ENOTFOUND, ETIMEDOUT, etc.). */
export class HttpClientConnectionError extends Error {
constructor(
message: string,
public readonly baseUrl: string,
public readonly cause?: unknown,
) {
super(message);
this.name = "HttpClientConnectionError";
}
}

export class HttpClient {
private api: Promise<AxiosInstance>;
private client: OpenAPIClientAxios;
Expand Down Expand Up @@ -213,6 +225,15 @@ export class HttpClient {
headers,
);
}
if (axios.isAxiosError(error) && !error.response) {
// No response — transport-level failure (ECONNREFUSED, ENOTFOUND, ETIMEDOUT, …)
const baseUrl = (this.client as any).axiosConfigDefaults?.baseURL ?? "unknown";
throw new HttpClientConnectionError(
`Cannot connect to Anytype API at ${baseUrl}: ${error.message}`,
baseUrl,
error,
);
}
throw error;
}
}
Expand Down
33 changes: 25 additions & 8 deletions src/init-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import path from "node:path";
import { OpenAPIV3 } from "openapi-types";
import { startHttpTransport } from "./mcp/http-transport";
import { MCPProxy } from "./mcp/proxy";
import { resolveSpecPath } from "./utils/base-url";
import { ensureAnytypeRunning } from "./utils/anytype-launcher";
import { DEFAULT_BASE_URL, resolveSpecPath } from "./utils/base-url";
import { getConfig } from "./utils/config";

export class ValidationError extends Error {
Expand All @@ -20,31 +21,47 @@ export async function loadOpenApiSpec(): Promise<OpenAPIV3.Document> {
let rawSpec: string | undefined;

if (finalSpec.startsWith("http://") || finalSpec.startsWith("https://")) {
try {
const fetchSpec = async () => {
const response = await axios.get(finalSpec);
rawSpec = typeof response.data === "string" ? response.data : JSON.stringify(response.data);
return typeof response.data === "string" ? response.data : JSON.stringify(response.data);
};

try {
rawSpec = await fetchSpec();
} catch (error: any) {
if (error.code === "ECONNREFUSED") {
console.error("Can't connect to API. Please ensure Anytype is running and reachable.");
const baseUrl = getConfig().httpClient.baseUrl ?? DEFAULT_BASE_URL;
const launched = await ensureAnytypeRunning(baseUrl);
if (launched) {
try {
rawSpec = await fetchSpec();
} catch (retryError: any) {
console.error(`Anytype started but API is still unreachable at ${baseUrl}:`, retryError.message);
process.exit(1);
}
} else {
console.error(`Cannot connect to Anytype API at ${baseUrl}. Please ensure the Anytype app is running.`);
process.exit(1);
}
} else {
console.error(`Failed to fetch OpenAPI specification from ${finalSpec}:`, error.message);
process.exit(1);
}
console.error("Failed to fetch OpenAPI specification from URL:", error.message);
process.exit(1);
}
} else {
const filePath = path.resolve(process.cwd(), finalSpec);
try {
rawSpec = fs.readFileSync(filePath, "utf-8");
} catch (error: any) {
console.error("Failed to read OpenAPI specification file:", error.message || String(error));
console.error(`Failed to read OpenAPI specification file from ${finalSpec}:`, error.message || String(error));
process.exit(1);
}
}

try {
return JSON.parse(rawSpec) as OpenAPIV3.Document;
} catch (error: any) {
console.error("Failed to parse OpenAPI specification:", error.message);
console.error(`Failed to parse OpenAPI specification from ${finalSpec}:`, error.message);
process.exit(1);
}
}
Expand Down
Loading