# Directory Structure
```
├── .clinerules
├── .gitignore
├── assets
│ ├── header.svg
│ └── release-v0.2.0.svg
├── docs
│ └── v0.2.0
│ └── RELEASE.md
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── handlers
│ │ ├── comment-handlers.ts
│ │ ├── index.ts
│ │ ├── issue-handlers.ts
│ │ ├── label-handlers.ts
│ │ └── tool-handlers.ts
│ ├── index.ts
│ ├── schemas
│ │ ├── comment-schemas.ts
│ │ ├── index.ts
│ │ └── issue-schemas.ts
│ ├── server.ts
│ ├── types.ts
│ └── utils
│ ├── error-handler.ts
│ ├── exec.ts
│ └── repo-info.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
node_modules/
build/
.env
.env.local
.DS_Store
coverage/
*.log
.vscode/
tmp
tasks/
.codegpt
```
--------------------------------------------------------------------------------
/src/schemas/index.ts:
--------------------------------------------------------------------------------
```typescript
export * from './issue-schemas.js';
export * from './comment-schemas.js';
```
--------------------------------------------------------------------------------
/src/handlers/index.ts:
--------------------------------------------------------------------------------
```typescript
export * from './issue-handlers.js';
export * from './label-handlers.js';
export * from './comment-handlers.js';
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { KanbanServer } from './server.js';
const server = new KanbanServer();
server.run().catch(console.error);
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@sunwood-ai-labs/github-kanban-mcp-server",
"version": "0.2.0",
"description": "A Model Context Protocol server for managing GitHub issues as Kanban using gh CLI",
"main": "build/index.js",
"type": "module",
"bin": {
"github-kanban-mcp-server": "build/index.js"
},
"scripts": {
"build": "tsc",
"start": "node build/index.js",
"dev": "node --loader ts-node/esm src/index.ts",
"test": "jest",
"prepare": "npm run build"
},
"keywords": [
"mcp",
"github",
"kanban",
"issues",
"llm",
"gh-cli"
],
"author": "Sunwood AI Labs",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/jest": "^29.5.5",
"@types/node": "^20.8.2",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
},
"files": [
"build",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/sunwood-ai-labs/github-kanban-mcp-server.git"
},
"bugs": {
"url": "https://github.com/sunwood-ai-labs/github-kanban-mcp-server/issues"
},
"homepage": "https://github.com/sunwood-ai-labs/github-kanban-mcp-server#readme"
}
```
--------------------------------------------------------------------------------
/src/handlers/label-handlers.ts:
--------------------------------------------------------------------------------
```typescript
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { execAsync } from '../utils/exec.js';
import { getRepoInfoFromGitConfig } from '../utils/repo-info.js';
/**
* ランダムな16進数カラーコードを生成する
*/
export function generateRandomColor(): string {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
/**
* リポジトリ内の既存のラベルを取得する
*/
export async function getExistingLabels(path: string): Promise<string[]> {
try {
const { owner, repo } = await getRepoInfoFromGitConfig(path);
const { stdout } = await execAsync(
`gh label list --repo ${owner}/${repo} --json name --jq '.[].name'`
);
return stdout.trim().split('\n').filter(Boolean);
} catch (error) {
console.error('Failed to get labels:', error);
return [];
}
}
/**
* 新しいラベルを作成する
*/
export async function createLabel(path: string, name: string): Promise<void> {
const color = generateRandomColor().substring(1); // '#'を除去
const { owner, repo } = await getRepoInfoFromGitConfig(path);
try {
await execAsync(
`gh label create "${name}" --repo ${owner}/${repo} --color "${color}" --force`
);
} catch (error) {
console.error(`Failed to create label ${name}:`, error);
throw new McpError(
ErrorCode.InternalError,
`Failed to create label ${name}: ${(error as Error).message}`
);
}
}
```
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
```typescript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import {
listIssuesSchema,
createIssueSchema,
updateIssueSchema,
addCommentSchema,
} from './schemas/index.js';
import { handleToolRequest } from './handlers/tool-handlers.js';
import { handleServerError, handleProcessTermination } from './utils/error-handler.js';
export class KanbanServer {
private server: Server;
constructor() {
this.server = new Server(
{
name: 'github-kanban-mcp-server',
version: '0.2.0',
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
this.server.onerror = handleServerError;
handleProcessTermination(this.server);
}
private setupToolHandlers(): void {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'list_issues',
description: 'カンバンボードのissue一覧を取得します',
inputSchema: listIssuesSchema,
},
{
name: 'create_issue',
description: '新しいissueを作成します',
inputSchema: createIssueSchema,
},
{
name: 'update_issue',
description: '既存のissueを更新します',
inputSchema: updateIssueSchema,
},
{
name: 'add_comment',
description: 'タスクにコメントを追加',
inputSchema: addCommentSchema,
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, handleToolRequest);
}
public async run(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('GitHub Kanban MCP server running on stdio');
}
}
```
--------------------------------------------------------------------------------
/src/handlers/comment-handlers.ts:
--------------------------------------------------------------------------------
```typescript
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { execAsync, writeToTempFile, removeTempFile } from '../utils/exec.js';
import { ToolResponse } from '../types.js';
/**
* Issueにコメントを追加する
*/
export async function handleAddComment(args: {
repo: string;
issue_number: string;
body: string;
state?: 'open' | 'closed';
}): Promise<ToolResponse> {
const tempFile = 'comment_body.md';
try {
// ステータスの変更が指定されている場合は先に処理
if (args.state) {
try {
const command = args.state === 'closed' ? 'close' : 'reopen';
await execAsync(
`gh issue ${command} ${args.issue_number} --repo ${args.repo}`
);
console.log(`Issue status changed to ${args.state}`);
} catch (error) {
console.error('Failed to change issue status:', error);
throw new McpError(
ErrorCode.InternalError,
`Failed to change issue status: ${(error as Error).message}`
);
}
}
// コメントを追加
const fullPath = await writeToTempFile(args.body, tempFile);
try {
await execAsync(
`gh issue comment ${args.issue_number} --repo ${args.repo} --body-file "${fullPath}"`
);
} catch (error) {
console.error('Failed to add comment:', error);
throw new McpError(
ErrorCode.InternalError,
`Failed to add comment: ${(error as Error).message}`
);
}
// 更新後のissue情報を取得して返却
try {
const { stdout: issueData } = await execAsync(
`gh issue view ${args.issue_number} --repo ${args.repo} --json number,title,state,url`
);
return {
content: [
{
type: 'text',
text: issueData,
},
],
};
} catch (error) {
console.error('Failed to get issue data:', error);
throw new McpError(
ErrorCode.InternalError,
`Failed to get issue data: ${(error as Error).message}`
);
}
} finally {
await removeTempFile(tempFile);
}
}
```
--------------------------------------------------------------------------------
/src/handlers/tool-handlers.ts:
--------------------------------------------------------------------------------
```typescript
import { CallToolRequest, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { ToolResponse, RepoArgs } from '../types.js';
import {
handleListIssues,
handleCreateIssue,
handleUpdateIssue,
handleAddComment,
} from './index.js';
import { getRepoInfoFromGitConfig, validateRepoInfo } from '../utils/repo-info.js';
export async function handleToolRequest(request: CallToolRequest): Promise<ToolResponse> {
try {
const args = request.params.arguments as Record<string, unknown> & RepoArgs;
// pathパラメータの確認
if (!args.path) {
throw new McpError(
ErrorCode.InvalidParams,
'リポジトリのパスを指定してください。引数で "path": "/path/to/repo" の形式で指定してください。'
);
}
// リポジトリ情報の取得
let repoInfo: { owner: string; repo: string };
try {
repoInfo = await getRepoInfoFromGitConfig(args.path as string);
} catch (error) {
// Gitリポジトリ関連のエラーの場合は、より具体的なエラーメッセージを表示
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InvalidParams,
`指定されたパスのリポジトリ情報の取得に失敗しました: ${(error as Error).message}`
);
}
const fullRepo = `${repoInfo.owner}/${repoInfo.repo}`;
switch (request.params.name) {
case 'list_issues':
return await handleListIssues({
path: args.path as string,
state: args?.state as 'open' | 'closed' | 'all',
labels: args?.labels as string[],
});
case 'create_issue': {
if (!args?.title) {
throw new McpError(ErrorCode.InvalidParams, 'Title is required');
}
return await handleCreateIssue({
path: args.path as string,
title: args.title as string,
emoji: args?.emoji as string | undefined,
body: args?.body as string | undefined,
labels: args?.labels as string[] | undefined,
assignees: args?.assignees as string[] | undefined,
});
}
case 'update_issue': {
if (!args?.issue_number) {
throw new McpError(ErrorCode.InvalidParams, 'Issue number is required');
}
return await handleUpdateIssue({
path: args.path as string,
issue_number: Number(args.issue_number),
title: args?.title as string | undefined,
emoji: args?.emoji as string | undefined,
body: args?.body as string | undefined,
state: args?.state as 'open' | 'closed' | undefined,
labels: args?.labels as string[] | undefined,
assignees: args?.assignees as string[] | undefined,
});
}
case 'add_comment': {
if (!args?.issue_number || !args?.body) {
throw new McpError(ErrorCode.InvalidParams, 'Issue number and body are required');
}
return await handleAddComment({
repo: fullRepo,
issue_number: args.issue_number as string,
body: args.body as string,
});
}
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
} catch (error) {
if (error instanceof McpError) throw error;
throw new McpError(
ErrorCode.InternalError,
`GitHub API error: ${(error as Error).message}`
);
}
}
```
--------------------------------------------------------------------------------
/assets/release-v0.2.0.svg:
--------------------------------------------------------------------------------
```
<?xml version="1.0" encoding="UTF-8"?>
<svg width="800" height="200" viewBox="0 0 800 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- メインの背景グラデーション -->
<linearGradient id="headerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1">
<animate attributeName="stop-color"
values="#3b82f6; #6366f1; #8b5cf6; #3b82f6"
dur="10s"
repeatCount="indefinite" />
</stop>
<stop offset="50%" style="stop-color:#6366f1;stop-opacity:1">
<animate attributeName="stop-color"
values="#6366f1; #8b5cf6; #3b82f6; #6366f1"
dur="10s"
repeatCount="indefinite" />
</stop>
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1">
<animate attributeName="stop-color"
values="#8b5cf6; #3b82f6; #6366f1; #8b5cf6"
dur="10s"
repeatCount="indefinite" />
</stop>
</linearGradient>
<!-- テキストグラデーション -->
<linearGradient id="textGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1">
<animate attributeName="stop-color"
values="#ffffff; #ffd700; #ffffff"
dur="3s"
repeatCount="indefinite" />
</stop>
<stop offset="100%" style="stop-color:#ffd700;stop-opacity:1">
<animate attributeName="stop-color"
values="#ffd700; #ffffff; #ffd700"
dur="3s"
repeatCount="indefinite" />
</stop>
</linearGradient>
<!-- 装飾用のパターン -->
<pattern id="dots" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1" fill="#fff" opacity="0.2">
<animate attributeName="opacity"
values="0.2;0.5;0.2"
dur="3s"
repeatCount="indefinite" />
</circle>
</pattern>
<style>
@keyframes floatIn {
0% {
transform: translateY(20px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes glowPulse {
0% { filter: drop-shadow(0 0 2px rgba(255,255,255,0.3)); }
50% { filter: drop-shadow(0 0 10px rgba(255,255,255,0.5)); }
100% { filter: drop-shadow(0 0 2px rgba(255,255,255,0.3)); }
}
@keyframes rotateGlow {
0% { transform: rotate(0deg) scale(1); opacity: 0.8; }
50% { transform: rotate(180deg) scale(1.1); opacity: 1; }
100% { transform: rotate(360deg) scale(1); opacity: 0.8; }
}
.version-text {
animation: floatIn 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards,
glowPulse 3s ease-in-out infinite;
}
.date-text {
animation: floatIn 1.2s cubic-bezier(0.4, 0, 0.2, 1) 0.3s forwards;
}
.decorative-circle {
animation: rotateGlow 15s linear infinite;
}
</style>
</defs>
<!-- 背景 -->
<rect x="10" y="10" width="780" height="180" rx="30" ry="30"
fill="url(#headerGradient)"
filter="drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1))">
<animate attributeName="filter"
values="drop-shadow(0 4px 6px rgba(0,0,0,0.1));drop-shadow(0 6px 8px rgba(0,0,0,0.2));drop-shadow(0 4px 6px rgba(0,0,0,0.1))"
dur="3s"
repeatCount="indefinite" />
</rect>
<!-- 装飾パターン -->
<rect x="10" y="10" width="780" height="180" rx="30" ry="30"
fill="url(#dots)" />
<!-- 装飾的な円 -->
<circle cx="650" cy="100" r="60"
fill="none"
stroke="rgba(255,255,255,0.2)"
stroke-width="2"
class="decorative-circle" />
<!-- メインコンテンツ -->
<g transform="translate(400,85)" text-anchor="middle">
<text class="version-text"
fill="url(#textGradient)"
font-family="'Segoe UI', system-ui, sans-serif"
font-size="64"
font-weight="bold"
filter="drop-shadow(0 2px 4px rgba(0,0,0,0.2))">
Version 0.2.0
</text>
<text class="date-text"
y="50"
fill="#FFFFFF"
font-family="'Segoe UI', system-ui, sans-serif"
font-size="24"
opacity="0">
GitHub Kanban MCP Server Release Notes
<animate attributeName="fill-opacity"
values="0.7;1;0.7"
dur="3s"
repeatCount="indefinite" />
</text>
</g>
<!-- 装飾的なアイコン -->
<g transform="translate(80,100)">
<rect x="0" y="0" width="30" height="30" rx="8"
fill="#FFFFFF" opacity="0.9"
transform="rotate(-15)">
<animate attributeName="opacity"
values="0.9;0.7;0.9"
dur="2s"
repeatCount="indefinite" />
</rect>
<rect x="40" y="0" width="30" height="30" rx="8"
fill="#FFFFFF" opacity="0.7"
transform="rotate(15)">
<animate attributeName="opacity"
values="0.7;0.5;0.7"
dur="2s"
repeatCount="indefinite" />
</rect>
<rect x="80" y="0" width="30" height="30" rx="8"
fill="#FFFFFF" opacity="0.5"
transform="rotate(-15)">
<animate attributeName="opacity"
values="0.5;0.3;0.5"
dur="2s"
repeatCount="indefinite" />
</rect>
</g>
</svg>
```
--------------------------------------------------------------------------------
/assets/header.svg:
--------------------------------------------------------------------------------
```
<?xml version="1.0" encoding="UTF-8"?>
<svg width="800" height="200" viewBox="0 0 800 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- メインの背景グラデーション -->
<linearGradient id="headerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1">
<animate attributeName="stop-color"
values="#6366f1; #8b5cf6; #ec4899; #6366f1"
dur="10s"
repeatCount="indefinite" />
</stop>
<stop offset="50%" style="stop-color:#8b5cf6;stop-opacity:1">
<animate attributeName="stop-color"
values="#8b5cf6; #ec4899; #6366f1; #8b5cf6"
dur="10s"
repeatCount="indefinite" />
</stop>
<stop offset="100%" style="stop-color:#ec4899;stop-opacity:1">
<animate attributeName="stop-color"
values="#ec4899; #6366f1; #8b5cf6; #ec4899"
dur="10s"
repeatCount="indefinite" />
</stop>
</linearGradient>
<!-- テキストグラデーション -->
<linearGradient id="textGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1">
<animate attributeName="stop-color"
values="#ffffff; #ffd700; #ffffff"
dur="3s"
repeatCount="indefinite" />
</stop>
<stop offset="100%" style="stop-color:#ffd700;stop-opacity:1">
<animate attributeName="stop-color"
values="#ffd700; #ffffff; #ffd700"
dur="3s"
repeatCount="indefinite" />
</stop>
</linearGradient>
<!-- 装飾用のパターン -->
<pattern id="dots" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1" fill="#fff" opacity="0.2">
<animate attributeName="opacity"
values="0.2;0.5;0.2"
dur="3s"
repeatCount="indefinite" />
</circle>
</pattern>
<style>
@keyframes gradientFlow {
0% { stop-color: #6366f1; }
50% { stop-color: #8b5cf6; }
100% { stop-color: #ec4899; }
}
@keyframes floatIn {
0% {
transform: translateY(20px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes glowPulse {
0% { filter: drop-shadow(0 0 2px rgba(255,255,255,0.3)); }
50% { filter: drop-shadow(0 0 10px rgba(255,255,255,0.5)); }
100% { filter: drop-shadow(0 0 2px rgba(255,255,255,0.3)); }
}
@keyframes rotateGlow {
0% { transform: rotate(0deg) scale(1); opacity: 0.8; }
50% { transform: rotate(180deg) scale(1.1); opacity: 1; }
100% { transform: rotate(360deg) scale(1); opacity: 0.8; }
}
@keyframes dashFlow {
to {
stroke-dashoffset: 0;
}
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.title-text {
animation: floatIn 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards,
glowPulse 3s ease-in-out infinite;
}
.subtitle-text {
animation: floatIn 1.2s cubic-bezier(0.4, 0, 0.2, 1) 0.3s forwards;
}
.decorative-circle {
animation: rotateGlow 15s linear infinite;
}
.path-animation {
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
animation: dashFlow 2s ease-out forwards;
}
.shimmer {
animation: shimmer 3s linear infinite;
}
</style>
</defs>
<!-- 背景 -->
<rect x="10" y="10" width="780" height="180" rx="30" ry="30"
fill="url(#headerGradient)"
filter="drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1))">
<animate attributeName="filter"
values="drop-shadow(0 4px 6px rgba(0,0,0,0.1));drop-shadow(0 6px 8px rgba(0,0,0,0.2));drop-shadow(0 4px 6px rgba(0,0,0,0.1))"
dur="3s"
repeatCount="indefinite" />
</rect>
<!-- 装飾パターン -->
<rect x="10" y="10" width="780" height="180" rx="30" ry="30"
fill="url(#dots)" />
<!-- 装飾的な円 -->
<circle cx="650" cy="100" r="60"
fill="none"
stroke="rgba(255,255,255,0.2)"
stroke-width="2"
class="decorative-circle">
<animate attributeName="stroke-width"
values="2;4;2"
dur="3s"
repeatCount="indefinite" />
</circle>
<!-- 装飾的なライン -->
<path d="M50,100 C150,50 250,150 350,100"
stroke="rgba(255,255,255,0.3)"
stroke-width="2"
fill="none"
class="path-animation">
<animate attributeName="stroke-opacity"
values="0.3;0.6;0.3"
dur="3s"
repeatCount="indefinite" />
</path>
<!-- メインコンテンツ -->
<g transform="translate(400,85)" text-anchor="middle">
<text class="title-text"
fill="url(#textGradient)"
font-family="'Segoe UI', system-ui, sans-serif"
font-size="48"
font-weight="bold"
filter="drop-shadow(0 2px 4px rgba(0,0,0,0.2))">
GitHub Kanban MCP Server
</text>
<text class="subtitle-text"
y="40"
fill="#FFFFFF"
font-family="'Segoe UI', system-ui, sans-serif"
font-size="20"
opacity="0">
Streamline Your Task Management with LLM
<animate attributeName="fill-opacity"
values="0.7;1;0.7"
dur="3s"
repeatCount="indefinite" />
</text>
</g>
<!-- 装飾的なアイコン -->
<g transform="translate(80,100)" class="path-animation">
<rect x="0" y="0" width="30" height="30" rx="8"
fill="#FFFFFF" opacity="0.9"
transform="rotate(-15)">
<animate attributeName="opacity"
values="0.9;0.7;0.9"
dur="2s"
repeatCount="indefinite" />
</rect>
<rect x="40" y="0" width="30" height="30" rx="8"
fill="#FFFFFF" opacity="0.7"
transform="rotate(15)">
<animate attributeName="opacity"
values="0.7;0.5;0.7"
dur="2s"
repeatCount="indefinite" />
</rect>
<rect x="80" y="0" width="30" height="30" rx="8"
fill="#FFFFFF" opacity="0.5"
transform="rotate(-15)">
<animate attributeName="opacity"
values="0.5;0.3;0.5"
dur="2s"
repeatCount="indefinite" />
</rect>
</g>
</svg>
```