@anyproto/anytype-mcp 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +36 -0
- package/Dockerfile-multi-stage +21 -0
- package/README.md +33 -0
- package/bin/cli.mjs +50 -50
- package/package.json +6 -6
- package/scripts/start-server.ts +2 -1
- package/src/init-server.ts +2 -1
- package/src/mcp/__tests__/proxy.test.ts +96 -147
- package/src/mcp/proxy.ts +2 -1
- package/src/utils/__tests__/base-url.test.ts +136 -0
- package/src/utils/base-url.ts +67 -0
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"mcp",
|
|
8
8
|
"server"
|
|
9
9
|
],
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.2.0",
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"type": "module",
|
|
13
13
|
"scripts": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@modelcontextprotocol/sdk": "1.26.0",
|
|
29
|
-
"axios": "1.13.
|
|
29
|
+
"axios": "1.13.5",
|
|
30
30
|
"form-data": "4.0.5",
|
|
31
31
|
"mustache": "4.2.0",
|
|
32
32
|
"node-fetch": "3.3.2",
|
|
@@ -40,14 +40,14 @@
|
|
|
40
40
|
"@eslint/js": "9.39.2",
|
|
41
41
|
"@types/json-schema": "7.0.15",
|
|
42
42
|
"@types/mustache": "4.2.6",
|
|
43
|
-
"@types/node": "25.2.
|
|
43
|
+
"@types/node": "25.2.3",
|
|
44
44
|
"@types/which": "3.0.4",
|
|
45
45
|
"@vitest/coverage-v8": "4.0.18",
|
|
46
|
-
"@typescript-eslint/eslint-plugin": "8.
|
|
47
|
-
"@typescript-eslint/parser": "8.
|
|
46
|
+
"@typescript-eslint/eslint-plugin": "8.56.0",
|
|
47
|
+
"@typescript-eslint/parser": "8.56.0",
|
|
48
48
|
"eslint": "9.39.2",
|
|
49
49
|
"esbuild": "0.27.3",
|
|
50
|
-
"openai": "6.
|
|
50
|
+
"openai": "6.22.0",
|
|
51
51
|
"prettier": "3.8.1",
|
|
52
52
|
"prettier-plugin-organize-imports": "4.3.0",
|
|
53
53
|
"tsx": "4.21.0",
|
package/scripts/start-server.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { AppKeyGenerator } from "../src/auth/get-key";
|
|
2
2
|
import { initProxy, loadOpenApiSpec, ValidationError } from "../src/init-server";
|
|
3
|
+
import { determineBaseUrl } from "../src/utils/base-url";
|
|
3
4
|
|
|
4
5
|
async function generateAppKey(specPath?: string) {
|
|
5
6
|
const openApiSpec = await loadOpenApiSpec(specPath);
|
|
6
|
-
const baseUrl = openApiSpec
|
|
7
|
+
const baseUrl = determineBaseUrl(openApiSpec);
|
|
7
8
|
const generator = new AppKeyGenerator(baseUrl);
|
|
8
9
|
await generator.generateAppKey();
|
|
9
10
|
}
|
package/src/init-server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { OpenAPIV3 } from "openapi-types";
|
|
6
6
|
import { MCPProxy } from "./mcp/proxy";
|
|
7
|
+
import { getDefaultSpecUrl } from "./utils/base-url";
|
|
7
8
|
|
|
8
9
|
export class ValidationError extends Error {
|
|
9
10
|
constructor(public errors: any[]) {
|
|
@@ -13,7 +14,7 @@ export class ValidationError extends Error {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export async function loadOpenApiSpec(specPath?: string): Promise<OpenAPIV3.Document> {
|
|
16
|
-
const finalSpec = specPath ||
|
|
17
|
+
const finalSpec = specPath || getDefaultSpecUrl();
|
|
17
18
|
let rawSpec: string;
|
|
18
19
|
|
|
19
20
|
if (finalSpec.startsWith("http://") || finalSpec.startsWith("https://")) {
|
|
@@ -13,41 +13,37 @@ describe("MCPProxy", () => {
|
|
|
13
13
|
let proxy: MCPProxy;
|
|
14
14
|
let mockOpenApiSpec: OpenAPIV3.Document;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
responses: {
|
|
33
|
-
"200": {
|
|
34
|
-
description: "Success",
|
|
35
|
-
},
|
|
36
|
-
},
|
|
37
|
-
},
|
|
16
|
+
const getHandlers = (proxy: MCPProxy) => {
|
|
17
|
+
const server = (proxy as any).server;
|
|
18
|
+
return server.setRequestHandler.mock.calls
|
|
19
|
+
.flatMap((x: unknown[]) => x)
|
|
20
|
+
.filter((x: unknown) => typeof x === "function");
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const createMockOpenApiSpec = (overrides?: Partial<OpenAPIV3.Document>): OpenAPIV3.Document => ({
|
|
24
|
+
openapi: "3.0.0",
|
|
25
|
+
servers: [{ url: "http://localhost:3000" }],
|
|
26
|
+
info: { title: "Test API", version: "1.0.0" },
|
|
27
|
+
paths: {
|
|
28
|
+
"/test": {
|
|
29
|
+
get: {
|
|
30
|
+
operationId: "getTest",
|
|
31
|
+
responses: { "200": { description: "Success" } },
|
|
38
32
|
},
|
|
39
33
|
},
|
|
40
|
-
}
|
|
34
|
+
},
|
|
35
|
+
...overrides,
|
|
36
|
+
});
|
|
41
37
|
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
mockOpenApiSpec = createMockOpenApiSpec();
|
|
42
41
|
proxy = new MCPProxy("test-proxy", mockOpenApiSpec);
|
|
43
42
|
});
|
|
44
43
|
|
|
45
44
|
describe("listTools handler", () => {
|
|
46
45
|
it("should return converted tools from OpenAPI spec", async () => {
|
|
47
|
-
const
|
|
48
|
-
const listToolsHandler = server.setRequestHandler.mock.calls[0].filter(
|
|
49
|
-
(x: unknown) => typeof x === "function",
|
|
50
|
-
)[0];
|
|
46
|
+
const [listToolsHandler] = getHandlers(proxy);
|
|
51
47
|
const result = await listToolsHandler();
|
|
52
48
|
|
|
53
49
|
expect(result).toHaveProperty("tools");
|
|
@@ -55,24 +51,18 @@ describe("MCPProxy", () => {
|
|
|
55
51
|
});
|
|
56
52
|
|
|
57
53
|
it("should truncate tool names exceeding 64 characters", async () => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"200": {
|
|
65
|
-
description: "Success",
|
|
66
|
-
},
|
|
54
|
+
const specWithLongName = createMockOpenApiSpec({
|
|
55
|
+
paths: {
|
|
56
|
+
"/test": {
|
|
57
|
+
get: {
|
|
58
|
+
operationId: "a".repeat(65),
|
|
59
|
+
responses: { "200": { description: "Success" } },
|
|
67
60
|
},
|
|
68
61
|
},
|
|
69
62
|
},
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const listToolsHandler = server.setRequestHandler.mock.calls[0].filter(
|
|
74
|
-
(x: unknown) => typeof x === "function",
|
|
75
|
-
)[0];
|
|
63
|
+
});
|
|
64
|
+
const testProxy = new MCPProxy("test-proxy", specWithLongName);
|
|
65
|
+
const [listToolsHandler] = getHandlers(testProxy);
|
|
76
66
|
const result = await listToolsHandler();
|
|
77
67
|
|
|
78
68
|
expect(result.tools[0].name.length).toBeLessThanOrEqual(64);
|
|
@@ -80,18 +70,15 @@ describe("MCPProxy", () => {
|
|
|
80
70
|
});
|
|
81
71
|
|
|
82
72
|
describe("callTool handler", () => {
|
|
73
|
+
const mockSuccessResponse = {
|
|
74
|
+
data: { message: "success" },
|
|
75
|
+
status: 200,
|
|
76
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
77
|
+
};
|
|
78
|
+
|
|
83
79
|
it("should execute operation and return formatted response", async () => {
|
|
84
|
-
|
|
85
|
-
const mockResponse = {
|
|
86
|
-
data: { message: "success" },
|
|
87
|
-
status: 200,
|
|
88
|
-
headers: new Headers({
|
|
89
|
-
"content-type": "application/json",
|
|
90
|
-
}),
|
|
91
|
-
};
|
|
92
|
-
(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
|
|
80
|
+
(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockSuccessResponse);
|
|
93
81
|
|
|
94
|
-
// Set up the openApiLookup with our test operation
|
|
95
82
|
(proxy as any).openApiLookup = {
|
|
96
83
|
"API-getTest": {
|
|
97
84
|
operationId: "getTest",
|
|
@@ -101,58 +88,25 @@ describe("MCPProxy", () => {
|
|
|
101
88
|
},
|
|
102
89
|
};
|
|
103
90
|
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
.flatMap((x: unknown[]) => x)
|
|
107
|
-
.filter((x: unknown) => typeof x === "function");
|
|
108
|
-
const callToolHandler = handlers[1];
|
|
109
|
-
|
|
110
|
-
const result = await callToolHandler({
|
|
111
|
-
params: {
|
|
112
|
-
name: "API-getTest",
|
|
113
|
-
arguments: {},
|
|
114
|
-
},
|
|
115
|
-
});
|
|
91
|
+
const [, callToolHandler] = getHandlers(proxy);
|
|
92
|
+
const result = await callToolHandler({ params: { name: "API-getTest", arguments: {} } });
|
|
116
93
|
|
|
117
94
|
expect(result).toEqual({
|
|
118
|
-
content: [
|
|
119
|
-
{
|
|
120
|
-
type: "text",
|
|
121
|
-
text: JSON.stringify({ message: "success" }),
|
|
122
|
-
},
|
|
123
|
-
],
|
|
95
|
+
content: [{ type: "text", text: JSON.stringify({ message: "success" }) }],
|
|
124
96
|
});
|
|
125
97
|
});
|
|
126
98
|
|
|
127
99
|
it("should throw error for non-existent operation", async () => {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
await expect(
|
|
135
|
-
callToolHandler({
|
|
136
|
-
params: {
|
|
137
|
-
name: "nonExistentMethod",
|
|
138
|
-
arguments: {},
|
|
139
|
-
},
|
|
140
|
-
}),
|
|
141
|
-
).rejects.toThrow("Method nonExistentMethod not found");
|
|
100
|
+
const [, callToolHandler] = getHandlers(proxy);
|
|
101
|
+
|
|
102
|
+
await expect(callToolHandler({ params: { name: "nonExistentMethod", arguments: {} } })).rejects.toThrow(
|
|
103
|
+
"Method nonExistentMethod not found",
|
|
104
|
+
);
|
|
142
105
|
});
|
|
143
106
|
|
|
144
107
|
it("should handle tool names exceeding 64 characters", async () => {
|
|
145
|
-
|
|
146
|
-
const mockResponse = {
|
|
147
|
-
data: { message: "success" },
|
|
148
|
-
status: 200,
|
|
149
|
-
headers: new Headers({
|
|
150
|
-
"content-type": "application/json",
|
|
151
|
-
}),
|
|
152
|
-
};
|
|
153
|
-
(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
|
|
108
|
+
(HttpClient.prototype.executeOperation as ReturnType<typeof vi.fn>).mockResolvedValue(mockSuccessResponse);
|
|
154
109
|
|
|
155
|
-
// Set up the openApiLookup with a long tool name
|
|
156
110
|
const longToolName = "a".repeat(65);
|
|
157
111
|
const truncatedToolName = longToolName.slice(0, 64);
|
|
158
112
|
(proxy as any).openApiLookup = {
|
|
@@ -164,26 +118,11 @@ describe("MCPProxy", () => {
|
|
|
164
118
|
},
|
|
165
119
|
};
|
|
166
120
|
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
.flatMap((x: unknown[]) => x)
|
|
170
|
-
.filter((x: unknown) => typeof x === "function");
|
|
171
|
-
const callToolHandler = handlers[1];
|
|
172
|
-
|
|
173
|
-
const result = await callToolHandler({
|
|
174
|
-
params: {
|
|
175
|
-
name: truncatedToolName,
|
|
176
|
-
arguments: {},
|
|
177
|
-
},
|
|
178
|
-
});
|
|
121
|
+
const [, callToolHandler] = getHandlers(proxy);
|
|
122
|
+
const result = await callToolHandler({ params: { name: truncatedToolName, arguments: {} } });
|
|
179
123
|
|
|
180
124
|
expect(result).toEqual({
|
|
181
|
-
content: [
|
|
182
|
-
{
|
|
183
|
-
type: "text",
|
|
184
|
-
text: JSON.stringify({ message: "success" }),
|
|
185
|
-
},
|
|
186
|
-
],
|
|
125
|
+
content: [{ type: "text", text: JSON.stringify({ message: "success" }) }],
|
|
187
126
|
});
|
|
188
127
|
});
|
|
189
128
|
});
|
|
@@ -202,6 +141,9 @@ describe("MCPProxy", () => {
|
|
|
202
141
|
|
|
203
142
|
describe("parseHeadersFromEnv", () => {
|
|
204
143
|
const originalEnv = process.env;
|
|
144
|
+
const expectHeaders = (headers: Record<string, string>) => {
|
|
145
|
+
expect(HttpClient).toHaveBeenCalledWith(expect.objectContaining({ headers }), expect.anything());
|
|
146
|
+
};
|
|
205
147
|
|
|
206
148
|
beforeEach(() => {
|
|
207
149
|
process.env = { ...originalEnv };
|
|
@@ -216,42 +158,21 @@ describe("MCPProxy", () => {
|
|
|
216
158
|
Authorization: "Bearer token123",
|
|
217
159
|
"X-Custom-Header": "test",
|
|
218
160
|
});
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
expect(HttpClient).toHaveBeenCalledWith(
|
|
222
|
-
expect.objectContaining({
|
|
223
|
-
headers: {
|
|
224
|
-
Authorization: "Bearer token123",
|
|
225
|
-
"X-Custom-Header": "test",
|
|
226
|
-
},
|
|
227
|
-
}),
|
|
228
|
-
expect.anything(),
|
|
229
|
-
);
|
|
161
|
+
new MCPProxy("test-proxy", mockOpenApiSpec);
|
|
162
|
+
expectHeaders({ Authorization: "Bearer token123", "X-Custom-Header": "test" });
|
|
230
163
|
});
|
|
231
164
|
|
|
232
165
|
it("should return empty object when env var is not set", () => {
|
|
233
166
|
delete process.env.OPENAPI_MCP_HEADERS;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
expect(HttpClient).toHaveBeenCalledWith(
|
|
237
|
-
expect.objectContaining({
|
|
238
|
-
headers: {},
|
|
239
|
-
}),
|
|
240
|
-
expect.anything(),
|
|
241
|
-
);
|
|
167
|
+
new MCPProxy("test-proxy", mockOpenApiSpec);
|
|
168
|
+
expectHeaders({});
|
|
242
169
|
});
|
|
243
170
|
|
|
244
171
|
it("should return empty object and warn on invalid JSON", () => {
|
|
245
172
|
const consoleSpy = vi.spyOn(console, "warn");
|
|
246
173
|
process.env.OPENAPI_MCP_HEADERS = "invalid json";
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
expect(HttpClient).toHaveBeenCalledWith(
|
|
250
|
-
expect.objectContaining({
|
|
251
|
-
headers: {},
|
|
252
|
-
}),
|
|
253
|
-
expect.anything(),
|
|
254
|
-
);
|
|
174
|
+
new MCPProxy("test-proxy", mockOpenApiSpec);
|
|
175
|
+
expectHeaders({});
|
|
255
176
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
256
177
|
"Failed to parse OPENAPI_MCP_HEADERS environment variable:",
|
|
257
178
|
expect.any(Error),
|
|
@@ -261,20 +182,48 @@ describe("MCPProxy", () => {
|
|
|
261
182
|
it("should return empty object and warn on non-object JSON", () => {
|
|
262
183
|
const consoleSpy = vi.spyOn(console, "warn");
|
|
263
184
|
process.env.OPENAPI_MCP_HEADERS = '"string"';
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
expect(HttpClient).toHaveBeenCalledWith(
|
|
267
|
-
expect.objectContaining({
|
|
268
|
-
headers: {},
|
|
269
|
-
}),
|
|
270
|
-
expect.anything(),
|
|
271
|
-
);
|
|
185
|
+
new MCPProxy("test-proxy", mockOpenApiSpec);
|
|
186
|
+
expectHeaders({});
|
|
272
187
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
273
188
|
"OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:",
|
|
274
189
|
"string",
|
|
275
190
|
);
|
|
276
191
|
});
|
|
277
192
|
});
|
|
193
|
+
|
|
194
|
+
describe("base URL integration", () => {
|
|
195
|
+
const originalEnv = process.env;
|
|
196
|
+
const expectBaseUrl = (url: string) => {
|
|
197
|
+
expect(HttpClient).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: url }), expect.anything());
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
beforeEach(() => {
|
|
201
|
+
process.env = { ...originalEnv };
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
afterEach(() => {
|
|
205
|
+
process.env = originalEnv;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should use ANYTYPE_API_BASE_URL when set", () => {
|
|
209
|
+
process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012";
|
|
210
|
+
new MCPProxy("test-proxy", mockOpenApiSpec);
|
|
211
|
+
expectBaseUrl("http://localhost:31012");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should use spec servers when env var not set", () => {
|
|
215
|
+
delete process.env.ANYTYPE_API_BASE_URL;
|
|
216
|
+
new MCPProxy("test-proxy", mockOpenApiSpec);
|
|
217
|
+
expectBaseUrl("http://localhost:3000");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("should use default when neither env var nor spec servers available", () => {
|
|
221
|
+
delete process.env.ANYTYPE_API_BASE_URL;
|
|
222
|
+
new MCPProxy("test-proxy", createMockOpenApiSpec({ servers: undefined }));
|
|
223
|
+
expectBaseUrl("http://127.0.0.1:31009");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
278
227
|
describe("connect", () => {
|
|
279
228
|
it("should connect to transport", async () => {
|
|
280
229
|
const mockTransport = {} as Transport;
|
package/src/mcp/proxy.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Headers } from "node-fetch";
|
|
|
6
6
|
import { OpenAPIV3 } from "openapi-types";
|
|
7
7
|
import { HttpClient, HttpClientError } from "../client/http-client";
|
|
8
8
|
import { OpenAPIToMCPConverter } from "../openapi/parser";
|
|
9
|
+
import { determineBaseUrl } from "../utils/base-url";
|
|
9
10
|
|
|
10
11
|
type PathItemObject = OpenAPIV3.PathItemObject & {
|
|
11
12
|
get?: OpenAPIV3.OperationObject;
|
|
@@ -32,7 +33,7 @@ export class MCPProxy {
|
|
|
32
33
|
|
|
33
34
|
constructor(name: string, openApiSpec: OpenAPIV3.Document) {
|
|
34
35
|
this.server = new Server({ name, version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
35
|
-
const baseUrl = openApiSpec
|
|
36
|
+
const baseUrl = determineBaseUrl(openApiSpec);
|
|
36
37
|
this.httpClient = new HttpClient(
|
|
37
38
|
{
|
|
38
39
|
baseUrl,
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { OpenAPIV3 } from "openapi-types";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { determineBaseUrl, getDefaultSpecUrl, parseBaseUrlFromEnv } from "../base-url";
|
|
4
|
+
|
|
5
|
+
describe("base-url utilities", () => {
|
|
6
|
+
const originalEnv = process.env;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
process.env = { ...originalEnv };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
process.env = originalEnv;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("parseBaseUrlFromEnv", () => {
|
|
17
|
+
it("should parse valid HTTP URL from env", () => {
|
|
18
|
+
process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012";
|
|
19
|
+
expect(parseBaseUrlFromEnv()).toBe("http://localhost:31012");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should parse valid HTTPS URL from env", () => {
|
|
23
|
+
process.env.ANYTYPE_API_BASE_URL = "https://api.example.com:8080";
|
|
24
|
+
expect(parseBaseUrlFromEnv()).toBe("https://api.example.com:8080");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should strip path from URL and return origin only", () => {
|
|
28
|
+
process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012/api/v1";
|
|
29
|
+
expect(parseBaseUrlFromEnv()).toBe("http://localhost:31012");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should return null when env var is not set", () => {
|
|
33
|
+
delete process.env.ANYTYPE_API_BASE_URL;
|
|
34
|
+
expect(parseBaseUrlFromEnv()).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should return null and warn on invalid URL", () => {
|
|
38
|
+
const consoleSpy = vi.spyOn(console, "warn");
|
|
39
|
+
process.env.ANYTYPE_API_BASE_URL = "not-a-valid-url";
|
|
40
|
+
|
|
41
|
+
expect(parseBaseUrlFromEnv()).toBeNull();
|
|
42
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
43
|
+
"Failed to parse ANYTYPE_API_BASE_URL environment variable:",
|
|
44
|
+
expect.any(Error),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should return null and warn on unsupported protocol", () => {
|
|
49
|
+
const consoleSpy = vi.spyOn(console, "warn");
|
|
50
|
+
process.env.ANYTYPE_API_BASE_URL = "ftp://localhost:31012";
|
|
51
|
+
|
|
52
|
+
expect(parseBaseUrlFromEnv()).toBeNull();
|
|
53
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
54
|
+
"ANYTYPE_API_BASE_URL must use http:// or https:// protocol, got: ftp:. Ignoring and using fallback.",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("determineBaseUrl", () => {
|
|
60
|
+
const mockOpenApiSpec: OpenAPIV3.Document = {
|
|
61
|
+
openapi: "3.0.0",
|
|
62
|
+
servers: [{ url: "http://localhost:3000" }],
|
|
63
|
+
info: {
|
|
64
|
+
title: "Test API",
|
|
65
|
+
version: "1.0.0",
|
|
66
|
+
},
|
|
67
|
+
paths: {},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
it("should prioritize ANYTYPE_API_BASE_URL over spec servers", () => {
|
|
71
|
+
const consoleSpy = vi.spyOn(console, "error");
|
|
72
|
+
process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012";
|
|
73
|
+
|
|
74
|
+
expect(determineBaseUrl(mockOpenApiSpec)).toBe("http://localhost:31012");
|
|
75
|
+
expect(consoleSpy).toHaveBeenCalledWith("Using base URL from ANYTYPE_API_BASE_URL: http://localhost:31012");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should use spec servers[0].url when env var is not set", () => {
|
|
79
|
+
const consoleSpy = vi.spyOn(console, "error");
|
|
80
|
+
delete process.env.ANYTYPE_API_BASE_URL;
|
|
81
|
+
|
|
82
|
+
expect(determineBaseUrl(mockOpenApiSpec)).toBe("http://localhost:3000");
|
|
83
|
+
expect(consoleSpy).toHaveBeenCalledWith("Using base URL from OpenAPI spec: http://localhost:3000");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should use default fallback when neither env var nor spec servers are available", () => {
|
|
87
|
+
const consoleSpy = vi.spyOn(console, "error");
|
|
88
|
+
delete process.env.ANYTYPE_API_BASE_URL;
|
|
89
|
+
const specWithoutServers = {
|
|
90
|
+
...mockOpenApiSpec,
|
|
91
|
+
servers: undefined,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
expect(determineBaseUrl(specWithoutServers)).toBe("http://127.0.0.1:31009");
|
|
95
|
+
expect(consoleSpy).toHaveBeenCalledWith("Using default base URL: http://127.0.0.1:31009");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should use default fallback when spec is not provided", () => {
|
|
99
|
+
const consoleSpy = vi.spyOn(console, "error");
|
|
100
|
+
delete process.env.ANYTYPE_API_BASE_URL;
|
|
101
|
+
|
|
102
|
+
expect(determineBaseUrl()).toBe("http://127.0.0.1:31009");
|
|
103
|
+
expect(consoleSpy).toHaveBeenCalledWith("Using default base URL: http://127.0.0.1:31009");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should fallback to spec servers when env var is invalid", () => {
|
|
107
|
+
const consoleSpy = vi.spyOn(console, "error");
|
|
108
|
+
process.env.ANYTYPE_API_BASE_URL = "invalid-url";
|
|
109
|
+
|
|
110
|
+
expect(determineBaseUrl(mockOpenApiSpec)).toBe("http://localhost:3000");
|
|
111
|
+
expect(consoleSpy).toHaveBeenCalledWith("Using base URL from OpenAPI spec: http://localhost:3000");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("getDefaultSpecUrl", () => {
|
|
116
|
+
it("should use ANYTYPE_API_BASE_URL with /docs/openapi.json suffix when set", () => {
|
|
117
|
+
process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012";
|
|
118
|
+
expect(getDefaultSpecUrl()).toBe("http://localhost:31012/docs/openapi.json");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should strip path from endpoint before adding suffix", () => {
|
|
122
|
+
process.env.ANYTYPE_API_BASE_URL = "http://localhost:31012/some/path";
|
|
123
|
+
expect(getDefaultSpecUrl()).toBe("http://localhost:31012/docs/openapi.json");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should return default URL when env var is not set", () => {
|
|
127
|
+
delete process.env.ANYTYPE_API_BASE_URL;
|
|
128
|
+
expect(getDefaultSpecUrl()).toBe("http://127.0.0.1:31009/docs/openapi.json");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should return default URL when env var is invalid", () => {
|
|
132
|
+
process.env.ANYTYPE_API_BASE_URL = "invalid-url";
|
|
133
|
+
expect(getDefaultSpecUrl()).toBe("http://127.0.0.1:31009/docs/openapi.json");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { URL } from "node:url";
|
|
2
|
+
import { OpenAPIV3 } from "openapi-types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parses the ANYTYPE_API_BASE_URL environment variable and returns the origin.
|
|
6
|
+
* Returns null if not set, invalid, or uses an unsupported protocol.
|
|
7
|
+
*/
|
|
8
|
+
export function parseBaseUrlFromEnv(): string | null {
|
|
9
|
+
const endpoint = process.env.ANYTYPE_API_BASE_URL;
|
|
10
|
+
if (!endpoint) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(endpoint);
|
|
16
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
17
|
+
console.warn(
|
|
18
|
+
`ANYTYPE_API_BASE_URL must use http:// or https:// protocol, got: ${url.protocol}. Ignoring and using fallback.`,
|
|
19
|
+
);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return url.origin;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.warn("Failed to parse ANYTYPE_API_BASE_URL environment variable:", error);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Determines the base URL using priority order:
|
|
31
|
+
* 1. ANYTYPE_API_BASE_URL environment variable
|
|
32
|
+
* 2. OpenAPI spec servers[0].url
|
|
33
|
+
* 3. Default fallback: http://127.0.0.1:31009
|
|
34
|
+
*/
|
|
35
|
+
export function determineBaseUrl(openApiSpec?: OpenAPIV3.Document): string {
|
|
36
|
+
// Priority 1: Environment variable
|
|
37
|
+
const envEndpoint = parseBaseUrlFromEnv();
|
|
38
|
+
if (envEndpoint) {
|
|
39
|
+
console.error(`Using base URL from ANYTYPE_API_BASE_URL: ${envEndpoint}`);
|
|
40
|
+
return envEndpoint;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Priority 2: OpenAPI spec servers[0].url
|
|
44
|
+
const specUrl = openApiSpec?.servers?.[0]?.url;
|
|
45
|
+
if (specUrl) {
|
|
46
|
+
console.error(`Using base URL from OpenAPI spec: ${specUrl}`);
|
|
47
|
+
return specUrl;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Priority 3: Default fallback
|
|
51
|
+
const defaultUrl = "http://127.0.0.1:31009";
|
|
52
|
+
console.error(`Using default base URL: ${defaultUrl}`);
|
|
53
|
+
return defaultUrl;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Gets the default OpenAPI spec URL.
|
|
58
|
+
* If ANYTYPE_API_BASE_URL is set, uses it with /docs/openapi.json suffix.
|
|
59
|
+
* Otherwise, returns the default spec URL.
|
|
60
|
+
*/
|
|
61
|
+
export function getDefaultSpecUrl(): string {
|
|
62
|
+
const endpoint = parseBaseUrlFromEnv();
|
|
63
|
+
if (endpoint) {
|
|
64
|
+
return `${endpoint}/docs/openapi.json`;
|
|
65
|
+
}
|
|
66
|
+
return "http://127.0.0.1:31009/docs/openapi.json";
|
|
67
|
+
}
|