@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.
- package/.claude/settings.local.json +23 -0
- package/.dockerignore +36 -0
- package/.envrc +2 -0
- package/.github/dependabot.yml +18 -0
- package/.github/workflows/docker-publish.yml +120 -0
- package/.github/workflows/main.yml +41 -0
- package/.node-version +1 -0
- package/CLAUDE.md +94 -0
- package/Dockerfile +34 -0
- package/LICENSE +7 -0
- package/README.en.md +139 -0
- package/README.md +139 -0
- package/bin/index.js +30 -0
- package/biome.json +57 -0
- package/package.json +48 -0
- package/src/__tests__/fixtures/mock-comment.ts +90 -0
- package/src/__tests__/fixtures/mock-post.ts +79 -0
- package/src/__tests__/index.test.ts +209 -0
- package/src/api_client/__tests__/index.test.ts +149 -0
- package/src/api_client/__tests__/middleware.test.ts +119 -0
- package/src/api_client/__tests__/with-context.test.ts +98 -0
- package/src/api_client/index.ts +29 -0
- package/src/api_client/middleware.ts +21 -0
- package/src/api_client/with-context.ts +26 -0
- package/src/config/__tests__/index.test.ts +65 -0
- package/src/config/index.ts +20 -0
- package/src/context/mcp-context.ts +1 -0
- package/src/context/stdio-context.ts +6 -0
- package/src/errors/missing-team-name-error.ts +8 -0
- package/src/formatters/__tests__/mcp-response.test.ts +106 -0
- package/src/formatters/mcp-response.ts +95 -0
- package/src/generated/api-types.ts +2691 -0
- package/src/i18n/__tests__/index.test.ts +53 -0
- package/src/i18n/index.ts +39 -0
- package/src/index.ts +47 -0
- package/src/locales/en.json +13 -0
- package/src/locales/ja.json +13 -0
- package/src/prompts/__tests__/index.test.ts +47 -0
- package/src/prompts/__tests__/summarize-post.test.ts +291 -0
- package/src/prompts/index.ts +21 -0
- package/src/prompts/summarize-post.ts +94 -0
- package/src/resources/__tests__/index.test.ts +49 -0
- package/src/resources/__tests__/recent-posts-list.test.ts +91 -0
- package/src/resources/__tests__/recent-posts.test.ts +270 -0
- package/src/resources/index.ts +33 -0
- package/src/resources/recent-posts-list.ts +22 -0
- package/src/resources/recent-posts.ts +45 -0
- package/src/schemas/team-name-schema.ts +19 -0
- package/src/tools/__tests__/categories.test.ts +226 -0
- package/src/tools/__tests__/comments.test.ts +970 -0
- package/src/tools/__tests__/helps.test.ts +222 -0
- package/src/tools/__tests__/index.test.ts +47 -0
- package/src/tools/__tests__/post-actions.test.ts +445 -0
- package/src/tools/__tests__/posts.test.ts +917 -0
- package/src/tools/__tests__/search.test.ts +339 -0
- package/src/tools/__tests__/teams.test.ts +615 -0
- package/src/tools/categories.ts +93 -0
- package/src/tools/comments.ts +258 -0
- package/src/tools/helps.ts +50 -0
- package/src/tools/index.ts +324 -0
- package/src/tools/post-actions.ts +132 -0
- package/src/tools/posts.ts +179 -0
- package/src/tools/search.ts +98 -0
- package/src/tools/teams.ts +157 -0
- package/src/transformers/__tests__/category-transformer.test.ts +161 -0
- package/src/transformers/__tests__/comment-transformer.test.ts +129 -0
- package/src/transformers/__tests__/post-name-normalizer.test.ts +53 -0
- package/src/transformers/__tests__/post-transformer.test.ts +70 -0
- package/src/transformers/__tests__/query-normalizer.test.ts +98 -0
- package/src/transformers/__tests__/team-name-normalizer.test.ts +21 -0
- package/src/transformers/category-transformer.ts +36 -0
- package/src/transformers/comment-transformer.ts +34 -0
- package/src/transformers/post-name-normalizer.ts +30 -0
- package/src/transformers/post-transformer.ts +38 -0
- package/src/transformers/query-normalizer.ts +36 -0
- package/src/transformers/team-name-normalizer.ts +7 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +30 -0
- package/tsdown.config.ts +13 -0
- package/vitest.config.ts +24 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { initI18n, setLanguage, t } from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("i18n", () => {
|
|
5
|
+
// Helper to set language environment variable and initialize i18n
|
|
6
|
+
const initI18nWithLang = async (envVar: string, value: string) => {
|
|
7
|
+
vi.stubEnv(envVar, value);
|
|
8
|
+
return await initI18n();
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.resetModules();
|
|
13
|
+
vi.stubEnv("LC_ALL", undefined);
|
|
14
|
+
vi.stubEnv("LC_MESSAGES", undefined);
|
|
15
|
+
vi.stubEnv("LANG", undefined);
|
|
16
|
+
vi.stubEnv("LANGUAGE", undefined);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.unstubAllEnvs();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should detect language from LC_ALL env var", async () => {
|
|
24
|
+
const i18n = await initI18nWithLang("LC_ALL", "ja_JP.UTF-8");
|
|
25
|
+
expect(i18n.language).toBe("ja");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should detect language from LC_MESSAGES env var", async () => {
|
|
29
|
+
const i18n = await initI18nWithLang("LC_MESSAGES", "ja_JP.UTF-8");
|
|
30
|
+
expect(i18n.language).toBe("ja");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should detect language from LANG env var", async () => {
|
|
34
|
+
const i18n = await initI18nWithLang("LANG", "ja_JP.UTF-8");
|
|
35
|
+
expect(i18n.language).toBe("ja");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should detect language from LANGUAGE env var", async () => {
|
|
39
|
+
const i18n = await initI18nWithLang("LANGUAGE", "ja_JP.UTF-8");
|
|
40
|
+
expect(i18n.language).toBe("ja");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should fallback to en when no env vars are set", async () => {
|
|
44
|
+
const i18n = await initI18n();
|
|
45
|
+
expect(i18n.language).toBe("en");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should change language dynamically with setLanguage", async () => {
|
|
49
|
+
await initI18n();
|
|
50
|
+
await setLanguage("ja");
|
|
51
|
+
expect(t("prompts.summarize_post.title")).toContain("要約");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import i18next from "i18next";
|
|
2
|
+
import enTranslations from "../locales/en.json" with { type: "json" };
|
|
3
|
+
import jaTranslations from "../locales/ja.json" with { type: "json" };
|
|
4
|
+
|
|
5
|
+
export async function initI18n() {
|
|
6
|
+
// https://github.com/neet/i18next-cli-language-detector/blob/main/src/i18next-cli-language-detector.ts
|
|
7
|
+
// を参考に簡易版の環境変数からの自動検出
|
|
8
|
+
const lng =
|
|
9
|
+
process.env.LC_ALL?.split(/[-_.]/)[0] ||
|
|
10
|
+
process.env.LC_MESSAGES?.split(/[-_.]/)[0] ||
|
|
11
|
+
process.env.LANG?.split(/[-_.]/)[0] ||
|
|
12
|
+
process.env.LANGUAGE?.split(/[-_.]/)[0] ||
|
|
13
|
+
"en";
|
|
14
|
+
await i18next.init({
|
|
15
|
+
lng,
|
|
16
|
+
fallbackLng: "en",
|
|
17
|
+
resources: {
|
|
18
|
+
ja: {
|
|
19
|
+
translation: jaTranslations,
|
|
20
|
+
},
|
|
21
|
+
en: {
|
|
22
|
+
translation: enTranslations,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
interpolation: {
|
|
26
|
+
escapeValue: false,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return i18next;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function setLanguage(lng: string) {
|
|
34
|
+
return i18next.changeLanguage(lng);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function t(key: string, options?: Record<string, unknown>) {
|
|
38
|
+
return i18next.t(key, options);
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { config, validateConfig } from "./config/index.js";
|
|
5
|
+
import { initI18n } from "./i18n/index.js";
|
|
6
|
+
import { setupPrompts } from "./prompts/index.js";
|
|
7
|
+
import { setupResources } from "./resources/index.js";
|
|
8
|
+
import { setupTools } from "./tools/index.js";
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
validateConfig();
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error("Configuration error:", error);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
await initI18n();
|
|
19
|
+
|
|
20
|
+
const server = new McpServer({
|
|
21
|
+
name: config.server.name,
|
|
22
|
+
version: config.server.version,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
setupTools(server, config.esa);
|
|
26
|
+
setupResources(server, config.esa);
|
|
27
|
+
setupPrompts(server, config.esa);
|
|
28
|
+
|
|
29
|
+
const transport = new StdioServerTransport();
|
|
30
|
+
|
|
31
|
+
// Handle transport errors gracefully
|
|
32
|
+
transport.onclose = () => {
|
|
33
|
+
console.error("Transport closed");
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
transport.onerror = (error) => {
|
|
37
|
+
console.error("Transport error:", error);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
await server.connect(transport);
|
|
41
|
+
console.error(`${config.server.name} v${config.server.version} started`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await main().catch((error) => {
|
|
45
|
+
console.error("Server startup error:", error);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"prompts": {
|
|
3
|
+
"summarize_post": {
|
|
4
|
+
"title": "Summarize esa post",
|
|
5
|
+
"description": "Summarize an esa post in various formats (bullet points, paragraph, or keywords)",
|
|
6
|
+
"args": {
|
|
7
|
+
"team_name": "The name of the esa team",
|
|
8
|
+
"post_number": "The post number to summarize",
|
|
9
|
+
"format": "Summary format (bullet/paragraph/keywords)"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"prompts": {
|
|
3
|
+
"summarize_post": {
|
|
4
|
+
"title": "esaの記事の要約",
|
|
5
|
+
"description": "esa記事を指定した形式で要約します(bullet: 箇条書き, paragraph: 文章, keywords: キーワード)",
|
|
6
|
+
"args": {
|
|
7
|
+
"team_name": "esaチーム名",
|
|
8
|
+
"post_number": "要約する記事番号",
|
|
9
|
+
"format": "要約形式 (bullet/paragraph/keywords)"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { MCPContext } from "../../context/mcp-context.js";
|
|
4
|
+
import { setupPrompts } from "../index.js";
|
|
5
|
+
|
|
6
|
+
describe("setupPrompts", () => {
|
|
7
|
+
let server: McpServer;
|
|
8
|
+
let context: MCPContext;
|
|
9
|
+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
server = new McpServer({
|
|
13
|
+
name: "test-server",
|
|
14
|
+
version: "1.0.0",
|
|
15
|
+
});
|
|
16
|
+
context = {} as unknown as MCPContext;
|
|
17
|
+
|
|
18
|
+
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
consoleErrorSpy.mockRestore();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should register all prompts with correct handlers", () => {
|
|
27
|
+
const registerPromptSpy = vi.spyOn(server, "registerPrompt");
|
|
28
|
+
|
|
29
|
+
setupPrompts(server, context);
|
|
30
|
+
|
|
31
|
+
expect(registerPromptSpy).toHaveBeenCalledTimes(1);
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < 1; i++) {
|
|
34
|
+
const [promptName, schema, handler] = registerPromptSpy.mock.calls[i];
|
|
35
|
+
expect(typeof promptName).toBe("string");
|
|
36
|
+
expect(promptName).toMatch(/^esa_/); // All prompts should start with 'esa_'
|
|
37
|
+
expect(schema).toBeTypeOf("object"); // Schema verification handled by individual prompt tests
|
|
38
|
+
expect(handler).toBeTypeOf("function"); // Handler functionality tested in specific prompt tests
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should log setup completion message", () => {
|
|
43
|
+
setupPrompts(server, context);
|
|
44
|
+
|
|
45
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith("Setting up MCP prompts...");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { createEsaClient } from "../../api_client/index.js";
|
|
3
|
+
import { createSummarizePostSchema, summarizePost } from "../summarize-post.js";
|
|
4
|
+
|
|
5
|
+
describe("createSummarizePostSchema", () => {
|
|
6
|
+
it("should create schema with proper fields", () => {
|
|
7
|
+
const schema = createSummarizePostSchema();
|
|
8
|
+
const shape = schema.shape;
|
|
9
|
+
|
|
10
|
+
expect(shape.teamName).toBeDefined();
|
|
11
|
+
expect(shape.postNumber).toBeDefined();
|
|
12
|
+
expect(shape.format).toBeDefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should validate correct input", () => {
|
|
16
|
+
const schema = createSummarizePostSchema();
|
|
17
|
+
const result = schema.safeParse({
|
|
18
|
+
teamName: "test-team",
|
|
19
|
+
postNumber: "123",
|
|
20
|
+
format: "bullet",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result.success).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should allow optional format", () => {
|
|
27
|
+
const schema = createSummarizePostSchema();
|
|
28
|
+
const result = schema.safeParse({
|
|
29
|
+
teamName: "test-team",
|
|
30
|
+
postNumber: "123",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result.success).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("summarizePost", () => {
|
|
38
|
+
const mockClient = {
|
|
39
|
+
GET: vi.fn(),
|
|
40
|
+
} as unknown as ReturnType<typeof createEsaClient> & {
|
|
41
|
+
GET: ReturnType<typeof vi.fn>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const mockPost = {
|
|
49
|
+
number: 123,
|
|
50
|
+
name: "test-post.md",
|
|
51
|
+
full_name: "dev/test-post.md #tag1 #tag2",
|
|
52
|
+
wip: false,
|
|
53
|
+
body_md: "# Test Post\n\nThis is a test post content.",
|
|
54
|
+
body_html: "<h1>Test Post</h1><p>This is a test post content.</p>",
|
|
55
|
+
created_at: "2024-01-01T00:00:00+09:00",
|
|
56
|
+
updated_at: "2024-01-02T00:00:00+09:00",
|
|
57
|
+
message: "Update test post",
|
|
58
|
+
url: "https://test-team.esa.example.com/posts/123",
|
|
59
|
+
category: "dev",
|
|
60
|
+
tags: ["tag1", "tag2"],
|
|
61
|
+
revision_number: 3,
|
|
62
|
+
created_by: {
|
|
63
|
+
name: "user1",
|
|
64
|
+
screen_name: "user1",
|
|
65
|
+
icon: "https://example.com/icon1.png",
|
|
66
|
+
},
|
|
67
|
+
updated_by: {
|
|
68
|
+
name: "user2",
|
|
69
|
+
screen_name: "user2",
|
|
70
|
+
icon: "https://example.com/icon2.png",
|
|
71
|
+
},
|
|
72
|
+
kind: "stock",
|
|
73
|
+
comments_count: 5,
|
|
74
|
+
tasks_count: 3,
|
|
75
|
+
done_tasks_count: 2,
|
|
76
|
+
stargazers_count: 10,
|
|
77
|
+
watchers_count: 8,
|
|
78
|
+
star: true,
|
|
79
|
+
watch: false,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
it("should generate a bullet format summary prompt", async () => {
|
|
83
|
+
mockClient.GET.mockResolvedValue({
|
|
84
|
+
data: mockPost,
|
|
85
|
+
error: undefined,
|
|
86
|
+
response: {
|
|
87
|
+
ok: true,
|
|
88
|
+
status: 200,
|
|
89
|
+
} as Response,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const result = await summarizePost(mockClient, {
|
|
93
|
+
teamName: "test-team",
|
|
94
|
+
postNumber: "123",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(mockClient.GET).toHaveBeenCalledWith(
|
|
98
|
+
"/v1/teams/{team_name}/posts/{post_number}",
|
|
99
|
+
{
|
|
100
|
+
params: {
|
|
101
|
+
path: { team_name: "test-team", post_number: 123 },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(result.messages).toHaveLength(1);
|
|
107
|
+
expect(result.messages[0].role).toBe("user");
|
|
108
|
+
expect(result.messages[0].content.type).toBe("text");
|
|
109
|
+
|
|
110
|
+
const promptText = result.messages[0].content.text;
|
|
111
|
+
expect(promptText).toContain("Please summarize the following post:");
|
|
112
|
+
expect(promptText).toContain("Title: test-post.md");
|
|
113
|
+
expect(promptText).toContain(
|
|
114
|
+
"URL: https://test-team.esa.example.com/posts/123",
|
|
115
|
+
);
|
|
116
|
+
expect(promptText).toContain("Author: user1");
|
|
117
|
+
expect(promptText).toContain("Category: dev");
|
|
118
|
+
expect(promptText).toContain("Tags: tag1, tag2");
|
|
119
|
+
expect(promptText).toContain("# Test Post");
|
|
120
|
+
expect(promptText).toContain("Please provide a summary in bullet points");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should generate a paragraph format summary prompt", async () => {
|
|
124
|
+
mockClient.GET.mockResolvedValue({
|
|
125
|
+
data: mockPost,
|
|
126
|
+
error: undefined,
|
|
127
|
+
response: {
|
|
128
|
+
ok: true,
|
|
129
|
+
status: 200,
|
|
130
|
+
} as Response,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const result = await summarizePost(mockClient, {
|
|
134
|
+
teamName: "test-team",
|
|
135
|
+
postNumber: "123",
|
|
136
|
+
format: "paragraph",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const promptText = result.messages[0].content.text;
|
|
140
|
+
expect(promptText).toContain("Please provide a summary in 2-3 paragraphs");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should generate a keywords format summary prompt", async () => {
|
|
144
|
+
mockClient.GET.mockResolvedValue({
|
|
145
|
+
data: mockPost,
|
|
146
|
+
error: undefined,
|
|
147
|
+
response: {
|
|
148
|
+
ok: true,
|
|
149
|
+
status: 200,
|
|
150
|
+
} as Response,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await summarizePost(mockClient, {
|
|
154
|
+
teamName: "test-team",
|
|
155
|
+
postNumber: "123",
|
|
156
|
+
format: "keywords",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const promptText = result.messages[0].content.text;
|
|
160
|
+
expect(promptText).toContain(
|
|
161
|
+
"Please extract and list 10-15 important keywords",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("should handle post without category and tags", async () => {
|
|
166
|
+
const postWithoutCategoryAndTags = {
|
|
167
|
+
...mockPost,
|
|
168
|
+
category: null,
|
|
169
|
+
tags: [],
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
mockClient.GET.mockResolvedValue({
|
|
173
|
+
data: postWithoutCategoryAndTags,
|
|
174
|
+
error: undefined,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const result = await summarizePost(mockClient, {
|
|
178
|
+
teamName: "test-team",
|
|
179
|
+
postNumber: "456",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const promptText = result.messages[0].content.text;
|
|
183
|
+
expect(promptText).not.toContain("Category:");
|
|
184
|
+
expect(promptText).not.toContain("Tags:");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should handle post with null body_md", async () => {
|
|
188
|
+
const postWithNullBody = {
|
|
189
|
+
...mockPost,
|
|
190
|
+
body_md: null,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
mockClient.GET.mockResolvedValue({
|
|
194
|
+
data: postWithNullBody,
|
|
195
|
+
error: undefined,
|
|
196
|
+
response: {
|
|
197
|
+
ok: true,
|
|
198
|
+
status: 200,
|
|
199
|
+
} as Response,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const result = await summarizePost(mockClient, {
|
|
203
|
+
teamName: "test-team",
|
|
204
|
+
postNumber: "789",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const promptText = result.messages[0].content.text;
|
|
208
|
+
expect(promptText).not.toContain("Content:");
|
|
209
|
+
expect(promptText).toContain("Please provide a summary in bullet points");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should handle API errors", async () => {
|
|
213
|
+
const mockError = { error: "not_found", message: "Post not found" };
|
|
214
|
+
|
|
215
|
+
mockClient.GET.mockResolvedValue({
|
|
216
|
+
data: undefined,
|
|
217
|
+
error: mockError,
|
|
218
|
+
response: {
|
|
219
|
+
ok: false,
|
|
220
|
+
status: 404,
|
|
221
|
+
} as Response,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = await summarizePost(mockClient, {
|
|
225
|
+
teamName: "test-team",
|
|
226
|
+
postNumber: "999",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(result.messages).toHaveLength(1);
|
|
230
|
+
expect(result.messages[0].role).toBe("user");
|
|
231
|
+
expect(result.messages[0].content.text).toContain(
|
|
232
|
+
JSON.stringify(mockError, null, 2),
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("should handle network errors", async () => {
|
|
237
|
+
const networkError = new Error("Network connection failed");
|
|
238
|
+
|
|
239
|
+
mockClient.GET.mockRejectedValue(networkError);
|
|
240
|
+
|
|
241
|
+
const result = await summarizePost(mockClient, {
|
|
242
|
+
teamName: "test-team",
|
|
243
|
+
postNumber: "123",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result.messages).toHaveLength(1);
|
|
247
|
+
expect(result.messages[0].role).toBe("user");
|
|
248
|
+
expect(result.messages[0].content.text).toContain(
|
|
249
|
+
"Network connection failed",
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("should handle non-Error exceptions", async () => {
|
|
254
|
+
mockClient.GET.mockRejectedValue("Unexpected error");
|
|
255
|
+
|
|
256
|
+
const result = await summarizePost(mockClient, {
|
|
257
|
+
teamName: "test-team",
|
|
258
|
+
postNumber: "123",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result.messages).toHaveLength(1);
|
|
262
|
+
expect(result.messages[0].role).toBe("user");
|
|
263
|
+
expect(result.messages[0].content.text).toContain("Unexpected error");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should handle invalid post number", async () => {
|
|
267
|
+
const result = await summarizePost(mockClient, {
|
|
268
|
+
teamName: "test-team",
|
|
269
|
+
postNumber: "invalid",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(result.messages).toHaveLength(1);
|
|
273
|
+
expect(result.messages[0].role).toBe("user");
|
|
274
|
+
expect(result.messages[0].content.text).toContain(
|
|
275
|
+
"Post number must be a positive integer",
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("should throw MissingTeamNameError when teamName is empty", async () => {
|
|
280
|
+
await expect(
|
|
281
|
+
summarizePost(mockClient, {
|
|
282
|
+
teamName: "",
|
|
283
|
+
postNumber: "123",
|
|
284
|
+
}),
|
|
285
|
+
).rejects.toThrow(
|
|
286
|
+
"Missing required parameter 'teamName'. Use esa_get_teams to list available teams, then retry with teamName specified.",
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
expect(mockClient.GET).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import { withContext } from "../api_client/with-context.js";
|
|
4
|
+
import type { MCPContext } from "../context/mcp-context.js";
|
|
5
|
+
import { t } from "../i18n/index.js";
|
|
6
|
+
import { createSummarizePostSchema, summarizePost } from "./summarize-post.js";
|
|
7
|
+
|
|
8
|
+
export function setupPrompts(server: McpServer, context: MCPContext): void {
|
|
9
|
+
console.error("Setting up MCP prompts...");
|
|
10
|
+
// NOTE: Stremable HTTP Stransport では ユーザーの LANG 設定に応じて i18next の lang 設定を変える
|
|
11
|
+
server.registerPrompt(
|
|
12
|
+
"esa_summarize_post",
|
|
13
|
+
{
|
|
14
|
+
title: t("prompts.summarize_post.title"),
|
|
15
|
+
description: t("prompts.summarize_post.description"),
|
|
16
|
+
argsSchema: createSummarizePostSchema().shape,
|
|
17
|
+
},
|
|
18
|
+
async (params: z.infer<ReturnType<typeof createSummarizePostSchema>>) =>
|
|
19
|
+
withContext(context, summarizePost, params),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { createEsaClient } from "../api_client/index.js";
|
|
3
|
+
import { MissingTeamNameError } from "../errors/missing-team-name-error.js";
|
|
4
|
+
import {
|
|
5
|
+
formatPromptError,
|
|
6
|
+
formatPromptResponse,
|
|
7
|
+
} from "../formatters/mcp-response.js";
|
|
8
|
+
import type { components } from "../generated/api-types.js";
|
|
9
|
+
import { t } from "../i18n/index.js";
|
|
10
|
+
|
|
11
|
+
// 多言語化対応のために 関数化してスキーマを遅延定義している
|
|
12
|
+
export const createSummarizePostSchema = () =>
|
|
13
|
+
z.object({
|
|
14
|
+
teamName: z.string().describe(t("prompts.summarize_post.args.team_name")),
|
|
15
|
+
postNumber: z
|
|
16
|
+
.string()
|
|
17
|
+
.describe(t("prompts.summarize_post.args.post_number")),
|
|
18
|
+
format: z
|
|
19
|
+
.enum(["bullet", "paragraph", "keywords"])
|
|
20
|
+
.optional()
|
|
21
|
+
.describe(t("prompts.summarize_post.args.format")),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export async function summarizePost(
|
|
25
|
+
client: ReturnType<typeof createEsaClient>,
|
|
26
|
+
args: z.infer<ReturnType<typeof createSummarizePostSchema>>,
|
|
27
|
+
) {
|
|
28
|
+
const { teamName, postNumber: postNumberStr, format = "bullet" } = args;
|
|
29
|
+
|
|
30
|
+
if (!teamName) {
|
|
31
|
+
throw new MissingTeamNameError();
|
|
32
|
+
}
|
|
33
|
+
const postNumber = Number.parseInt(postNumberStr, 10);
|
|
34
|
+
|
|
35
|
+
if (Number.isNaN(postNumber) || postNumber <= 0) {
|
|
36
|
+
return formatPromptError("Post number must be a positive integer");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const { data, error, response } = await client.GET(
|
|
41
|
+
"/v1/teams/{team_name}/posts/{post_number}",
|
|
42
|
+
{
|
|
43
|
+
params: {
|
|
44
|
+
path: { team_name: teamName, post_number: postNumber },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (error || !response.ok) {
|
|
50
|
+
return formatPromptError(error || response.status);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const post: components["schemas"]["Post"] = data;
|
|
54
|
+
|
|
55
|
+
let prompt = `Please summarize the following post:\n\n`;
|
|
56
|
+
prompt += `Title: ${post.name}\n`;
|
|
57
|
+
prompt += `URL: ${post.url}\n`;
|
|
58
|
+
prompt += `Author: ${post.created_by.name}\n`;
|
|
59
|
+
prompt += `Created: ${post.created_at}\n`;
|
|
60
|
+
prompt += `Updated: ${post.updated_at}\n`;
|
|
61
|
+
|
|
62
|
+
if (post.category) {
|
|
63
|
+
prompt += `Category: ${post.category}\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (post.tags && post.tags.length > 0) {
|
|
67
|
+
prompt += `Tags: ${post.tags.join(", ")}\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
prompt += "\n---\n\n";
|
|
71
|
+
|
|
72
|
+
if (post.body_md) {
|
|
73
|
+
prompt += `Content:\n${post.body_md}\n\n---\n\n`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
switch (format) {
|
|
77
|
+
case "bullet":
|
|
78
|
+
prompt +=
|
|
79
|
+
"Please provide a summary in bullet points (3-5 main points).";
|
|
80
|
+
break;
|
|
81
|
+
case "paragraph":
|
|
82
|
+
prompt += "Please provide a summary in 2-3 paragraphs.";
|
|
83
|
+
break;
|
|
84
|
+
case "keywords":
|
|
85
|
+
prompt +=
|
|
86
|
+
"Please extract and list 10-15 important keywords from this post.";
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return formatPromptResponse(prompt);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return formatPromptError(error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { MCPContext } from "../../context/mcp-context.js";
|
|
4
|
+
import { setupResources } from "../index.js";
|
|
5
|
+
|
|
6
|
+
describe("setupResources", () => {
|
|
7
|
+
let server: McpServer;
|
|
8
|
+
let context: MCPContext;
|
|
9
|
+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
server = new McpServer({
|
|
13
|
+
name: "test-server",
|
|
14
|
+
version: "1.0.0",
|
|
15
|
+
});
|
|
16
|
+
context = {} as unknown as MCPContext;
|
|
17
|
+
|
|
18
|
+
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
consoleErrorSpy.mockRestore();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should register all resources with correct handlers", () => {
|
|
27
|
+
const registerResourceSpy = vi.spyOn(server, "registerResource");
|
|
28
|
+
|
|
29
|
+
setupResources(server, context);
|
|
30
|
+
|
|
31
|
+
expect(registerResourceSpy).toHaveBeenCalledTimes(1);
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < 1; i++) {
|
|
34
|
+
const [resourceName, template, metadata, handler] =
|
|
35
|
+
registerResourceSpy.mock.calls[i];
|
|
36
|
+
expect(typeof resourceName).toBe("string");
|
|
37
|
+
expect(resourceName).toMatch(/^esa_/); // All resources should start with 'esa_'
|
|
38
|
+
expect(template).toBeTypeOf("object"); // Template verification handled by specific resource tests
|
|
39
|
+
expect(metadata).toBeTypeOf("object"); // Metadata verification handled by specific resource tests
|
|
40
|
+
expect(handler).toBeTypeOf("function"); // Handler functionality tested in specific resource tests
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should log setup completion message", () => {
|
|
45
|
+
setupResources(server, context);
|
|
46
|
+
|
|
47
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith("Setting up MCP resources...");
|
|
48
|
+
});
|
|
49
|
+
});
|