@withpica/mcp-server-directory 1.0.0 → 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/CHANGELOG.md +56 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +9 -12
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/public-question-atlas.d.ts +121 -0
- package/dist/prompts/public-question-atlas.d.ts.map +1 -0
- package/dist/prompts/public-question-atlas.js +404 -0
- package/dist/prompts/public-question-atlas.js.map +1 -0
- package/dist/resources/llms-primer.d.ts.map +1 -1
- package/dist/resources/llms-primer.js +1 -0
- package/dist/resources/llms-primer.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/chain.d.ts +12 -0
- package/dist/tools/chain.d.ts.map +1 -0
- package/dist/tools/chain.js +109 -0
- package/dist/tools/chain.js.map +1 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/people.d.ts +0 -1
- package/dist/tools/people.d.ts.map +1 -1
- package/dist/tools/people.js +24 -36
- package/dist/tools/people.js.map +1 -1
- package/dist/tools/recordings.d.ts.map +1 -1
- package/dist/tools/recordings.js +8 -3
- package/dist/tools/recordings.js.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +8 -4
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/works.d.ts +0 -1
- package/dist/tools/works.d.ts.map +1 -1
- package/dist/tools/works.js +42 -42
- package/dist/tools/works.js.map +1 -1
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +1 -0
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/formatting.d.ts.map +1 -1
- package/dist/utils/formatting.js +1 -0
- package/dist/utils/formatting.js.map +1 -1
- package/jest.config.js +31 -0
- package/package.json +3 -2
- package/src/__tests__/prompts/index.test.ts +128 -0
- package/src/__tests__/prompts/prompt-eval-harness.test.ts +282 -0
- package/src/__tests__/tools/chain.test.ts +122 -0
- package/src/__tests__/tools/composability-chains.test.ts +100 -0
- package/src/__tests__/tools/people.test.ts +112 -0
- package/src/__tests__/tools/search.test.ts +94 -0
- package/src/__tests__/tools/works.test.ts +177 -0
- package/src/client.ts +128 -0
- package/src/config.ts +23 -0
- package/src/index.ts +36 -0
- package/src/prompts/index.ts +206 -0
- package/src/prompts/public-question-atlas.ts +540 -0
- package/src/resources/llms-primer.ts +35 -0
- package/src/server.ts +134 -0
- package/src/tools/chain.ts +118 -0
- package/src/tools/index.ts +83 -0
- package/src/tools/people.ts +196 -0
- package/src/tools/recordings.ts +149 -0
- package/src/tools/search.ts +66 -0
- package/src/tools/works.ts +266 -0
- package/src/utils/errors.ts +64 -0
- package/src/utils/formatting.ts +28 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
jest,
|
|
5
|
+
describe,
|
|
6
|
+
it,
|
|
7
|
+
expect,
|
|
8
|
+
beforeEach,
|
|
9
|
+
afterEach,
|
|
10
|
+
} from "@jest/globals";
|
|
11
|
+
import { DirectoryPeopleTools } from "../../tools/people";
|
|
12
|
+
import { DirectoryClient } from "../../client";
|
|
13
|
+
|
|
14
|
+
describe("DirectoryPeopleTools", () => {
|
|
15
|
+
let peopleTools: DirectoryPeopleTools;
|
|
16
|
+
let mockClient: jest.Mocked<DirectoryClient>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mockClient = { request: jest.fn() } as any;
|
|
20
|
+
peopleTools = new DirectoryPeopleTools(mockClient);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("registers 2 tools", () => {
|
|
28
|
+
const tools = peopleTools.getTools();
|
|
29
|
+
expect(tools).toHaveLength(2);
|
|
30
|
+
const names = tools.map((t) => t.definition.name);
|
|
31
|
+
expect(names).toContain("directory_list_people");
|
|
32
|
+
expect(names).toContain("directory_lookup_person");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("exposes the letter filter on list_people", () => {
|
|
36
|
+
const tool = peopleTools
|
|
37
|
+
.getTools()
|
|
38
|
+
.find((t) => t.definition.name === "directory_list_people")!;
|
|
39
|
+
expect(tool.definition.inputSchema.properties.letter).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("directory_list_people", () => {
|
|
43
|
+
it("calls /people with query params", async () => {
|
|
44
|
+
mockClient.request.mockResolvedValue({
|
|
45
|
+
success: true,
|
|
46
|
+
data: [{ global_creator_id: "gc1", name: "John Doe" }],
|
|
47
|
+
pagination: { limit: 20, offset: 0, total: 1 },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const tool = peopleTools
|
|
51
|
+
.getTools()
|
|
52
|
+
.find((t) => t.definition.name === "directory_list_people")!;
|
|
53
|
+
await tool.executor({ q: "john", isni: "0000000123456789" });
|
|
54
|
+
|
|
55
|
+
expect(mockClient.request).toHaveBeenCalledWith("/people", {
|
|
56
|
+
q: "john",
|
|
57
|
+
isni: "0000000123456789",
|
|
58
|
+
limit: "20",
|
|
59
|
+
offset: "0",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("calculates offset from page number", async () => {
|
|
64
|
+
mockClient.request.mockResolvedValue({
|
|
65
|
+
success: true,
|
|
66
|
+
data: [],
|
|
67
|
+
pagination: { limit: 20, offset: 20, total: 50 },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const tool = peopleTools
|
|
71
|
+
.getTools()
|
|
72
|
+
.find((t) => t.definition.name === "directory_list_people")!;
|
|
73
|
+
await tool.executor({ page: 2 });
|
|
74
|
+
|
|
75
|
+
expect(mockClient.request).toHaveBeenCalledWith("/people", {
|
|
76
|
+
limit: "20",
|
|
77
|
+
offset: "20",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("error handling", () => {
|
|
83
|
+
it("throws on API failure", async () => {
|
|
84
|
+
mockClient.request.mockRejectedValue(new Error("Network error"));
|
|
85
|
+
const tool = peopleTools
|
|
86
|
+
.getTools()
|
|
87
|
+
.find((t) => t.definition.name === "directory_list_people")!;
|
|
88
|
+
await expect(tool.executor({})).rejects.toThrow("Network error");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("directory_lookup_person", () => {
|
|
93
|
+
it("calls /people/{id}", async () => {
|
|
94
|
+
mockClient.request.mockResolvedValue({
|
|
95
|
+
success: true,
|
|
96
|
+
data: {
|
|
97
|
+
global_creator_id: "gc1",
|
|
98
|
+
name: "John Doe",
|
|
99
|
+
roles: ["Writer", "Producer"],
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const tool = peopleTools
|
|
104
|
+
.getTools()
|
|
105
|
+
.find((t) => t.definition.name === "directory_lookup_person")!;
|
|
106
|
+
const result = await tool.executor({ id: "gc1" });
|
|
107
|
+
|
|
108
|
+
expect(mockClient.request).toHaveBeenCalledWith("/people/gc1");
|
|
109
|
+
expect(result.content).toBeDefined();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
jest,
|
|
5
|
+
describe,
|
|
6
|
+
it,
|
|
7
|
+
expect,
|
|
8
|
+
beforeEach,
|
|
9
|
+
afterEach,
|
|
10
|
+
} from "@jest/globals";
|
|
11
|
+
import { DirectorySearchTools } from "../../tools/search";
|
|
12
|
+
import { DirectoryClient } from "../../client";
|
|
13
|
+
|
|
14
|
+
describe("DirectorySearchTools", () => {
|
|
15
|
+
let searchTools: DirectorySearchTools;
|
|
16
|
+
let mockClient: jest.Mocked<DirectoryClient>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mockClient = { request: jest.fn() } as any;
|
|
20
|
+
searchTools = new DirectorySearchTools(mockClient);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("registers 1 tool", () => {
|
|
28
|
+
const tools = searchTools.getTools();
|
|
29
|
+
expect(tools).toHaveLength(1);
|
|
30
|
+
expect(tools[0].definition.name).toBe("directory_search");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("directory_search", () => {
|
|
34
|
+
it("calls /search with query", async () => {
|
|
35
|
+
mockClient.request.mockResolvedValue({
|
|
36
|
+
success: true,
|
|
37
|
+
data: [
|
|
38
|
+
{ type: "work", id: "w1", title: "Test Song" },
|
|
39
|
+
{ type: "person", id: "p1", name: "Test Artist" },
|
|
40
|
+
],
|
|
41
|
+
pagination: { limit: 20, offset: 0, total: 2 },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const tool = searchTools
|
|
45
|
+
.getTools()
|
|
46
|
+
.find((t) => t.definition.name === "directory_search")!;
|
|
47
|
+
const result = await tool.executor({ query: "test" });
|
|
48
|
+
|
|
49
|
+
expect(mockClient.request).toHaveBeenCalledWith("/search", {
|
|
50
|
+
q: "test",
|
|
51
|
+
limit: "20",
|
|
52
|
+
offset: "0",
|
|
53
|
+
});
|
|
54
|
+
expect(result.content).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("passes type filter", async () => {
|
|
58
|
+
mockClient.request.mockResolvedValue({
|
|
59
|
+
success: true,
|
|
60
|
+
data: [],
|
|
61
|
+
pagination: { limit: 20, offset: 0, total: 0 },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const tool = searchTools
|
|
65
|
+
.getTools()
|
|
66
|
+
.find((t) => t.definition.name === "directory_search")!;
|
|
67
|
+
await tool.executor({ query: "test", type: "works" });
|
|
68
|
+
|
|
69
|
+
expect(mockClient.request).toHaveBeenCalledWith("/search", {
|
|
70
|
+
q: "test",
|
|
71
|
+
type: "works",
|
|
72
|
+
limit: "20",
|
|
73
|
+
offset: "0",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("throws on API failure", async () => {
|
|
78
|
+
mockClient.request.mockRejectedValue(new Error("Network error"));
|
|
79
|
+
const tool = searchTools
|
|
80
|
+
.getTools()
|
|
81
|
+
.find((t) => t.definition.name === "directory_search")!;
|
|
82
|
+
await expect(tool.executor({ query: "test" })).rejects.toThrow(
|
|
83
|
+
"Network error",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("requires query parameter", () => {
|
|
88
|
+
const tool = searchTools
|
|
89
|
+
.getTools()
|
|
90
|
+
.find((t) => t.definition.name === "directory_search")!;
|
|
91
|
+
expect(tool.definition.inputSchema.required).toContain("query");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
jest,
|
|
5
|
+
describe,
|
|
6
|
+
it,
|
|
7
|
+
expect,
|
|
8
|
+
beforeEach,
|
|
9
|
+
afterEach,
|
|
10
|
+
} from "@jest/globals";
|
|
11
|
+
import { DirectoryWorksTools } from "../../tools/works";
|
|
12
|
+
import { DirectoryClient } from "../../client";
|
|
13
|
+
|
|
14
|
+
describe("DirectoryWorksTools", () => {
|
|
15
|
+
let worksTools: DirectoryWorksTools;
|
|
16
|
+
let mockClient: jest.Mocked<DirectoryClient>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mockClient = {
|
|
20
|
+
request: jest.fn(),
|
|
21
|
+
} as any;
|
|
22
|
+
|
|
23
|
+
worksTools = new DirectoryWorksTools(mockClient);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
jest.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("registers 3 tools", () => {
|
|
31
|
+
const tools = worksTools.getTools();
|
|
32
|
+
expect(tools).toHaveLength(3);
|
|
33
|
+
const names = tools.map((t) => t.definition.name);
|
|
34
|
+
expect(names).toContain("directory_list_works");
|
|
35
|
+
expect(names).toContain("directory_lookup_work");
|
|
36
|
+
expect(names).toContain("directory_lookup_isrc");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("exposes letter, sort, and isrc filters on list_works", () => {
|
|
40
|
+
const tool = worksTools
|
|
41
|
+
.getTools()
|
|
42
|
+
.find((t) => t.definition.name === "directory_list_works")!;
|
|
43
|
+
expect(tool.definition.inputSchema.properties.letter).toBeDefined();
|
|
44
|
+
expect(tool.definition.inputSchema.properties.sort).toBeDefined();
|
|
45
|
+
expect(tool.definition.inputSchema.properties.isrc).toBeDefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("list_works forwards letter, sort, and isrc to /works", async () => {
|
|
49
|
+
mockClient.request.mockResolvedValue({
|
|
50
|
+
success: true,
|
|
51
|
+
data: [],
|
|
52
|
+
pagination: { limit: 20, offset: 0, total: 0 },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const tool = worksTools
|
|
56
|
+
.getTools()
|
|
57
|
+
.find((t) => t.definition.name === "directory_list_works")!;
|
|
58
|
+
await tool.executor({ letter: "B", sort: "recent", isrc: "USABC1234567" });
|
|
59
|
+
|
|
60
|
+
expect(mockClient.request).toHaveBeenCalledWith("/works", {
|
|
61
|
+
letter: "B",
|
|
62
|
+
sort: "recent",
|
|
63
|
+
isrc: "USABC1234567",
|
|
64
|
+
limit: "20",
|
|
65
|
+
offset: "0",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("directory_list_works", () => {
|
|
70
|
+
it("calls /works with query params", async () => {
|
|
71
|
+
mockClient.request.mockResolvedValue({
|
|
72
|
+
success: true,
|
|
73
|
+
data: [{ id: "w1", title: "Test Song" }],
|
|
74
|
+
pagination: { limit: 20, offset: 0, total: 1 },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const tool = worksTools
|
|
78
|
+
.getTools()
|
|
79
|
+
.find((t) => t.definition.name === "directory_list_works")!;
|
|
80
|
+
const result = await tool.executor({ q: "test", page: 1 });
|
|
81
|
+
|
|
82
|
+
expect(mockClient.request).toHaveBeenCalledWith("/works", {
|
|
83
|
+
q: "test",
|
|
84
|
+
limit: "20",
|
|
85
|
+
offset: "0",
|
|
86
|
+
});
|
|
87
|
+
expect(result.content).toBeDefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("passes publisher and label filters", async () => {
|
|
91
|
+
mockClient.request.mockResolvedValue({
|
|
92
|
+
success: true,
|
|
93
|
+
data: [],
|
|
94
|
+
pagination: { limit: 20, offset: 0, total: 0 },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const tool = worksTools
|
|
98
|
+
.getTools()
|
|
99
|
+
.find((t) => t.definition.name === "directory_list_works")!;
|
|
100
|
+
await tool.executor({ publisher: "Sony", label: "Columbia" });
|
|
101
|
+
|
|
102
|
+
expect(mockClient.request).toHaveBeenCalledWith("/works", {
|
|
103
|
+
publisher: "Sony",
|
|
104
|
+
label: "Columbia",
|
|
105
|
+
limit: "20",
|
|
106
|
+
offset: "0",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("calculates offset from page number", async () => {
|
|
111
|
+
mockClient.request.mockResolvedValue({
|
|
112
|
+
success: true,
|
|
113
|
+
data: [],
|
|
114
|
+
pagination: { limit: 20, offset: 40, total: 100 },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const tool = worksTools
|
|
118
|
+
.getTools()
|
|
119
|
+
.find((t) => t.definition.name === "directory_list_works")!;
|
|
120
|
+
await tool.executor({ page: 3 });
|
|
121
|
+
|
|
122
|
+
expect(mockClient.request).toHaveBeenCalledWith("/works", {
|
|
123
|
+
limit: "20",
|
|
124
|
+
offset: "40",
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("directory_lookup_work", () => {
|
|
130
|
+
it("calls /works/{identifier}", async () => {
|
|
131
|
+
mockClient.request.mockResolvedValue({
|
|
132
|
+
success: true,
|
|
133
|
+
data: { id: "w1", title: "Test Song", iswc: "T-123.456.789-0" },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const tool = worksTools
|
|
137
|
+
.getTools()
|
|
138
|
+
.find((t) => t.definition.name === "directory_lookup_work")!;
|
|
139
|
+
const result = await tool.executor({ identifier: "T-123.456.789-0" });
|
|
140
|
+
|
|
141
|
+
expect(mockClient.request).toHaveBeenCalledWith("/works/T-123.456.789-0");
|
|
142
|
+
expect(result.content).toBeDefined();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("error handling", () => {
|
|
147
|
+
it("throws on API failure", async () => {
|
|
148
|
+
mockClient.request.mockRejectedValue(new Error("Network error"));
|
|
149
|
+
const tool = worksTools
|
|
150
|
+
.getTools()
|
|
151
|
+
.find((t) => t.definition.name === "directory_list_works")!;
|
|
152
|
+
await expect(tool.executor({})).rejects.toThrow("Network error");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("directory_lookup_isrc", () => {
|
|
157
|
+
it("calls /works with isrc param", async () => {
|
|
158
|
+
mockClient.request.mockResolvedValue({
|
|
159
|
+
success: true,
|
|
160
|
+
data: [{ id: "w1", title: "Test Song" }],
|
|
161
|
+
pagination: { limit: 20, offset: 0, total: 1 },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const tool = worksTools
|
|
165
|
+
.getTools()
|
|
166
|
+
.find((t) => t.definition.name === "directory_lookup_isrc")!;
|
|
167
|
+
const result = await tool.executor({ isrc: "USABC1234567" });
|
|
168
|
+
|
|
169
|
+
expect(mockClient.request).toHaveBeenCalledWith("/works", {
|
|
170
|
+
isrc: "USABC1234567",
|
|
171
|
+
limit: "100",
|
|
172
|
+
offset: "0",
|
|
173
|
+
});
|
|
174
|
+
expect(result.content).toBeDefined();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
4
|
+
const MAX_RETRIES = 2;
|
|
5
|
+
const RETRY_BASE_MS = 500;
|
|
6
|
+
|
|
7
|
+
function isRetryableStatus(status: number): boolean {
|
|
8
|
+
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sleep(ms: number): Promise<void> {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DirectoryClientConfig {
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
debug?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class DirectoryClient {
|
|
21
|
+
private baseUrl: string;
|
|
22
|
+
private debug: boolean;
|
|
23
|
+
|
|
24
|
+
constructor(config: DirectoryClientConfig) {
|
|
25
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
26
|
+
this.debug = config.debug || false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private async fetchWithTimeout(
|
|
30
|
+
url: string,
|
|
31
|
+
init: RequestInit,
|
|
32
|
+
timeoutMs: number,
|
|
33
|
+
): Promise<Response> {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
36
|
+
try {
|
|
37
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
38
|
+
} catch (err: any) {
|
|
39
|
+
if (err.name === "AbortError") {
|
|
40
|
+
throw new Error(`Request timed out after ${timeoutMs}ms: GET ${url}`);
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
|
49
|
+
const url = new URL(`${this.baseUrl}/api/public/directory${path}`);
|
|
50
|
+
if (params) {
|
|
51
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
52
|
+
if (value !== undefined && value !== "") {
|
|
53
|
+
url.searchParams.set(key, value);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (this.debug) {
|
|
59
|
+
console.error(
|
|
60
|
+
`[Directory MCP] GET ${url.toString()} (timeout: ${DEFAULT_TIMEOUT_MS}ms)`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let lastError: Error | undefined;
|
|
65
|
+
|
|
66
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
const response = await this.fetchWithTimeout(
|
|
69
|
+
url.toString(),
|
|
70
|
+
{ method: "GET", headers: { Accept: "application/json" } },
|
|
71
|
+
DEFAULT_TIMEOUT_MS,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (response.ok) {
|
|
75
|
+
const json: any = await response.json();
|
|
76
|
+
if (json.success === false) {
|
|
77
|
+
throw new Error(json.error || "API returned unsuccessful response");
|
|
78
|
+
}
|
|
79
|
+
return json as T;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const errorText = await response.text();
|
|
83
|
+
|
|
84
|
+
// Auth errors — fail fast, never retry
|
|
85
|
+
if (response.status === 401 || response.status === 403) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Authentication failed: ${response.status} ${errorText}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isRetryableStatus(response.status) && attempt < MAX_RETRIES) {
|
|
92
|
+
const retryAfter = response.headers.get("retry-after");
|
|
93
|
+
const parsed = retryAfter ? parseInt(retryAfter, 10) : NaN;
|
|
94
|
+
const delayMs = !isNaN(parsed)
|
|
95
|
+
? parsed * 1000
|
|
96
|
+
: RETRY_BASE_MS * Math.pow(2, attempt);
|
|
97
|
+
if (this.debug) {
|
|
98
|
+
console.error(
|
|
99
|
+
`[Directory MCP] ${response.status} on attempt ${attempt + 1}, retrying in ${delayMs}ms`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
await sleep(delayMs);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
throw new Error(`API request failed: ${response.status} ${errorText}`);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
lastError = err as Error;
|
|
109
|
+
if (
|
|
110
|
+
(err as Error).message?.includes("timed out") &&
|
|
111
|
+
attempt < MAX_RETRIES
|
|
112
|
+
) {
|
|
113
|
+
const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
|
|
114
|
+
if (this.debug) {
|
|
115
|
+
console.error(
|
|
116
|
+
`[Directory MCP] timeout on attempt ${attempt + 1}, retrying in ${delayMs}ms`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
await sleep(delayMs);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
throw lastError || new Error("Request failed after retries");
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
export interface ServerConfig {
|
|
4
|
+
directoryUrl: string;
|
|
5
|
+
serverName: string;
|
|
6
|
+
version: string;
|
|
7
|
+
debug: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadConfig(): ServerConfig {
|
|
11
|
+
return {
|
|
12
|
+
directoryUrl: process.env.PICA_DIRECTORY_URL || "https://withpica.com",
|
|
13
|
+
serverName: "pica-directory-mcp-server",
|
|
14
|
+
version: "1.0.0",
|
|
15
|
+
debug: process.env.DEBUG === "true" || process.env.DEBUG === "1",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateConfig(config: ServerConfig): void {
|
|
20
|
+
if (!config.directoryUrl || !config.directoryUrl.startsWith("http")) {
|
|
21
|
+
throw new Error("Invalid PICA_DIRECTORY_URL");
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) 2024-2026 Withpica Ltd. All rights reserved.
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import { loadConfig, validateConfig } from "./config.js";
|
|
6
|
+
import { DirectoryMcpServer } from "./server.js";
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
try {
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
validateConfig(config);
|
|
12
|
+
|
|
13
|
+
const server = new DirectoryMcpServer(config);
|
|
14
|
+
await server.start();
|
|
15
|
+
|
|
16
|
+
process.on("SIGINT", async () => {
|
|
17
|
+
console.error("[Directory MCP] Shutting down...");
|
|
18
|
+
await server.stop();
|
|
19
|
+
process.exit(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
process.on("SIGTERM", async () => {
|
|
23
|
+
console.error("[Directory MCP] Shutting down...");
|
|
24
|
+
await server.stop();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error(
|
|
29
|
+
"[Directory MCP] Fatal error:",
|
|
30
|
+
error instanceof Error ? error.message : String(error),
|
|
31
|
+
);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
main();
|