@rubyjobs-jp/tanebi-mcp-server 1.0.2

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.
@@ -0,0 +1,26 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: 20
18
+ registry-url: https://registry.npmjs.org
19
+
20
+ - run: npm ci
21
+
22
+ - run: npm run build
23
+
24
+ - run: npm publish --access public
25
+ env:
26
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # Tanebi MCP Server
2
+
3
+ Tanebi API にアクセスするための [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) サーバー。
4
+ Claude Code などの AI ツールから企画の一覧取得・詳細取得・新規作成が可能。
5
+
6
+ ## セットアップ
7
+
8
+ ```bash
9
+ cd mcp-server
10
+ npm install
11
+ npm run build
12
+ ```
13
+
14
+ ## 環境変数
15
+
16
+ | 変数名 | 必須 | 説明 |
17
+ |--------|------|------|
18
+ | `TANEBI_API_KEY` | Yes | Tanebi iOS アプリの設定画面から発行した API Key |
19
+ | `TANEBI_API_BASE_URL` | No | API のベース URL(デフォルト: `http://localhost:3000`) |
20
+
21
+ ## 提供ツール
22
+
23
+ ### `list_ideas` - 企画一覧取得
24
+
25
+ 公開されている企画の一覧を取得する。
26
+
27
+ | パラメータ | 型 | 必須 | デフォルト | 説明 |
28
+ |-----------|------|------|-----------|------|
29
+ | `page` | number | No | 1 | ページ番号 |
30
+ | `per_page` | number | No | 20 | 1ページあたりの件数(最大100) |
31
+
32
+ ### `get_idea` - 企画詳細取得
33
+
34
+ 指定した企画の詳細情報(本文ライン・リアクション含む)を取得する。
35
+
36
+ | パラメータ | 型 | 必須 | 説明 |
37
+ |-----------|------|------|------|
38
+ | `idea_id` | number | Yes | 企画の ID |
39
+
40
+ ### `create_idea` - 企画新規作成
41
+
42
+ 新しい企画を作成する。`content` は空行区切りで段落に分割され、`#` で始まる段落は見出しになる。
43
+
44
+ | パラメータ | 型 | 必須 | デフォルト | 説明 |
45
+ |-----------|------|------|-----------|------|
46
+ | `title` | string | Yes | - | 企画タイトル |
47
+ | `content` | string | No | - | 企画の本文 |
48
+ | `visibility` | `"public"` \| `"private"` | No | `"public"` | 公開範囲 |
49
+
50
+ ## Claude Code での設定
51
+
52
+ `~/.claude.json` または `.claude/settings.json` に追加:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "tanebi": {
58
+ "command": "node",
59
+ "args": ["/absolute/path/to/mcp-server/dist/index.js"],
60
+ "env": {
61
+ "TANEBI_API_KEY": "your-api-key"
62
+ }
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ 開発時は `tsx` で直接実行も可能:
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "tanebi": {
74
+ "command": "npx",
75
+ "args": ["tsx", "/absolute/path/to/mcp-server/src/index.ts"],
76
+ "env": {
77
+ "TANEBI_API_KEY": "your-api-key"
78
+ }
79
+ }
80
+ }
81
+ }
82
+ ```
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,148 @@
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 { z } from "zod";
5
+ // --- Configuration ---
6
+ const API_KEY = process.env.TANEBI_API_KEY;
7
+ const API_BASE_URL = process.env.TANEBI_API_BASE_URL?.replace(/\/+$/, "") ||
8
+ "http://localhost:3000";
9
+ if (!API_KEY) {
10
+ console.error("Error: TANEBI_API_KEY environment variable is required.\n" +
11
+ "Generate an API key from the Tanebi iOS app settings.");
12
+ process.exit(1);
13
+ }
14
+ async function apiRequest(path, options = {}) {
15
+ const { method = "GET", body } = options;
16
+ const headers = {
17
+ "X-API-Key": API_KEY,
18
+ Accept: "application/json",
19
+ };
20
+ if (body) {
21
+ headers["Content-Type"] = "application/json";
22
+ }
23
+ const response = await fetch(`${API_BASE_URL}${path}`, {
24
+ method,
25
+ headers,
26
+ body: body ? JSON.stringify(body) : undefined,
27
+ });
28
+ if (!response.ok) {
29
+ const errorBody = await response.text();
30
+ throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorBody}`);
31
+ }
32
+ return response.json();
33
+ }
34
+ // --- Formatters ---
35
+ function formatIdeaSummary(idea) {
36
+ return [
37
+ `[ID: ${idea.id}] ${idea.title}`,
38
+ ` Stage: ${idea.current_stage} | Visibility: ${idea.visibility}`,
39
+ ` Author: ${idea.user.display_name}`,
40
+ ` Reactions: ${idea.reactions_count} | Comments: ${idea.comments_count}`,
41
+ ` Created: ${idea.created_at}`,
42
+ ].join("\n");
43
+ }
44
+ function formatIdeaDetail(idea) {
45
+ const header = [
46
+ `# ${idea.title}`,
47
+ "",
48
+ `ID: ${idea.id}`,
49
+ `Stage: ${idea.current_stage} | Visibility: ${idea.visibility}`,
50
+ `Author: ${idea.user.display_name}`,
51
+ `Reactions: ${idea.reactions_count}`,
52
+ `Created: ${idea.created_at} | Updated: ${idea.updated_at}`,
53
+ ].join("\n");
54
+ const content = idea.lines.length > 0
55
+ ? "\n\n## Content\n\n" +
56
+ idea.lines
57
+ .sort((a, b) => a.position - b.position)
58
+ .map((line) => {
59
+ const prefix = line.line_type === "heading" ? "### " : "";
60
+ const commentTag = line.comments_count > 0 ? ` [${line.comments_count} comments]` : "";
61
+ return `${prefix}${line.content}${commentTag}`;
62
+ })
63
+ .join("\n\n")
64
+ : "\n\n(No content yet)";
65
+ const reactions = idea.reactions.length > 0
66
+ ? "\n\n## Reactions\n\n" +
67
+ idea.reactions
68
+ .map((r) => `- ${r.reaction_type} by ${r.user.display_name}`)
69
+ .join("\n")
70
+ : "";
71
+ return header + content + reactions;
72
+ }
73
+ // --- MCP Server ---
74
+ const server = new McpServer({
75
+ name: "tanebi",
76
+ version: "1.0.0",
77
+ });
78
+ // Tool: list_ideas
79
+ server.tool("list_ideas", "List ideas from Tanebi. Returns summaries with title, stage, author, and counts.", {
80
+ page: z
81
+ .number()
82
+ .int()
83
+ .positive()
84
+ .default(1)
85
+ .describe("Page number (default: 1)"),
86
+ per_page: z
87
+ .number()
88
+ .int()
89
+ .positive()
90
+ .max(100)
91
+ .default(20)
92
+ .describe("Items per page (default: 20, max: 100)"),
93
+ }, async ({ page, per_page }) => {
94
+ const data = await apiRequest(`/api/v1/ideas?page=${page}&per_page=${per_page}`);
95
+ const summaries = data.ideas.map(formatIdeaSummary).join("\n\n");
96
+ const pagination = `\nPage ${data.meta.current_page} of ${data.meta.total_pages} (${data.meta.total_count} total ideas)`;
97
+ return {
98
+ content: [{ type: "text", text: summaries + "\n" + pagination }],
99
+ };
100
+ });
101
+ // Tool: get_idea
102
+ server.tool("get_idea", "Get detailed information about a specific idea, including its content lines and reactions.", {
103
+ idea_id: z.number().int().positive().describe("The ID of the idea to retrieve"),
104
+ }, async ({ idea_id }) => {
105
+ const data = await apiRequest(`/api/v1/ideas/${idea_id}`);
106
+ // The API may return the idea directly or wrapped in { idea: ... }
107
+ const idea = "idea" in data ? data.idea : data;
108
+ return {
109
+ content: [{ type: "text", text: formatIdeaDetail(idea) }],
110
+ };
111
+ });
112
+ // Tool: create_idea
113
+ server.tool("create_idea", "Create a new idea on Tanebi. Content is split into paragraphs (separated by blank lines) and stored as lines.", {
114
+ title: z.string().min(1).describe("Title of the idea"),
115
+ content: z
116
+ .string()
117
+ .optional()
118
+ .describe("Content of the idea. Paragraphs are separated by blank lines. Lines starting with # become headings."),
119
+ visibility: z
120
+ .enum(["public", "private"])
121
+ .default("public")
122
+ .describe('Visibility: "public" or "private" (default: "public")'),
123
+ }, async ({ title, content, visibility }) => {
124
+ const body = { title, visibility };
125
+ if (content) {
126
+ body.content = content;
127
+ }
128
+ const data = await apiRequest("/api/v1/ideas", { method: "POST", body });
129
+ const idea = "idea" in data ? data.idea : data;
130
+ return {
131
+ content: [
132
+ {
133
+ type: "text",
134
+ text: `Idea created successfully!\n\n${formatIdeaDetail(idea)}`,
135
+ },
136
+ ],
137
+ };
138
+ });
139
+ // --- Start Server ---
140
+ async function main() {
141
+ const transport = new StdioServerTransport();
142
+ await server.connect(transport);
143
+ }
144
+ main().catch((error) => {
145
+ console.error("Failed to start MCP server:", error);
146
+ process.exit(1);
147
+ });
148
+ //# sourceMappingURL=index.js.map
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@rubyjobs-jp/tanebi-mcp-server",
3
+ "version": "1.0.2",
4
+ "description": "MCP server for Tanebi API - list, get, and create ideas",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "tanebi-mcp-server": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/index.js",
13
+ "dev": "tsx src/index.ts"
14
+ },
15
+ "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.0.0",
17
+ "zod": "^3.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.0.0",
21
+ "tsx": "^4.0.0",
22
+ "typescript": "^5.7.0"
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+
7
+ // --- Configuration ---
8
+
9
+ const API_KEY = process.env.TANEBI_API_KEY;
10
+ const API_BASE_URL =
11
+ process.env.TANEBI_API_BASE_URL?.replace(/\/+$/, "") ||
12
+ "http://localhost:3000";
13
+
14
+ if (!API_KEY) {
15
+ console.error(
16
+ "Error: TANEBI_API_KEY environment variable is required.\n" +
17
+ "Generate an API key from the Tanebi iOS app settings."
18
+ );
19
+ process.exit(1);
20
+ }
21
+
22
+ // --- API Client ---
23
+
24
+ interface ApiRequestOptions {
25
+ method?: string;
26
+ body?: Record<string, unknown>;
27
+ }
28
+
29
+ async function apiRequest<T>(
30
+ path: string,
31
+ options: ApiRequestOptions = {}
32
+ ): Promise<T> {
33
+ const { method = "GET", body } = options;
34
+
35
+ const headers: Record<string, string> = {
36
+ "X-API-Key": API_KEY!,
37
+ Accept: "application/json",
38
+ };
39
+
40
+ if (body) {
41
+ headers["Content-Type"] = "application/json";
42
+ }
43
+
44
+ const response = await fetch(`${API_BASE_URL}${path}`, {
45
+ method,
46
+ headers,
47
+ body: body ? JSON.stringify(body) : undefined,
48
+ });
49
+
50
+ if (!response.ok) {
51
+ const errorBody = await response.text();
52
+ throw new Error(
53
+ `API request failed: ${response.status} ${response.statusText} - ${errorBody}`
54
+ );
55
+ }
56
+
57
+ return response.json() as Promise<T>;
58
+ }
59
+
60
+ // --- Response Types ---
61
+
62
+ interface SimplifiedUser {
63
+ id: number;
64
+ display_name: string;
65
+ avatar_path: string | null;
66
+ }
67
+
68
+ interface IdeaSummary {
69
+ id: number;
70
+ title: string;
71
+ visibility: string;
72
+ current_stage: string;
73
+ reactions_count: number;
74
+ comments_count: number;
75
+ is_bookmarked: boolean;
76
+ created_at: string;
77
+ updated_at: string;
78
+ user: SimplifiedUser;
79
+ }
80
+
81
+ interface IdeaLine {
82
+ id: number;
83
+ line_type: string;
84
+ content: string;
85
+ position: number;
86
+ comments_count: number;
87
+ created_at: string;
88
+ updated_at: string;
89
+ }
90
+
91
+ interface Reaction {
92
+ id: number;
93
+ reaction_type: string;
94
+ created_at: string;
95
+ user: SimplifiedUser;
96
+ }
97
+
98
+ interface IdeaDetail {
99
+ id: number;
100
+ title: string;
101
+ visibility: string;
102
+ current_stage: string;
103
+ reactions_count: number;
104
+ is_bookmarked: boolean;
105
+ created_at: string;
106
+ updated_at: string;
107
+ user: SimplifiedUser;
108
+ lines: IdeaLine[];
109
+ reactions: Reaction[];
110
+ }
111
+
112
+ interface PaginationMeta {
113
+ current_page: number;
114
+ total_pages: number;
115
+ total_count: number;
116
+ }
117
+
118
+ interface IdeasListResponse {
119
+ ideas: IdeaSummary[];
120
+ meta: PaginationMeta;
121
+ }
122
+
123
+ // --- Formatters ---
124
+
125
+ function formatIdeaSummary(idea: IdeaSummary): string {
126
+ return [
127
+ `[ID: ${idea.id}] ${idea.title}`,
128
+ ` Stage: ${idea.current_stage} | Visibility: ${idea.visibility}`,
129
+ ` Author: ${idea.user.display_name}`,
130
+ ` Reactions: ${idea.reactions_count} | Comments: ${idea.comments_count}`,
131
+ ` Created: ${idea.created_at}`,
132
+ ].join("\n");
133
+ }
134
+
135
+ function formatIdeaDetail(idea: IdeaDetail): string {
136
+ const header = [
137
+ `# ${idea.title}`,
138
+ "",
139
+ `ID: ${idea.id}`,
140
+ `Stage: ${idea.current_stage} | Visibility: ${idea.visibility}`,
141
+ `Author: ${idea.user.display_name}`,
142
+ `Reactions: ${idea.reactions_count}`,
143
+ `Created: ${idea.created_at} | Updated: ${idea.updated_at}`,
144
+ ].join("\n");
145
+
146
+ const content =
147
+ idea.lines.length > 0
148
+ ? "\n\n## Content\n\n" +
149
+ idea.lines
150
+ .sort((a, b) => a.position - b.position)
151
+ .map((line) => {
152
+ const prefix = line.line_type === "heading" ? "### " : "";
153
+ const commentTag = line.comments_count > 0 ? ` [${line.comments_count} comments]` : "";
154
+ return `${prefix}${line.content}${commentTag}`;
155
+ })
156
+ .join("\n\n")
157
+ : "\n\n(No content yet)";
158
+
159
+ const reactions =
160
+ idea.reactions.length > 0
161
+ ? "\n\n## Reactions\n\n" +
162
+ idea.reactions
163
+ .map((r) => `- ${r.reaction_type} by ${r.user.display_name}`)
164
+ .join("\n")
165
+ : "";
166
+
167
+ return header + content + reactions;
168
+ }
169
+
170
+ // --- MCP Server ---
171
+
172
+ const server = new McpServer({
173
+ name: "tanebi",
174
+ version: "1.0.0",
175
+ });
176
+
177
+ // Tool: list_ideas
178
+ server.tool(
179
+ "list_ideas",
180
+ "List ideas from Tanebi. Returns summaries with title, stage, author, and counts.",
181
+ {
182
+ page: z
183
+ .number()
184
+ .int()
185
+ .positive()
186
+ .default(1)
187
+ .describe("Page number (default: 1)"),
188
+ per_page: z
189
+ .number()
190
+ .int()
191
+ .positive()
192
+ .max(100)
193
+ .default(20)
194
+ .describe("Items per page (default: 20, max: 100)"),
195
+ },
196
+ async ({ page, per_page }) => {
197
+ const data = await apiRequest<IdeasListResponse>(
198
+ `/api/v1/ideas?page=${page}&per_page=${per_page}`
199
+ );
200
+
201
+ const summaries = data.ideas.map(formatIdeaSummary).join("\n\n");
202
+ const pagination = `\nPage ${data.meta.current_page} of ${data.meta.total_pages} (${data.meta.total_count} total ideas)`;
203
+
204
+ return {
205
+ content: [{ type: "text" as const, text: summaries + "\n" + pagination }],
206
+ };
207
+ }
208
+ );
209
+
210
+ // Tool: get_idea
211
+ server.tool(
212
+ "get_idea",
213
+ "Get detailed information about a specific idea, including its content lines and reactions.",
214
+ {
215
+ idea_id: z.number().int().positive().describe("The ID of the idea to retrieve"),
216
+ },
217
+ async ({ idea_id }) => {
218
+ const data = await apiRequest<{ idea: IdeaDetail }>(
219
+ `/api/v1/ideas/${idea_id}`
220
+ );
221
+
222
+ // The API may return the idea directly or wrapped in { idea: ... }
223
+ const idea = "idea" in data ? data.idea : (data as unknown as IdeaDetail);
224
+
225
+ return {
226
+ content: [{ type: "text" as const, text: formatIdeaDetail(idea) }],
227
+ };
228
+ }
229
+ );
230
+
231
+ // Tool: create_idea
232
+ server.tool(
233
+ "create_idea",
234
+ "Create a new idea on Tanebi. Content is split into paragraphs (separated by blank lines) and stored as lines.",
235
+ {
236
+ title: z.string().min(1).describe("Title of the idea"),
237
+ content: z
238
+ .string()
239
+ .optional()
240
+ .describe(
241
+ "Content of the idea. Paragraphs are separated by blank lines. Lines starting with # become headings."
242
+ ),
243
+ visibility: z
244
+ .enum(["public", "private"])
245
+ .default("public")
246
+ .describe('Visibility: "public" or "private" (default: "public")'),
247
+ },
248
+ async ({ title, content, visibility }) => {
249
+ const body: Record<string, unknown> = { title, visibility };
250
+ if (content) {
251
+ body.content = content;
252
+ }
253
+
254
+ const data = await apiRequest<{ idea: IdeaDetail } | IdeaDetail>(
255
+ "/api/v1/ideas",
256
+ { method: "POST", body }
257
+ );
258
+
259
+ const idea = "idea" in data ? data.idea : data;
260
+
261
+ return {
262
+ content: [
263
+ {
264
+ type: "text" as const,
265
+ text: `Idea created successfully!\n\n${formatIdeaDetail(idea)}`,
266
+ },
267
+ ],
268
+ };
269
+ }
270
+ );
271
+
272
+ // --- Start Server ---
273
+
274
+ async function main() {
275
+ const transport = new StdioServerTransport();
276
+ await server.connect(transport);
277
+ }
278
+
279
+ main().catch((error) => {
280
+ console.error("Failed to start MCP server:", error);
281
+ process.exit(1);
282
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true,
13
+ "sourceMap": true
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }