@esaio/esa-mcp-server 0.1.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.
Files changed (80) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/.dockerignore +36 -0
  3. package/.envrc +2 -0
  4. package/.github/dependabot.yml +18 -0
  5. package/.github/workflows/docker-publish.yml +120 -0
  6. package/.github/workflows/main.yml +41 -0
  7. package/.node-version +1 -0
  8. package/CLAUDE.md +94 -0
  9. package/Dockerfile +34 -0
  10. package/LICENSE +7 -0
  11. package/README.en.md +139 -0
  12. package/README.md +139 -0
  13. package/bin/index.js +30 -0
  14. package/biome.json +57 -0
  15. package/package.json +48 -0
  16. package/src/__tests__/fixtures/mock-comment.ts +90 -0
  17. package/src/__tests__/fixtures/mock-post.ts +79 -0
  18. package/src/__tests__/index.test.ts +209 -0
  19. package/src/api_client/__tests__/index.test.ts +149 -0
  20. package/src/api_client/__tests__/middleware.test.ts +119 -0
  21. package/src/api_client/__tests__/with-context.test.ts +98 -0
  22. package/src/api_client/index.ts +29 -0
  23. package/src/api_client/middleware.ts +21 -0
  24. package/src/api_client/with-context.ts +26 -0
  25. package/src/config/__tests__/index.test.ts +65 -0
  26. package/src/config/index.ts +20 -0
  27. package/src/context/mcp-context.ts +1 -0
  28. package/src/context/stdio-context.ts +6 -0
  29. package/src/errors/missing-team-name-error.ts +8 -0
  30. package/src/formatters/__tests__/mcp-response.test.ts +106 -0
  31. package/src/formatters/mcp-response.ts +95 -0
  32. package/src/generated/api-types.ts +2691 -0
  33. package/src/i18n/__tests__/index.test.ts +53 -0
  34. package/src/i18n/index.ts +39 -0
  35. package/src/index.ts +47 -0
  36. package/src/locales/en.json +13 -0
  37. package/src/locales/ja.json +13 -0
  38. package/src/prompts/__tests__/index.test.ts +47 -0
  39. package/src/prompts/__tests__/summarize-post.test.ts +291 -0
  40. package/src/prompts/index.ts +21 -0
  41. package/src/prompts/summarize-post.ts +94 -0
  42. package/src/resources/__tests__/index.test.ts +49 -0
  43. package/src/resources/__tests__/recent-posts-list.test.ts +91 -0
  44. package/src/resources/__tests__/recent-posts.test.ts +270 -0
  45. package/src/resources/index.ts +33 -0
  46. package/src/resources/recent-posts-list.ts +22 -0
  47. package/src/resources/recent-posts.ts +45 -0
  48. package/src/schemas/team-name-schema.ts +19 -0
  49. package/src/tools/__tests__/categories.test.ts +226 -0
  50. package/src/tools/__tests__/comments.test.ts +970 -0
  51. package/src/tools/__tests__/helps.test.ts +222 -0
  52. package/src/tools/__tests__/index.test.ts +47 -0
  53. package/src/tools/__tests__/post-actions.test.ts +445 -0
  54. package/src/tools/__tests__/posts.test.ts +917 -0
  55. package/src/tools/__tests__/search.test.ts +339 -0
  56. package/src/tools/__tests__/teams.test.ts +615 -0
  57. package/src/tools/categories.ts +93 -0
  58. package/src/tools/comments.ts +258 -0
  59. package/src/tools/helps.ts +50 -0
  60. package/src/tools/index.ts +324 -0
  61. package/src/tools/post-actions.ts +132 -0
  62. package/src/tools/posts.ts +179 -0
  63. package/src/tools/search.ts +98 -0
  64. package/src/tools/teams.ts +157 -0
  65. package/src/transformers/__tests__/category-transformer.test.ts +161 -0
  66. package/src/transformers/__tests__/comment-transformer.test.ts +129 -0
  67. package/src/transformers/__tests__/post-name-normalizer.test.ts +53 -0
  68. package/src/transformers/__tests__/post-transformer.test.ts +70 -0
  69. package/src/transformers/__tests__/query-normalizer.test.ts +98 -0
  70. package/src/transformers/__tests__/team-name-normalizer.test.ts +21 -0
  71. package/src/transformers/category-transformer.ts +36 -0
  72. package/src/transformers/comment-transformer.ts +34 -0
  73. package/src/transformers/post-name-normalizer.ts +30 -0
  74. package/src/transformers/post-transformer.ts +38 -0
  75. package/src/transformers/query-normalizer.ts +36 -0
  76. package/src/transformers/team-name-normalizer.ts +7 -0
  77. package/tsconfig.build.json +4 -0
  78. package/tsconfig.json +30 -0
  79. package/tsdown.config.ts +13 -0
  80. package/vitest.config.ts +24 -0
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { withContext } from "../../api_client/with-context.js";
3
+ import type { MCPContext } from "../../context/mcp-context.js";
4
+ import { getTeams } from "../../tools/teams.js";
5
+ import { createRecentPostsResourceList } from "../recent-posts-list.js";
6
+
7
+ vi.mock("../../api_client/with-context.js");
8
+ vi.mock("../../tools/teams.js");
9
+
10
+ describe("createRecentPostsResourceList", () => {
11
+ let context: MCPContext;
12
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
13
+
14
+ beforeEach(() => {
15
+ context = {} as unknown as MCPContext;
16
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ afterEach(() => {
21
+ consoleErrorSpy.mockRestore();
22
+ });
23
+
24
+ it("should return team resources when teams are available", async () => {
25
+ const mockTeams = {
26
+ teams: [
27
+ { name: "team1", description: "Team One" },
28
+ { name: "team2", description: "" },
29
+ ],
30
+ };
31
+
32
+ vi.mocked(withContext).mockResolvedValue({
33
+ content: [{ text: JSON.stringify(mockTeams) }],
34
+ } as unknown as Awaited<ReturnType<typeof withContext>>);
35
+
36
+ const result = await createRecentPostsResourceList(context);
37
+
38
+ expect(withContext).toHaveBeenCalledWith(context, getTeams, {});
39
+ expect(result).toEqual([
40
+ {
41
+ uri: "esa://teams/team1/posts/recent",
42
+ name: "Recent posts from team1",
43
+ description: "Recent posts from team1 (Team One)",
44
+ mimeType: "application/json",
45
+ },
46
+ {
47
+ uri: "esa://teams/team2/posts/recent",
48
+ name: "Recent posts from team2",
49
+ description: "Recent posts from team2",
50
+ mimeType: "application/json",
51
+ },
52
+ ]);
53
+ });
54
+
55
+ it("should return empty array when no teams available", async () => {
56
+ vi.mocked(withContext).mockResolvedValue({
57
+ content: [{ text: JSON.stringify({ teams: null }) }],
58
+ } as unknown as Awaited<ReturnType<typeof withContext>>);
59
+
60
+ const result = await createRecentPostsResourceList(context);
61
+
62
+ expect(result).toEqual([]);
63
+ });
64
+
65
+ it("should return empty array and log error when withContext fails", async () => {
66
+ const error = new Error("API Error");
67
+ vi.mocked(withContext).mockRejectedValue(error);
68
+
69
+ const result = await createRecentPostsResourceList(context);
70
+
71
+ expect(result).toEqual([]);
72
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
73
+ "Failed to list teams:",
74
+ error,
75
+ );
76
+ });
77
+
78
+ it("should return empty array when JSON parsing fails", async () => {
79
+ vi.mocked(withContext).mockResolvedValue({
80
+ content: [{ text: "invalid json" }],
81
+ } as unknown as Awaited<ReturnType<typeof withContext>>);
82
+
83
+ const result = await createRecentPostsResourceList(context);
84
+
85
+ expect(result).toEqual([]);
86
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
87
+ "Failed to list teams:",
88
+ expect.any(SyntaxError),
89
+ );
90
+ });
91
+ });
@@ -0,0 +1,270 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createExpectedTransformed,
4
+ createLongContentPost,
5
+ createMockPost,
6
+ createNullBodyPost,
7
+ createWipPost,
8
+ } from "../../__tests__/fixtures/mock-post.js";
9
+ import type { createEsaClient } from "../../api_client/index.js";
10
+ import { getRecentPosts } from "../recent-posts.js";
11
+
12
+ describe("getRecentPosts", () => {
13
+ const mockClient = {
14
+ GET: vi.fn(),
15
+ } as unknown as ReturnType<typeof createEsaClient> & {
16
+ GET: ReturnType<typeof vi.fn>;
17
+ };
18
+
19
+ const testUri = "esa://teams/test-team/posts/recent";
20
+
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ });
24
+
25
+ it("should return recent posts successfully", async () => {
26
+ const mockPosts = [
27
+ createMockPost({
28
+ number: 1,
29
+ name: "post1.md",
30
+ full_name: "dev/post1.md #tag1 #tag2",
31
+ body_md:
32
+ "This is a test post content that is quite long and should be truncated",
33
+ body_html: "<p>This is a test post content</p>",
34
+ url: "https://test-team.esa.example.com/posts/1",
35
+ comments_count: 3,
36
+ tasks_count: 5,
37
+ done_tasks_count: 2,
38
+ watchers_count: 7,
39
+ star: false,
40
+ watch: false,
41
+ }),
42
+ createWipPost({
43
+ number: 2,
44
+ name: "post2.md",
45
+ full_name: "docs/post2.md #wip",
46
+ body_md: "Short content",
47
+ body_html: "<p>Short content</p>",
48
+ url: "https://test-team.esa.example.com/posts/2",
49
+ created_at: "2024-01-03T00:00:00+09:00",
50
+ updated_at: "2024-01-04T00:00:00+09:00",
51
+ stargazers_count: 1,
52
+ watchers_count: 2,
53
+ star: true,
54
+ watch: true,
55
+ }),
56
+ ];
57
+
58
+ const mockResponse = {
59
+ posts: mockPosts,
60
+ page: 1,
61
+ per_page: 20,
62
+ total_count: 2,
63
+ max_per_page: 100,
64
+ };
65
+
66
+ mockClient.GET.mockResolvedValue({
67
+ data: mockResponse,
68
+ error: undefined,
69
+ response: {
70
+ ok: true,
71
+ status: 200,
72
+ } as Response,
73
+ });
74
+
75
+ const result = await getRecentPosts(mockClient, {
76
+ teamName: "test-team",
77
+ uri: testUri,
78
+ });
79
+
80
+ expect(mockClient.GET).toHaveBeenCalledWith("/v1/teams/{team_name}/posts", {
81
+ params: {
82
+ path: { team_name: "test-team" },
83
+ query: {
84
+ sort: "updated",
85
+ order: "desc",
86
+ },
87
+ },
88
+ });
89
+
90
+ const expectedResponse = {
91
+ posts: mockPosts.map((post) => createExpectedTransformed(post)),
92
+ page: 1,
93
+ per_page: 20,
94
+ total_count: 2,
95
+ max_per_page: 100,
96
+ };
97
+
98
+ expect(result).toEqual({
99
+ contents: [
100
+ {
101
+ uri: testUri,
102
+ mimeType: "application/json",
103
+ text: JSON.stringify(expectedResponse, null, 2),
104
+ },
105
+ ],
106
+ });
107
+ });
108
+
109
+ it("should truncate long body_md content", async () => {
110
+ const mockPost = createLongContentPost(600);
111
+
112
+ mockClient.GET.mockResolvedValue({
113
+ data: { posts: [mockPost] },
114
+ error: undefined,
115
+ response: {
116
+ ok: true,
117
+ status: 200,
118
+ } as Response,
119
+ });
120
+
121
+ const result = await getRecentPosts(mockClient, {
122
+ teamName: "test-team",
123
+ uri: testUri,
124
+ });
125
+
126
+ const parsedResult = JSON.parse(result.contents[0].text as string);
127
+ expect(parsedResult.posts[0].body_md).toBe(`${"a".repeat(500)}...`);
128
+ });
129
+
130
+ it("should handle empty posts array", async () => {
131
+ mockClient.GET.mockResolvedValue({
132
+ data: {
133
+ posts: [],
134
+ page: 1,
135
+ per_page: 20,
136
+ total_count: 0,
137
+ max_per_page: 100,
138
+ },
139
+ error: undefined,
140
+ response: {
141
+ ok: true,
142
+ status: 200,
143
+ } as Response,
144
+ });
145
+
146
+ const result = await getRecentPosts(mockClient, {
147
+ teamName: "test-team",
148
+ uri: testUri,
149
+ });
150
+
151
+ const parsedResult = JSON.parse(result.contents[0].text as string);
152
+ expect(parsedResult.posts).toEqual([]);
153
+ expect(parsedResult.total_count).toBe(0);
154
+ });
155
+
156
+ it("should handle API errors", async () => {
157
+ const mockError = { error: "unauthorized", message: "Invalid token" };
158
+
159
+ mockClient.GET.mockResolvedValue({
160
+ data: undefined,
161
+ error: mockError,
162
+ response: {
163
+ ok: false,
164
+ status: 404,
165
+ } as Response,
166
+ });
167
+
168
+ const result = await getRecentPosts(mockClient, {
169
+ teamName: "test-team",
170
+ uri: testUri,
171
+ });
172
+
173
+ expect(result).toEqual({
174
+ contents: [
175
+ {
176
+ uri: testUri,
177
+ mimeType: "application/json",
178
+ text: `Error: ${JSON.stringify(mockError, null, 2)}`,
179
+ },
180
+ ],
181
+ });
182
+ });
183
+
184
+ it("should handle response status errors", async () => {
185
+ mockClient.GET.mockResolvedValue({
186
+ data: undefined,
187
+ error: undefined,
188
+ response: {
189
+ ok: false,
190
+ status: 500,
191
+ } as Response,
192
+ });
193
+
194
+ const result = await getRecentPosts(mockClient, {
195
+ teamName: "test-team",
196
+ uri: testUri,
197
+ });
198
+
199
+ expect(result).toEqual({
200
+ contents: [
201
+ {
202
+ uri: testUri,
203
+ mimeType: "application/json",
204
+ text: "Error: API Response(status: 500)",
205
+ },
206
+ ],
207
+ });
208
+ });
209
+
210
+ it("should handle network errors", async () => {
211
+ const networkError = new Error("Network connection failed");
212
+
213
+ mockClient.GET.mockRejectedValue(networkError);
214
+
215
+ const result = await getRecentPosts(mockClient, {
216
+ teamName: "test-team",
217
+ uri: testUri,
218
+ });
219
+
220
+ expect(result).toEqual({
221
+ contents: [
222
+ {
223
+ uri: testUri,
224
+ mimeType: "application/json",
225
+ text: "Error: Network connection failed",
226
+ },
227
+ ],
228
+ });
229
+ });
230
+
231
+ it("should handle non-Error exceptions", async () => {
232
+ mockClient.GET.mockRejectedValue("Unexpected error");
233
+
234
+ const result = await getRecentPosts(mockClient, {
235
+ teamName: "test-team",
236
+ uri: testUri,
237
+ });
238
+
239
+ expect(result).toEqual({
240
+ contents: [
241
+ {
242
+ uri: testUri,
243
+ mimeType: "application/json",
244
+ text: "Error: Unexpected error",
245
+ },
246
+ ],
247
+ });
248
+ });
249
+
250
+ it("should handle posts with undefined body_md", async () => {
251
+ const mockPost = createNullBodyPost();
252
+
253
+ mockClient.GET.mockResolvedValue({
254
+ data: { posts: [mockPost] },
255
+ error: undefined,
256
+ response: {
257
+ ok: true,
258
+ status: 200,
259
+ } as Response,
260
+ });
261
+
262
+ const result = await getRecentPosts(mockClient, {
263
+ teamName: "test-team",
264
+ uri: testUri,
265
+ });
266
+
267
+ const parsedResult = JSON.parse(result.contents[0].text as string);
268
+ expect(parsedResult.posts[0].body_md).toBe(undefined);
269
+ });
270
+ });
@@ -0,0 +1,33 @@
1
+ import {
2
+ type McpServer,
3
+ ResourceTemplate,
4
+ } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { withContext } from "../api_client/with-context.js";
6
+ import type { MCPContext } from "../context/mcp-context.js";
7
+
8
+ import { getRecentPosts } from "./recent-posts.js";
9
+ import { createRecentPostsResourceList } from "./recent-posts-list.js";
10
+
11
+ export function setupResources(server: McpServer, context: MCPContext): void {
12
+ console.error("Setting up MCP resources...");
13
+
14
+ server.registerResource(
15
+ "esa_recent_posts",
16
+ new ResourceTemplate("esa://teams/{teamName}/posts/recent", {
17
+ list: async () => {
18
+ const resources = await createRecentPostsResourceList(context);
19
+ return { resources };
20
+ },
21
+ }),
22
+ {
23
+ title: "Recent Posts",
24
+ description: "Fetch recent updated posts from esa team",
25
+ mimeType: "application/json",
26
+ },
27
+ async (uri, params) =>
28
+ withContext(context, getRecentPosts, { ...params, uri: uri.href } as {
29
+ teamName: string;
30
+ uri: string;
31
+ }),
32
+ );
33
+ }
@@ -0,0 +1,22 @@
1
+ import { withContext } from "../api_client/with-context.js";
2
+ import type { MCPContext } from "../context/mcp-context.js";
3
+ import { getTeams } from "../tools/teams.js";
4
+
5
+ export async function createRecentPostsResourceList(context: MCPContext) {
6
+ try {
7
+ const result = await withContext(context, getTeams, {});
8
+ const data = JSON.parse(result.content[0].text as string);
9
+
10
+ return (
11
+ data.teams?.map((team: { name: string; description: string }) => ({
12
+ uri: `esa://teams/${team.name}/posts/recent`,
13
+ name: `Recent posts from ${team.name}`,
14
+ description: `Recent posts from ${team.name}${team.description ? ` (${team.description})` : ""}`,
15
+ mimeType: "application/json",
16
+ })) || []
17
+ );
18
+ } catch (error) {
19
+ console.error("Failed to list teams:", error);
20
+ return [];
21
+ }
22
+ }
@@ -0,0 +1,45 @@
1
+ import type { createEsaClient } from "../api_client/index.js";
2
+ import {
3
+ formatResourceError,
4
+ formatResourceResponse,
5
+ } from "../formatters/mcp-response.js";
6
+ import type { components } from "../generated/api-types.js";
7
+ import { transformPost } from "../transformers/post-transformer.js";
8
+
9
+ const RECENT_POSTS_QUERY = {
10
+ sort: "updated",
11
+ order: "desc",
12
+ } as const;
13
+
14
+ export async function getRecentPosts(
15
+ client: ReturnType<typeof createEsaClient>,
16
+ args: { teamName: string; uri: string },
17
+ ) {
18
+ const { teamName, uri } = args;
19
+ try {
20
+ const { data, error, response } = await client.GET(
21
+ "/v1/teams/{team_name}/posts",
22
+ {
23
+ params: {
24
+ path: { team_name: teamName },
25
+ query: RECENT_POSTS_QUERY,
26
+ },
27
+ },
28
+ );
29
+
30
+ if (error || !response.ok) {
31
+ return formatResourceError(error || response.status, uri);
32
+ }
33
+
34
+ const transformed = {
35
+ ...data,
36
+ posts: data.posts?.map((post: components["schemas"]["Post"]) =>
37
+ transformPost(post, { truncateBody: 500 }),
38
+ ),
39
+ };
40
+
41
+ return formatResourceResponse(transformed, uri);
42
+ } catch (error) {
43
+ return formatResourceError(error, uri);
44
+ }
45
+ }
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ import { normalizeTeamName } from "../transformers/team-name-normalizer.js";
3
+
4
+ export const teamNameSchema = z
5
+ .string()
6
+ .default("")
7
+ .describe(
8
+ "Team name (required). Use esa_get_teams first to see available teams.",
9
+ )
10
+ .transform(normalizeTeamName);
11
+
12
+ export function createSchemaWithTeamName<T extends z.ZodRawShape>(
13
+ schema: T,
14
+ ): z.ZodObject<T & { teamName: typeof teamNameSchema }> {
15
+ return z.object({
16
+ teamName: teamNameSchema,
17
+ ...schema,
18
+ });
19
+ }
@@ -0,0 +1,226 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { createEsaClient } from "../../api_client/index.js";
3
+ import { getCategories, getTopCategories } from "../categories.js";
4
+
5
+ describe("getCategories", () => {
6
+ const mockClient = {
7
+ GET: vi.fn(),
8
+ } as unknown as ReturnType<typeof createEsaClient> & {
9
+ GET: ReturnType<typeof vi.fn>;
10
+ };
11
+
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ });
15
+
16
+ it("should fetch categories for a specific path", async () => {
17
+ const mockResponse = {
18
+ current_category: "dev/docs",
19
+ categories: [
20
+ {
21
+ name: "api",
22
+ full_name: "dev/docs/api",
23
+ count: 5,
24
+ has_child: true,
25
+ },
26
+ ],
27
+ total_count: 1,
28
+ per_page: 20,
29
+ page: 1,
30
+ prev_page: null,
31
+ next_page: null,
32
+ max_per_page: 100,
33
+ };
34
+
35
+ mockClient.GET.mockResolvedValue({
36
+ data: mockResponse,
37
+ error: undefined,
38
+ response: { ok: true, status: 200 } as Response,
39
+ });
40
+
41
+ const result = await getCategories(mockClient, {
42
+ teamName: "test-team",
43
+ select: "dev/docs",
44
+ });
45
+
46
+ expect(mockClient.GET).toHaveBeenCalledWith(
47
+ "/v1/teams/{team_name}/categories",
48
+ {
49
+ params: {
50
+ path: { team_name: "test-team" },
51
+ query: {
52
+ select: "dev/docs",
53
+ include: undefined,
54
+ descendant_posts: undefined,
55
+ page: undefined,
56
+ per_page: undefined,
57
+ },
58
+ },
59
+ },
60
+ );
61
+
62
+ expect(result.content[0].text).toContain("dev/docs");
63
+ });
64
+
65
+ it("should handle API errors", async () => {
66
+ const mockError = { error: "not_found", message: "Category not found" };
67
+
68
+ mockClient.GET.mockResolvedValue({
69
+ data: undefined,
70
+ error: mockError,
71
+ response: { ok: false, status: 404 } as Response,
72
+ });
73
+
74
+ const result = await getCategories(mockClient, {
75
+ teamName: "test-team",
76
+ select: "nonexistent",
77
+ });
78
+
79
+ expect(result.content[0].text).toContain("not_found");
80
+ });
81
+
82
+ it("should handle network errors", async () => {
83
+ const networkError = new Error("Network connection failed");
84
+
85
+ mockClient.GET.mockRejectedValue(networkError);
86
+
87
+ const result = await getCategories(mockClient, {
88
+ teamName: "test-team",
89
+ select: "dev/docs",
90
+ });
91
+
92
+ expect(result.content[0].text).toContain("Network connection failed");
93
+ });
94
+
95
+ it("should handle non-Error exceptions", async () => {
96
+ mockClient.GET.mockRejectedValue("Unexpected error");
97
+
98
+ const result = await getCategories(mockClient, {
99
+ teamName: "test-team",
100
+ select: "dev/docs",
101
+ });
102
+
103
+ expect(result.content[0].text).toContain("Unexpected error");
104
+ });
105
+
106
+ it("should throw MissingTeamNameError when teamName is empty", async () => {
107
+ const result = await getCategories(mockClient, {
108
+ teamName: "",
109
+ select: "dev",
110
+ });
111
+
112
+ expect(result).toEqual({
113
+ content: [
114
+ {
115
+ type: "text",
116
+ text: "Error: Missing required parameter 'teamName'. Use esa_get_teams to list available teams, then retry with teamName specified.",
117
+ },
118
+ ],
119
+ });
120
+
121
+ expect(mockClient.GET).not.toHaveBeenCalled();
122
+ });
123
+ });
124
+
125
+ describe("getTopCategories", () => {
126
+ const mockClient = {
127
+ GET: vi.fn(),
128
+ } as unknown as ReturnType<typeof createEsaClient> & {
129
+ GET: ReturnType<typeof vi.fn>;
130
+ };
131
+
132
+ beforeEach(() => {
133
+ vi.clearAllMocks();
134
+ });
135
+
136
+ it("should fetch top-level categories", async () => {
137
+ const mockResponse = {
138
+ current_category: "",
139
+ categories: [
140
+ { name: "dev", full_name: "dev", count: 15, has_child: true },
141
+ { name: "design", full_name: "design", count: 8, has_child: false },
142
+ ],
143
+ total_count: 2,
144
+ per_page: 20,
145
+ page: 1,
146
+ prev_page: null,
147
+ next_page: null,
148
+ max_per_page: 100,
149
+ };
150
+
151
+ mockClient.GET.mockResolvedValue({
152
+ data: mockResponse,
153
+ error: undefined,
154
+ response: { ok: true, status: 200 } as Response,
155
+ });
156
+
157
+ const result = await getTopCategories(mockClient, {
158
+ teamName: "test-team",
159
+ });
160
+
161
+ expect(mockClient.GET).toHaveBeenCalledWith(
162
+ "/v1/teams/{team_name}/categories/top",
163
+ {
164
+ params: { path: { team_name: "test-team" } },
165
+ },
166
+ );
167
+
168
+ expect(result.content[0].text).toContain("dev");
169
+ expect(result.content[0].text).toContain("design");
170
+ });
171
+
172
+ it("should handle API errors", async () => {
173
+ const mockError = { error: "forbidden", message: "Access denied" };
174
+
175
+ mockClient.GET.mockResolvedValue({
176
+ data: undefined,
177
+ error: mockError,
178
+ response: { ok: false, status: 403 } as Response,
179
+ });
180
+
181
+ const result = await getTopCategories(mockClient, {
182
+ teamName: "test-team",
183
+ });
184
+
185
+ expect(result.content[0].text).toContain("forbidden");
186
+ });
187
+
188
+ it("should handle network errors", async () => {
189
+ const networkError = new Error("Network connection failed");
190
+
191
+ mockClient.GET.mockRejectedValue(networkError);
192
+
193
+ const result = await getTopCategories(mockClient, {
194
+ teamName: "test-team",
195
+ });
196
+
197
+ expect(result.content[0].text).toContain("Network connection failed");
198
+ });
199
+
200
+ it("should handle non-Error exceptions", async () => {
201
+ mockClient.GET.mockRejectedValue("Unexpected error");
202
+
203
+ const result = await getTopCategories(mockClient, {
204
+ teamName: "test-team",
205
+ });
206
+
207
+ expect(result.content[0].text).toContain("Unexpected error");
208
+ });
209
+
210
+ it("should throw MissingTeamNameError when teamName is empty", async () => {
211
+ const result = await getTopCategories(mockClient, {
212
+ teamName: "",
213
+ });
214
+
215
+ expect(result).toEqual({
216
+ content: [
217
+ {
218
+ type: "text",
219
+ text: "Error: Missing required parameter 'teamName'. Use esa_get_teams to list available teams, then retry with teamName specified.",
220
+ },
221
+ ],
222
+ });
223
+
224
+ expect(mockClient.GET).not.toHaveBeenCalled();
225
+ });
226
+ });