hanary-mcp 0.2.1__tar.gz → 0.8.0__tar.gz
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.
- {hanary_mcp-0.2.1 → hanary_mcp-0.8.0}/PKG-INFO +11 -11
- {hanary_mcp-0.2.1 → hanary_mcp-0.8.0}/README.md +10 -10
- {hanary_mcp-0.2.1 → hanary_mcp-0.8.0}/example.mcp.json +1 -1
- {hanary_mcp-0.2.1 → hanary_mcp-0.8.0}/pyproject.toml +1 -1
- hanary_mcp-0.8.0/src/hanary_mcp/client.py +211 -0
- hanary_mcp-0.8.0/src/hanary_mcp/server.py +464 -0
- hanary_mcp-0.2.1/src/hanary_mcp/client.py +0 -163
- hanary_mcp-0.2.1/src/hanary_mcp/server.py +0 -287
- {hanary_mcp-0.2.1 → hanary_mcp-0.8.0}/.gitignore +0 -0
- {hanary_mcp-0.2.1 → hanary_mcp-0.8.0}/src/hanary_mcp/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hanary-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Hanary MCP Server - Task management for Claude Code
|
|
5
5
|
Author: Hanary
|
|
6
6
|
License: MIT
|
|
@@ -19,13 +19,13 @@ Description-Content-Type: text/markdown
|
|
|
19
19
|
|
|
20
20
|
# Hanary MCP Server
|
|
21
21
|
|
|
22
|
-
[Hanary](https://hanary.org) MCP Server for Claude Code -
|
|
22
|
+
[Hanary](https://hanary.org) MCP Server for Claude Code - squad-bound task management.
|
|
23
23
|
|
|
24
24
|
## Installation
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
# Using uvx (recommended)
|
|
28
|
-
uvx hanary-mcp --
|
|
28
|
+
uvx hanary-mcp --squad my-project
|
|
29
29
|
|
|
30
30
|
# Or install globally
|
|
31
31
|
uv tool install hanary-mcp
|
|
@@ -42,7 +42,7 @@ Add to your project's `.mcp.json`:
|
|
|
42
42
|
"mcpServers": {
|
|
43
43
|
"hanary": {
|
|
44
44
|
"command": "uvx",
|
|
45
|
-
"args": ["hanary-mcp", "--
|
|
45
|
+
"args": ["hanary-mcp", "--squad", "your-squad-slug"],
|
|
46
46
|
"env": {
|
|
47
47
|
"HANARY_API_TOKEN": "${HANARY_API_TOKEN}"
|
|
48
48
|
}
|
|
@@ -54,7 +54,7 @@ Add to your project's `.mcp.json`:
|
|
|
54
54
|
Or add via CLI:
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
claude mcp add hanary --transport stdio -- uvx hanary-mcp --
|
|
57
|
+
claude mcp add hanary --transport stdio -- uvx hanary-mcp --squad your-squad-slug
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
### Environment Variables
|
|
@@ -68,7 +68,7 @@ claude mcp add hanary --transport stdio -- uvx hanary-mcp --workspace your-works
|
|
|
68
68
|
|
|
69
69
|
### Task Management
|
|
70
70
|
|
|
71
|
-
- `list_tasks` - List tasks in the
|
|
71
|
+
- `list_tasks` - List tasks in the squad
|
|
72
72
|
- `create_task` - Create a new task
|
|
73
73
|
- `update_task` - Update task title/description
|
|
74
74
|
- `complete_task` - Mark task as completed
|
|
@@ -76,14 +76,14 @@ claude mcp add hanary --transport stdio -- uvx hanary-mcp --workspace your-works
|
|
|
76
76
|
- `delete_task` - Soft delete a task
|
|
77
77
|
- `get_top_task` - Get highest priority incomplete task
|
|
78
78
|
|
|
79
|
-
###
|
|
79
|
+
### Squad
|
|
80
80
|
|
|
81
|
-
- `
|
|
82
|
-
- `
|
|
81
|
+
- `get_squad` - Get squad details
|
|
82
|
+
- `list_squad_members` - List squad members
|
|
83
83
|
|
|
84
84
|
### Messages
|
|
85
85
|
|
|
86
|
-
- `list_messages` - List
|
|
86
|
+
- `list_messages` - List squad messages
|
|
87
87
|
- `create_message` - Send a message
|
|
88
88
|
|
|
89
89
|
## Development
|
|
@@ -95,7 +95,7 @@ cd hanary-mcp
|
|
|
95
95
|
uv sync
|
|
96
96
|
|
|
97
97
|
# Run locally
|
|
98
|
-
HANARY_API_TOKEN=your_token uv run hanary-mcp --
|
|
98
|
+
HANARY_API_TOKEN=your_token uv run hanary-mcp --squad test
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
## License
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Hanary MCP Server
|
|
2
2
|
|
|
3
|
-
[Hanary](https://hanary.org) MCP Server for Claude Code -
|
|
3
|
+
[Hanary](https://hanary.org) MCP Server for Claude Code - squad-bound task management.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
# Using uvx (recommended)
|
|
9
|
-
uvx hanary-mcp --
|
|
9
|
+
uvx hanary-mcp --squad my-project
|
|
10
10
|
|
|
11
11
|
# Or install globally
|
|
12
12
|
uv tool install hanary-mcp
|
|
@@ -23,7 +23,7 @@ Add to your project's `.mcp.json`:
|
|
|
23
23
|
"mcpServers": {
|
|
24
24
|
"hanary": {
|
|
25
25
|
"command": "uvx",
|
|
26
|
-
"args": ["hanary-mcp", "--
|
|
26
|
+
"args": ["hanary-mcp", "--squad", "your-squad-slug"],
|
|
27
27
|
"env": {
|
|
28
28
|
"HANARY_API_TOKEN": "${HANARY_API_TOKEN}"
|
|
29
29
|
}
|
|
@@ -35,7 +35,7 @@ Add to your project's `.mcp.json`:
|
|
|
35
35
|
Or add via CLI:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
claude mcp add hanary --transport stdio -- uvx hanary-mcp --
|
|
38
|
+
claude mcp add hanary --transport stdio -- uvx hanary-mcp --squad your-squad-slug
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
### Environment Variables
|
|
@@ -49,7 +49,7 @@ claude mcp add hanary --transport stdio -- uvx hanary-mcp --workspace your-works
|
|
|
49
49
|
|
|
50
50
|
### Task Management
|
|
51
51
|
|
|
52
|
-
- `list_tasks` - List tasks in the
|
|
52
|
+
- `list_tasks` - List tasks in the squad
|
|
53
53
|
- `create_task` - Create a new task
|
|
54
54
|
- `update_task` - Update task title/description
|
|
55
55
|
- `complete_task` - Mark task as completed
|
|
@@ -57,14 +57,14 @@ claude mcp add hanary --transport stdio -- uvx hanary-mcp --workspace your-works
|
|
|
57
57
|
- `delete_task` - Soft delete a task
|
|
58
58
|
- `get_top_task` - Get highest priority incomplete task
|
|
59
59
|
|
|
60
|
-
###
|
|
60
|
+
### Squad
|
|
61
61
|
|
|
62
|
-
- `
|
|
63
|
-
- `
|
|
62
|
+
- `get_squad` - Get squad details
|
|
63
|
+
- `list_squad_members` - List squad members
|
|
64
64
|
|
|
65
65
|
### Messages
|
|
66
66
|
|
|
67
|
-
- `list_messages` - List
|
|
67
|
+
- `list_messages` - List squad messages
|
|
68
68
|
- `create_message` - Send a message
|
|
69
69
|
|
|
70
70
|
## Development
|
|
@@ -76,7 +76,7 @@ cd hanary-mcp
|
|
|
76
76
|
uv sync
|
|
77
77
|
|
|
78
78
|
# Run locally
|
|
79
|
-
HANARY_API_TOKEN=your_token uv run hanary-mcp --
|
|
79
|
+
HANARY_API_TOKEN=your_token uv run hanary-mcp --squad test
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
## License
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Hanary API Client for MCP Server."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HanaryClient:
|
|
10
|
+
"""Client for Hanary HTTP MCP API."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, api_token: str, api_url: str = "https://hanary.org"):
|
|
13
|
+
self.api_url = api_url.rstrip("/")
|
|
14
|
+
self.api_token = api_token
|
|
15
|
+
self._session: Optional[requests.Session] = None
|
|
16
|
+
self._squad_id_cache: dict[str, int] = {}
|
|
17
|
+
|
|
18
|
+
def _get_session(self) -> requests.Session:
|
|
19
|
+
if self._session is None:
|
|
20
|
+
self._session = requests.Session()
|
|
21
|
+
self._session.headers.update({
|
|
22
|
+
"Authorization": f"Bearer {self.api_token}",
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
"User-Agent": "curl/8.7.1",
|
|
25
|
+
})
|
|
26
|
+
return self._session
|
|
27
|
+
|
|
28
|
+
async def _call_mcp(self, method: str, params: dict = None) -> dict:
|
|
29
|
+
"""Call the Hanary MCP endpoint."""
|
|
30
|
+
session = self._get_session()
|
|
31
|
+
|
|
32
|
+
request_body = {
|
|
33
|
+
"jsonrpc": "2.0",
|
|
34
|
+
"id": 1,
|
|
35
|
+
"method": method,
|
|
36
|
+
"params": params or {},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
response = session.post(f"{self.api_url}/mcp", json=request_body)
|
|
40
|
+
response.raise_for_status()
|
|
41
|
+
|
|
42
|
+
result = response.json()
|
|
43
|
+
|
|
44
|
+
if "error" in result:
|
|
45
|
+
raise Exception(result["error"].get("message", "Unknown error"))
|
|
46
|
+
|
|
47
|
+
return result.get("result", {})
|
|
48
|
+
|
|
49
|
+
async def _get_squad_id(self, squad_slug: str) -> int:
|
|
50
|
+
"""Get squad ID from slug (cached)."""
|
|
51
|
+
if squad_slug in self._squad_id_cache:
|
|
52
|
+
return self._squad_id_cache[squad_slug]
|
|
53
|
+
|
|
54
|
+
result = await self._call_mcp("tools/call", {
|
|
55
|
+
"name": "get_squad",
|
|
56
|
+
"arguments": {"slug": squad_slug}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
content = result.get("content", [])
|
|
60
|
+
if content:
|
|
61
|
+
data = json.loads(content[0].get("text", "{}"))
|
|
62
|
+
squad = data.get("squad", {})
|
|
63
|
+
squad_id = squad.get("id")
|
|
64
|
+
if squad_id:
|
|
65
|
+
self._squad_id_cache[squad_slug] = squad_id
|
|
66
|
+
return squad_id
|
|
67
|
+
|
|
68
|
+
raise Exception(f"Squad not found: {squad_slug}")
|
|
69
|
+
|
|
70
|
+
async def _call_tool(self, name: str, arguments: dict) -> str:
|
|
71
|
+
"""Call a tool and return the result as string."""
|
|
72
|
+
result = await self._call_mcp("tools/call", {
|
|
73
|
+
"name": name,
|
|
74
|
+
"arguments": arguments,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
content = result.get("content", [])
|
|
78
|
+
if content:
|
|
79
|
+
return content[0].get("text", "{}")
|
|
80
|
+
return "{}"
|
|
81
|
+
|
|
82
|
+
# Task methods
|
|
83
|
+
async def list_tasks(
|
|
84
|
+
self, squad_slug: Optional[str] = None, include_completed: bool = False
|
|
85
|
+
) -> str:
|
|
86
|
+
args = {"include_completed": include_completed}
|
|
87
|
+
if squad_slug:
|
|
88
|
+
squad_id = await self._get_squad_id(squad_slug)
|
|
89
|
+
args["squad_id"] = str(squad_id)
|
|
90
|
+
return await self._call_tool("list_tasks", args)
|
|
91
|
+
|
|
92
|
+
async def create_task(
|
|
93
|
+
self,
|
|
94
|
+
title: str,
|
|
95
|
+
squad_slug: Optional[str] = None,
|
|
96
|
+
description: Optional[str] = None,
|
|
97
|
+
parent_id: Optional[str] = None,
|
|
98
|
+
) -> str:
|
|
99
|
+
args = {"title": title}
|
|
100
|
+
if squad_slug:
|
|
101
|
+
squad_id = await self._get_squad_id(squad_slug)
|
|
102
|
+
args["squad_id"] = str(squad_id)
|
|
103
|
+
if description:
|
|
104
|
+
args["description"] = description
|
|
105
|
+
if parent_id:
|
|
106
|
+
args["parent_id"] = parent_id
|
|
107
|
+
|
|
108
|
+
return await self._call_tool("create_task", args)
|
|
109
|
+
|
|
110
|
+
async def update_task(
|
|
111
|
+
self,
|
|
112
|
+
task_id: str,
|
|
113
|
+
title: Optional[str] = None,
|
|
114
|
+
description: Optional[str] = None,
|
|
115
|
+
) -> str:
|
|
116
|
+
args = {"task_id": task_id}
|
|
117
|
+
if title:
|
|
118
|
+
args["title"] = title
|
|
119
|
+
if description:
|
|
120
|
+
args["description"] = description
|
|
121
|
+
|
|
122
|
+
return await self._call_tool("update_task", args)
|
|
123
|
+
|
|
124
|
+
async def complete_task(self, task_id: str) -> str:
|
|
125
|
+
return await self._call_tool("complete_task", {"task_id": task_id})
|
|
126
|
+
|
|
127
|
+
async def uncomplete_task(self, task_id: str) -> str:
|
|
128
|
+
return await self._call_tool("uncomplete_task", {"task_id": task_id})
|
|
129
|
+
|
|
130
|
+
async def delete_task(self, task_id: str) -> str:
|
|
131
|
+
return await self._call_tool("delete_task", {"task_id": task_id})
|
|
132
|
+
|
|
133
|
+
async def get_top_task(self, squad_slug: Optional[str] = None) -> str:
|
|
134
|
+
args = {}
|
|
135
|
+
if squad_slug:
|
|
136
|
+
squad_id = await self._get_squad_id(squad_slug)
|
|
137
|
+
args["squad_id"] = str(squad_id)
|
|
138
|
+
return await self._call_tool("get_top_task", args)
|
|
139
|
+
|
|
140
|
+
async def start_task(self, task_id: str) -> str:
|
|
141
|
+
return await self._call_tool("start_task", {"task_id": task_id})
|
|
142
|
+
|
|
143
|
+
async def stop_task(self, task_id: str) -> str:
|
|
144
|
+
return await self._call_tool("stop_task", {"task_id": task_id})
|
|
145
|
+
|
|
146
|
+
async def reorder_task(self, task_id: str, new_rank: int) -> str:
|
|
147
|
+
return await self._call_tool("reorder_task", {
|
|
148
|
+
"task_id": task_id,
|
|
149
|
+
"new_rank": new_rank,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
# Calibration methods (Self-Calibration feature)
|
|
153
|
+
async def get_weekly_stats(self) -> str:
|
|
154
|
+
return await self._call_tool("get_weekly_stats", {})
|
|
155
|
+
|
|
156
|
+
async def get_estimation_accuracy(self) -> str:
|
|
157
|
+
return await self._call_tool("get_estimation_accuracy", {})
|
|
158
|
+
|
|
159
|
+
async def suggest_duration(self, task_id: str) -> str:
|
|
160
|
+
return await self._call_tool("suggest_duration", {"task_id": task_id})
|
|
161
|
+
|
|
162
|
+
async def detect_overload(self) -> str:
|
|
163
|
+
return await self._call_tool("detect_overload", {})
|
|
164
|
+
|
|
165
|
+
async def detect_underload(self) -> str:
|
|
166
|
+
return await self._call_tool("detect_underload", {})
|
|
167
|
+
|
|
168
|
+
# Squad methods
|
|
169
|
+
async def get_squad(self, squad_slug: str) -> str:
|
|
170
|
+
return await self._call_tool("get_squad", {"slug": squad_slug})
|
|
171
|
+
|
|
172
|
+
async def list_squad_members(self, squad_slug: str) -> str:
|
|
173
|
+
squad_id = await self._get_squad_id(squad_slug)
|
|
174
|
+
return await self._call_tool("list_squad_members", {
|
|
175
|
+
"squad_id": str(squad_id),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
# Message methods
|
|
179
|
+
async def list_messages(self, squad_slug: str, limit: int = 50) -> str:
|
|
180
|
+
squad_id = await self._get_squad_id(squad_slug)
|
|
181
|
+
return await self._call_tool("list_messages", {
|
|
182
|
+
"squad_id": str(squad_id),
|
|
183
|
+
"limit": limit,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
async def create_message(self, squad_slug: str, content: str) -> str:
|
|
187
|
+
squad_id = await self._get_squad_id(squad_slug)
|
|
188
|
+
return await self._call_tool("create_message", {
|
|
189
|
+
"squad_id": str(squad_id),
|
|
190
|
+
"content": content,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
# Session review methods (8시간 초과 자동 중지 세션 관리)
|
|
194
|
+
async def list_sessions_needing_review(self, squad_slug: Optional[str] = None) -> str:
|
|
195
|
+
args = {}
|
|
196
|
+
if squad_slug:
|
|
197
|
+
squad_id = await self._get_squad_id(squad_slug)
|
|
198
|
+
args["squad_id"] = str(squad_id)
|
|
199
|
+
return await self._call_tool("list_sessions_needing_review", args)
|
|
200
|
+
|
|
201
|
+
async def approve_session(self, session_id: str) -> str:
|
|
202
|
+
return await self._call_tool("approve_session", {"session_id": session_id})
|
|
203
|
+
|
|
204
|
+
async def review_session(self, session_id: str, ended_at: str) -> str:
|
|
205
|
+
return await self._call_tool("review_session", {
|
|
206
|
+
"session_id": session_id,
|
|
207
|
+
"ended_at": ended_at,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
async def delete_session(self, session_id: str) -> str:
|
|
211
|
+
return await self._call_tool("delete_session", {"session_id": session_id})
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""Hanary MCP Server implementation."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from mcp.server import Server
|
|
8
|
+
from mcp.server.stdio import stdio_server
|
|
9
|
+
from mcp.types import Tool, TextContent
|
|
10
|
+
|
|
11
|
+
from .client import HanaryClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_server(squad: str | None, client: HanaryClient) -> Server:
|
|
15
|
+
"""Create and configure the MCP server."""
|
|
16
|
+
server = Server("hanary")
|
|
17
|
+
|
|
18
|
+
# Determine mode description
|
|
19
|
+
if squad:
|
|
20
|
+
task_scope = f"squad '{squad}'"
|
|
21
|
+
else:
|
|
22
|
+
task_scope = "personal tasks (including assigned squad tasks)"
|
|
23
|
+
|
|
24
|
+
@server.list_tools()
|
|
25
|
+
async def list_tools() -> list[Tool]:
|
|
26
|
+
tools = [
|
|
27
|
+
Tool(
|
|
28
|
+
name="list_tasks",
|
|
29
|
+
description=f"List tasks for {task_scope}.",
|
|
30
|
+
inputSchema={
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"include_completed": {
|
|
34
|
+
"type": "boolean",
|
|
35
|
+
"description": "Include completed tasks (default: false)",
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
),
|
|
40
|
+
Tool(
|
|
41
|
+
name="create_task",
|
|
42
|
+
description=f"Create a new task in {task_scope}.",
|
|
43
|
+
inputSchema={
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"title": {"type": "string", "description": "Task title (required)"},
|
|
47
|
+
"description": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "Task description (optional)",
|
|
50
|
+
},
|
|
51
|
+
"parent_id": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Parent task ID for subtask (optional)",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
"required": ["title"],
|
|
57
|
+
},
|
|
58
|
+
),
|
|
59
|
+
Tool(
|
|
60
|
+
name="update_task",
|
|
61
|
+
description="Update an existing task's title or description.",
|
|
62
|
+
inputSchema={
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"task_id": {"type": "string", "description": "Task ID (required)"},
|
|
66
|
+
"title": {"type": "string", "description": "New title (optional)"},
|
|
67
|
+
"description": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "New description (optional)",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
"required": ["task_id"],
|
|
73
|
+
},
|
|
74
|
+
),
|
|
75
|
+
Tool(
|
|
76
|
+
name="complete_task",
|
|
77
|
+
description="Mark a task as completed.",
|
|
78
|
+
inputSchema={
|
|
79
|
+
"type": "object",
|
|
80
|
+
"properties": {
|
|
81
|
+
"task_id": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "Task ID to complete (required)",
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"required": ["task_id"],
|
|
87
|
+
},
|
|
88
|
+
),
|
|
89
|
+
Tool(
|
|
90
|
+
name="uncomplete_task",
|
|
91
|
+
description="Mark a completed task as incomplete.",
|
|
92
|
+
inputSchema={
|
|
93
|
+
"type": "object",
|
|
94
|
+
"properties": {
|
|
95
|
+
"task_id": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"description": "Task ID to uncomplete (required)",
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"required": ["task_id"],
|
|
101
|
+
},
|
|
102
|
+
),
|
|
103
|
+
Tool(
|
|
104
|
+
name="delete_task",
|
|
105
|
+
description="Soft delete a task.",
|
|
106
|
+
inputSchema={
|
|
107
|
+
"type": "object",
|
|
108
|
+
"properties": {
|
|
109
|
+
"task_id": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"description": "Task ID to delete (required)",
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"required": ["task_id"],
|
|
115
|
+
},
|
|
116
|
+
),
|
|
117
|
+
Tool(
|
|
118
|
+
name="get_top_task",
|
|
119
|
+
description="Get the highest priority incomplete task. Returns the deepest uncompleted task along with its ancestor chain.",
|
|
120
|
+
inputSchema={"type": "object", "properties": {}},
|
|
121
|
+
),
|
|
122
|
+
Tool(
|
|
123
|
+
name="start_task",
|
|
124
|
+
description="Start time tracking for a task. Creates a new time session.",
|
|
125
|
+
inputSchema={
|
|
126
|
+
"type": "object",
|
|
127
|
+
"properties": {
|
|
128
|
+
"task_id": {
|
|
129
|
+
"type": "string",
|
|
130
|
+
"description": "Task ID to start time tracking (required)",
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"required": ["task_id"],
|
|
134
|
+
},
|
|
135
|
+
),
|
|
136
|
+
Tool(
|
|
137
|
+
name="stop_task",
|
|
138
|
+
description="Stop time tracking for a task. Ends the current time session.",
|
|
139
|
+
inputSchema={
|
|
140
|
+
"type": "object",
|
|
141
|
+
"properties": {
|
|
142
|
+
"task_id": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"description": "Task ID to stop time tracking (required)",
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
"required": ["task_id"],
|
|
148
|
+
},
|
|
149
|
+
),
|
|
150
|
+
Tool(
|
|
151
|
+
name="reorder_task",
|
|
152
|
+
description="Change the order of a task among its siblings. Moves the task to the specified rank position.",
|
|
153
|
+
inputSchema={
|
|
154
|
+
"type": "object",
|
|
155
|
+
"properties": {
|
|
156
|
+
"task_id": {
|
|
157
|
+
"type": "string",
|
|
158
|
+
"description": "Task ID to reorder (required)",
|
|
159
|
+
},
|
|
160
|
+
"new_rank": {
|
|
161
|
+
"type": "integer",
|
|
162
|
+
"description": "New rank position (0-based index among siblings, required)",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
"required": ["task_id", "new_rank"],
|
|
166
|
+
},
|
|
167
|
+
),
|
|
168
|
+
# Calibration tools (Self-Calibration feature)
|
|
169
|
+
Tool(
|
|
170
|
+
name="get_weekly_stats",
|
|
171
|
+
description="Get weekly task completion statistics for the past 4 weeks. Returns weekly averages of time spent on tasks.",
|
|
172
|
+
inputSchema={"type": "object", "properties": {}},
|
|
173
|
+
),
|
|
174
|
+
Tool(
|
|
175
|
+
name="get_estimation_accuracy",
|
|
176
|
+
description="Get estimation accuracy statistics. Returns the ratio of actual time spent vs estimated time. Ratio > 1.0 means underestimating, < 1.0 means overestimating.",
|
|
177
|
+
inputSchema={"type": "object", "properties": {}},
|
|
178
|
+
),
|
|
179
|
+
Tool(
|
|
180
|
+
name="suggest_duration",
|
|
181
|
+
description="Get suggested duration for a task based on similar completed tasks. Useful for setting realistic time estimates.",
|
|
182
|
+
inputSchema={
|
|
183
|
+
"type": "object",
|
|
184
|
+
"properties": {
|
|
185
|
+
"task_id": {
|
|
186
|
+
"type": "string",
|
|
187
|
+
"description": "Task ID to get suggestion for (required)",
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
"required": ["task_id"],
|
|
191
|
+
},
|
|
192
|
+
),
|
|
193
|
+
Tool(
|
|
194
|
+
name="detect_overload",
|
|
195
|
+
description="Detect overload signals. Checks for: tasks taking 2x longer than estimated, stale tasks (7+ days incomplete), low completion rate.",
|
|
196
|
+
inputSchema={"type": "object", "properties": {}},
|
|
197
|
+
),
|
|
198
|
+
Tool(
|
|
199
|
+
name="detect_underload",
|
|
200
|
+
description="Detect underload signals. Checks if tasks are being completed in less than 50% of estimated time.",
|
|
201
|
+
inputSchema={"type": "object", "properties": {}},
|
|
202
|
+
),
|
|
203
|
+
# Session review tools (8시간 초과 자동 중지 세션 관리)
|
|
204
|
+
Tool(
|
|
205
|
+
name="list_sessions_needing_review",
|
|
206
|
+
description="List time sessions that need review. These are sessions that were auto-stopped after 8+ hours and may need time correction.",
|
|
207
|
+
inputSchema={"type": "object", "properties": {}},
|
|
208
|
+
),
|
|
209
|
+
Tool(
|
|
210
|
+
name="approve_session",
|
|
211
|
+
description="Approve an auto-stopped session. Keeps the recorded time as-is and removes the needs_review flag.",
|
|
212
|
+
inputSchema={
|
|
213
|
+
"type": "object",
|
|
214
|
+
"properties": {
|
|
215
|
+
"session_id": {
|
|
216
|
+
"type": "string",
|
|
217
|
+
"description": "Session ID to approve (required)",
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
"required": ["session_id"],
|
|
221
|
+
},
|
|
222
|
+
),
|
|
223
|
+
Tool(
|
|
224
|
+
name="review_session",
|
|
225
|
+
description="Review and correct an auto-stopped session's end time.",
|
|
226
|
+
inputSchema={
|
|
227
|
+
"type": "object",
|
|
228
|
+
"properties": {
|
|
229
|
+
"session_id": {
|
|
230
|
+
"type": "string",
|
|
231
|
+
"description": "Session ID to review (required)",
|
|
232
|
+
},
|
|
233
|
+
"ended_at": {
|
|
234
|
+
"type": "string",
|
|
235
|
+
"description": "Corrected end time in ISO 8601 format (required)",
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
"required": ["session_id", "ended_at"],
|
|
239
|
+
},
|
|
240
|
+
),
|
|
241
|
+
Tool(
|
|
242
|
+
name="delete_session",
|
|
243
|
+
description="Delete a time session. Also recalculates the task's total time spent.",
|
|
244
|
+
inputSchema={
|
|
245
|
+
"type": "object",
|
|
246
|
+
"properties": {
|
|
247
|
+
"session_id": {
|
|
248
|
+
"type": "string",
|
|
249
|
+
"description": "Session ID to delete (required)",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
"required": ["session_id"],
|
|
253
|
+
},
|
|
254
|
+
),
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
# Add squad-only tools when squad is specified
|
|
258
|
+
if squad:
|
|
259
|
+
tools.extend([
|
|
260
|
+
Tool(
|
|
261
|
+
name="get_squad",
|
|
262
|
+
description="Get details of the current squad.",
|
|
263
|
+
inputSchema={"type": "object", "properties": {}},
|
|
264
|
+
),
|
|
265
|
+
Tool(
|
|
266
|
+
name="list_squad_members",
|
|
267
|
+
description="List members of the current squad.",
|
|
268
|
+
inputSchema={"type": "object", "properties": {}},
|
|
269
|
+
),
|
|
270
|
+
Tool(
|
|
271
|
+
name="list_messages",
|
|
272
|
+
description="List messages in the current squad.",
|
|
273
|
+
inputSchema={
|
|
274
|
+
"type": "object",
|
|
275
|
+
"properties": {
|
|
276
|
+
"limit": {
|
|
277
|
+
"type": "integer",
|
|
278
|
+
"description": "Number of messages to retrieve (default: 50)",
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
),
|
|
283
|
+
Tool(
|
|
284
|
+
name="create_message",
|
|
285
|
+
description="Send a message to the current squad.",
|
|
286
|
+
inputSchema={
|
|
287
|
+
"type": "object",
|
|
288
|
+
"properties": {
|
|
289
|
+
"content": {
|
|
290
|
+
"type": "string",
|
|
291
|
+
"description": "Message content (required)",
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
"required": ["content"],
|
|
295
|
+
},
|
|
296
|
+
),
|
|
297
|
+
])
|
|
298
|
+
|
|
299
|
+
return tools
|
|
300
|
+
|
|
301
|
+
@server.call_tool()
|
|
302
|
+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
303
|
+
try:
|
|
304
|
+
result = await handle_tool_call(name, arguments, squad, client)
|
|
305
|
+
return [TextContent(type="text", text=result)]
|
|
306
|
+
except Exception as e:
|
|
307
|
+
return [TextContent(type="text", text=f"Error: {str(e)}")]
|
|
308
|
+
|
|
309
|
+
return server
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def handle_tool_call(
|
|
313
|
+
name: str, arguments: dict, squad: str | None, client: HanaryClient
|
|
314
|
+
) -> str:
|
|
315
|
+
"""Handle individual tool calls."""
|
|
316
|
+
# Task tools
|
|
317
|
+
if name == "list_tasks":
|
|
318
|
+
return await client.list_tasks(
|
|
319
|
+
squad_slug=squad,
|
|
320
|
+
include_completed=arguments.get("include_completed", False),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
elif name == "create_task":
|
|
324
|
+
return await client.create_task(
|
|
325
|
+
title=arguments["title"],
|
|
326
|
+
squad_slug=squad,
|
|
327
|
+
description=arguments.get("description"),
|
|
328
|
+
parent_id=arguments.get("parent_id"),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
elif name == "update_task":
|
|
332
|
+
return await client.update_task(
|
|
333
|
+
task_id=arguments["task_id"],
|
|
334
|
+
title=arguments.get("title"),
|
|
335
|
+
description=arguments.get("description"),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
elif name == "complete_task":
|
|
339
|
+
return await client.complete_task(task_id=arguments["task_id"])
|
|
340
|
+
|
|
341
|
+
elif name == "uncomplete_task":
|
|
342
|
+
return await client.uncomplete_task(task_id=arguments["task_id"])
|
|
343
|
+
|
|
344
|
+
elif name == "delete_task":
|
|
345
|
+
return await client.delete_task(task_id=arguments["task_id"])
|
|
346
|
+
|
|
347
|
+
elif name == "get_top_task":
|
|
348
|
+
return await client.get_top_task(squad_slug=squad)
|
|
349
|
+
|
|
350
|
+
elif name == "start_task":
|
|
351
|
+
return await client.start_task(task_id=arguments["task_id"])
|
|
352
|
+
|
|
353
|
+
elif name == "stop_task":
|
|
354
|
+
return await client.stop_task(task_id=arguments["task_id"])
|
|
355
|
+
|
|
356
|
+
elif name == "reorder_task":
|
|
357
|
+
return await client.reorder_task(
|
|
358
|
+
task_id=arguments["task_id"],
|
|
359
|
+
new_rank=arguments["new_rank"],
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Calibration tools
|
|
363
|
+
elif name == "get_weekly_stats":
|
|
364
|
+
return await client.get_weekly_stats()
|
|
365
|
+
|
|
366
|
+
elif name == "get_estimation_accuracy":
|
|
367
|
+
return await client.get_estimation_accuracy()
|
|
368
|
+
|
|
369
|
+
elif name == "suggest_duration":
|
|
370
|
+
return await client.suggest_duration(task_id=arguments["task_id"])
|
|
371
|
+
|
|
372
|
+
elif name == "detect_overload":
|
|
373
|
+
return await client.detect_overload()
|
|
374
|
+
|
|
375
|
+
elif name == "detect_underload":
|
|
376
|
+
return await client.detect_underload()
|
|
377
|
+
|
|
378
|
+
# Session review tools
|
|
379
|
+
elif name == "list_sessions_needing_review":
|
|
380
|
+
return await client.list_sessions_needing_review(squad_slug=squad)
|
|
381
|
+
|
|
382
|
+
elif name == "approve_session":
|
|
383
|
+
return await client.approve_session(session_id=arguments["session_id"])
|
|
384
|
+
|
|
385
|
+
elif name == "review_session":
|
|
386
|
+
return await client.review_session(
|
|
387
|
+
session_id=arguments["session_id"],
|
|
388
|
+
ended_at=arguments["ended_at"],
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
elif name == "delete_session":
|
|
392
|
+
return await client.delete_session(session_id=arguments["session_id"])
|
|
393
|
+
|
|
394
|
+
# Squad tools
|
|
395
|
+
elif name == "get_squad":
|
|
396
|
+
return await client.get_squad(squad_slug=squad)
|
|
397
|
+
|
|
398
|
+
elif name == "list_squad_members":
|
|
399
|
+
return await client.list_squad_members(squad_slug=squad)
|
|
400
|
+
|
|
401
|
+
# Message tools
|
|
402
|
+
elif name == "list_messages":
|
|
403
|
+
return await client.list_messages(
|
|
404
|
+
squad_slug=squad,
|
|
405
|
+
limit=arguments.get("limit", 50),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
elif name == "create_message":
|
|
409
|
+
return await client.create_message(
|
|
410
|
+
squad_slug=squad,
|
|
411
|
+
content=arguments["content"],
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
else:
|
|
415
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
async def run_server(squad: str | None, api_token: str, api_url: str):
|
|
419
|
+
"""Run the MCP server."""
|
|
420
|
+
client = HanaryClient(api_token=api_token, api_url=api_url)
|
|
421
|
+
server = create_server(squad, client)
|
|
422
|
+
|
|
423
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
424
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def main():
|
|
428
|
+
"""Main entry point."""
|
|
429
|
+
parser = argparse.ArgumentParser(
|
|
430
|
+
description="Hanary MCP Server - Task management for Claude Code"
|
|
431
|
+
)
|
|
432
|
+
parser.add_argument(
|
|
433
|
+
"--squad",
|
|
434
|
+
"-s",
|
|
435
|
+
default=None,
|
|
436
|
+
help="Squad slug to bind to. If not specified, manages personal tasks.",
|
|
437
|
+
)
|
|
438
|
+
parser.add_argument(
|
|
439
|
+
"--token",
|
|
440
|
+
"-t",
|
|
441
|
+
default=os.environ.get("HANARY_API_TOKEN"),
|
|
442
|
+
help="Hanary API token (or set HANARY_API_TOKEN env var)",
|
|
443
|
+
)
|
|
444
|
+
parser.add_argument(
|
|
445
|
+
"--api-url",
|
|
446
|
+
default=os.environ.get("HANARY_API_URL", "https://hanary.org"),
|
|
447
|
+
help="Hanary API URL (default: https://hanary.org)",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
args = parser.parse_args()
|
|
451
|
+
|
|
452
|
+
# Get API token from argument or environment
|
|
453
|
+
api_token = args.token
|
|
454
|
+
if not api_token:
|
|
455
|
+
print("Error: --token argument or HANARY_API_TOKEN environment variable is required", file=sys.stderr)
|
|
456
|
+
sys.exit(1)
|
|
457
|
+
|
|
458
|
+
import asyncio
|
|
459
|
+
|
|
460
|
+
asyncio.run(run_server(args.squad, api_token, args.api_url))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
if __name__ == "__main__":
|
|
464
|
+
main()
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
"""Hanary API Client for MCP Server."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from typing import Optional
|
|
5
|
-
|
|
6
|
-
import requests
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class HanaryClient:
|
|
10
|
-
"""Client for Hanary HTTP MCP API."""
|
|
11
|
-
|
|
12
|
-
def __init__(self, api_token: str, api_url: str = "https://hanary.org"):
|
|
13
|
-
self.api_url = api_url.rstrip("/")
|
|
14
|
-
self.api_token = api_token
|
|
15
|
-
self._session: Optional[requests.Session] = None
|
|
16
|
-
self._workspace_id_cache: dict[str, int] = {}
|
|
17
|
-
|
|
18
|
-
def _get_session(self) -> requests.Session:
|
|
19
|
-
if self._session is None:
|
|
20
|
-
self._session = requests.Session()
|
|
21
|
-
self._session.headers.update({
|
|
22
|
-
"Authorization": f"Bearer {self.api_token}",
|
|
23
|
-
"Content-Type": "application/json",
|
|
24
|
-
"User-Agent": "curl/8.7.1",
|
|
25
|
-
})
|
|
26
|
-
return self._session
|
|
27
|
-
|
|
28
|
-
async def _call_mcp(self, method: str, params: dict = None) -> dict:
|
|
29
|
-
"""Call the Hanary MCP endpoint."""
|
|
30
|
-
session = self._get_session()
|
|
31
|
-
|
|
32
|
-
request_body = {
|
|
33
|
-
"jsonrpc": "2.0",
|
|
34
|
-
"id": 1,
|
|
35
|
-
"method": method,
|
|
36
|
-
"params": params or {},
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
response = session.post(f"{self.api_url}/mcp", json=request_body)
|
|
40
|
-
response.raise_for_status()
|
|
41
|
-
|
|
42
|
-
result = response.json()
|
|
43
|
-
|
|
44
|
-
if "error" in result:
|
|
45
|
-
raise Exception(result["error"].get("message", "Unknown error"))
|
|
46
|
-
|
|
47
|
-
return result.get("result", {})
|
|
48
|
-
|
|
49
|
-
async def _get_workspace_id(self, workspace_slug: str) -> int:
|
|
50
|
-
"""Get workspace ID from slug (cached)."""
|
|
51
|
-
if workspace_slug in self._workspace_id_cache:
|
|
52
|
-
return self._workspace_id_cache[workspace_slug]
|
|
53
|
-
|
|
54
|
-
result = await self._call_mcp("tools/call", {
|
|
55
|
-
"name": "get_workspace",
|
|
56
|
-
"arguments": {"slug": workspace_slug}
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
content = result.get("content", [])
|
|
60
|
-
if content:
|
|
61
|
-
data = json.loads(content[0].get("text", "{}"))
|
|
62
|
-
workspace = data.get("workspace", {})
|
|
63
|
-
workspace_id = workspace.get("id")
|
|
64
|
-
if workspace_id:
|
|
65
|
-
self._workspace_id_cache[workspace_slug] = workspace_id
|
|
66
|
-
return workspace_id
|
|
67
|
-
|
|
68
|
-
raise Exception(f"Workspace not found: {workspace_slug}")
|
|
69
|
-
|
|
70
|
-
async def _call_tool(self, name: str, arguments: dict) -> str:
|
|
71
|
-
"""Call a tool and return the result as string."""
|
|
72
|
-
result = await self._call_mcp("tools/call", {
|
|
73
|
-
"name": name,
|
|
74
|
-
"arguments": arguments,
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
content = result.get("content", [])
|
|
78
|
-
if content:
|
|
79
|
-
return content[0].get("text", "{}")
|
|
80
|
-
return "{}"
|
|
81
|
-
|
|
82
|
-
# Task methods
|
|
83
|
-
async def list_tasks(
|
|
84
|
-
self, workspace_slug: Optional[str] = None, include_completed: bool = False
|
|
85
|
-
) -> str:
|
|
86
|
-
args = {"include_completed": include_completed}
|
|
87
|
-
if workspace_slug:
|
|
88
|
-
workspace_id = await self._get_workspace_id(workspace_slug)
|
|
89
|
-
args["workspace_id"] = str(workspace_id)
|
|
90
|
-
return await self._call_tool("list_tasks", args)
|
|
91
|
-
|
|
92
|
-
async def create_task(
|
|
93
|
-
self,
|
|
94
|
-
title: str,
|
|
95
|
-
workspace_slug: Optional[str] = None,
|
|
96
|
-
description: Optional[str] = None,
|
|
97
|
-
parent_id: Optional[str] = None,
|
|
98
|
-
) -> str:
|
|
99
|
-
args = {"title": title}
|
|
100
|
-
if workspace_slug:
|
|
101
|
-
workspace_id = await self._get_workspace_id(workspace_slug)
|
|
102
|
-
args["workspace_id"] = str(workspace_id)
|
|
103
|
-
if description:
|
|
104
|
-
args["description"] = description
|
|
105
|
-
if parent_id:
|
|
106
|
-
args["parent_id"] = parent_id
|
|
107
|
-
|
|
108
|
-
return await self._call_tool("create_task", args)
|
|
109
|
-
|
|
110
|
-
async def update_task(
|
|
111
|
-
self,
|
|
112
|
-
task_id: str,
|
|
113
|
-
title: Optional[str] = None,
|
|
114
|
-
description: Optional[str] = None,
|
|
115
|
-
) -> str:
|
|
116
|
-
args = {"task_id": task_id}
|
|
117
|
-
if title:
|
|
118
|
-
args["title"] = title
|
|
119
|
-
if description:
|
|
120
|
-
args["description"] = description
|
|
121
|
-
|
|
122
|
-
return await self._call_tool("update_task", args)
|
|
123
|
-
|
|
124
|
-
async def complete_task(self, task_id: str) -> str:
|
|
125
|
-
return await self._call_tool("complete_task", {"task_id": task_id})
|
|
126
|
-
|
|
127
|
-
async def uncomplete_task(self, task_id: str) -> str:
|
|
128
|
-
return await self._call_tool("uncomplete_task", {"task_id": task_id})
|
|
129
|
-
|
|
130
|
-
async def delete_task(self, task_id: str) -> str:
|
|
131
|
-
return await self._call_tool("delete_task", {"task_id": task_id})
|
|
132
|
-
|
|
133
|
-
async def get_top_task(self, workspace_slug: Optional[str] = None) -> str:
|
|
134
|
-
args = {}
|
|
135
|
-
if workspace_slug:
|
|
136
|
-
workspace_id = await self._get_workspace_id(workspace_slug)
|
|
137
|
-
args["workspace_id"] = str(workspace_id)
|
|
138
|
-
return await self._call_tool("get_top_task", args)
|
|
139
|
-
|
|
140
|
-
# Workspace methods
|
|
141
|
-
async def get_workspace(self, workspace_slug: str) -> str:
|
|
142
|
-
return await self._call_tool("get_workspace", {"slug": workspace_slug})
|
|
143
|
-
|
|
144
|
-
async def list_workspace_members(self, workspace_slug: str) -> str:
|
|
145
|
-
workspace_id = await self._get_workspace_id(workspace_slug)
|
|
146
|
-
return await self._call_tool("list_workspace_members", {
|
|
147
|
-
"workspace_id": str(workspace_id),
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
# Message methods
|
|
151
|
-
async def list_messages(self, workspace_slug: str, limit: int = 50) -> str:
|
|
152
|
-
workspace_id = await self._get_workspace_id(workspace_slug)
|
|
153
|
-
return await self._call_tool("list_messages", {
|
|
154
|
-
"workspace_id": str(workspace_id),
|
|
155
|
-
"limit": limit,
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
async def create_message(self, workspace_slug: str, content: str) -> str:
|
|
159
|
-
workspace_id = await self._get_workspace_id(workspace_slug)
|
|
160
|
-
return await self._call_tool("create_message", {
|
|
161
|
-
"workspace_id": str(workspace_id),
|
|
162
|
-
"content": content,
|
|
163
|
-
})
|
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
"""Hanary MCP Server implementation."""
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import os
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
from mcp.server import Server
|
|
8
|
-
from mcp.server.stdio import stdio_server
|
|
9
|
-
from mcp.types import Tool, TextContent
|
|
10
|
-
|
|
11
|
-
from .client import HanaryClient
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def create_server(workspace: str | None, client: HanaryClient) -> Server:
|
|
15
|
-
"""Create and configure the MCP server."""
|
|
16
|
-
server = Server("hanary")
|
|
17
|
-
|
|
18
|
-
# Determine mode description
|
|
19
|
-
if workspace:
|
|
20
|
-
task_scope = f"workspace '{workspace}'"
|
|
21
|
-
else:
|
|
22
|
-
task_scope = "personal tasks (including assigned workspace tasks)"
|
|
23
|
-
|
|
24
|
-
@server.list_tools()
|
|
25
|
-
async def list_tools() -> list[Tool]:
|
|
26
|
-
tools = [
|
|
27
|
-
Tool(
|
|
28
|
-
name="list_tasks",
|
|
29
|
-
description=f"List tasks for {task_scope}.",
|
|
30
|
-
inputSchema={
|
|
31
|
-
"type": "object",
|
|
32
|
-
"properties": {
|
|
33
|
-
"include_completed": {
|
|
34
|
-
"type": "boolean",
|
|
35
|
-
"description": "Include completed tasks (default: false)",
|
|
36
|
-
}
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
),
|
|
40
|
-
Tool(
|
|
41
|
-
name="create_task",
|
|
42
|
-
description=f"Create a new task in {task_scope}.",
|
|
43
|
-
inputSchema={
|
|
44
|
-
"type": "object",
|
|
45
|
-
"properties": {
|
|
46
|
-
"title": {"type": "string", "description": "Task title (required)"},
|
|
47
|
-
"description": {
|
|
48
|
-
"type": "string",
|
|
49
|
-
"description": "Task description (optional)",
|
|
50
|
-
},
|
|
51
|
-
"parent_id": {
|
|
52
|
-
"type": "string",
|
|
53
|
-
"description": "Parent task ID for subtask (optional)",
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
"required": ["title"],
|
|
57
|
-
},
|
|
58
|
-
),
|
|
59
|
-
Tool(
|
|
60
|
-
name="update_task",
|
|
61
|
-
description="Update an existing task's title or description.",
|
|
62
|
-
inputSchema={
|
|
63
|
-
"type": "object",
|
|
64
|
-
"properties": {
|
|
65
|
-
"task_id": {"type": "string", "description": "Task ID (required)"},
|
|
66
|
-
"title": {"type": "string", "description": "New title (optional)"},
|
|
67
|
-
"description": {
|
|
68
|
-
"type": "string",
|
|
69
|
-
"description": "New description (optional)",
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
"required": ["task_id"],
|
|
73
|
-
},
|
|
74
|
-
),
|
|
75
|
-
Tool(
|
|
76
|
-
name="complete_task",
|
|
77
|
-
description="Mark a task as completed.",
|
|
78
|
-
inputSchema={
|
|
79
|
-
"type": "object",
|
|
80
|
-
"properties": {
|
|
81
|
-
"task_id": {
|
|
82
|
-
"type": "string",
|
|
83
|
-
"description": "Task ID to complete (required)",
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
"required": ["task_id"],
|
|
87
|
-
},
|
|
88
|
-
),
|
|
89
|
-
Tool(
|
|
90
|
-
name="uncomplete_task",
|
|
91
|
-
description="Mark a completed task as incomplete.",
|
|
92
|
-
inputSchema={
|
|
93
|
-
"type": "object",
|
|
94
|
-
"properties": {
|
|
95
|
-
"task_id": {
|
|
96
|
-
"type": "string",
|
|
97
|
-
"description": "Task ID to uncomplete (required)",
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
"required": ["task_id"],
|
|
101
|
-
},
|
|
102
|
-
),
|
|
103
|
-
Tool(
|
|
104
|
-
name="delete_task",
|
|
105
|
-
description="Soft delete a task.",
|
|
106
|
-
inputSchema={
|
|
107
|
-
"type": "object",
|
|
108
|
-
"properties": {
|
|
109
|
-
"task_id": {
|
|
110
|
-
"type": "string",
|
|
111
|
-
"description": "Task ID to delete (required)",
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
"required": ["task_id"],
|
|
115
|
-
},
|
|
116
|
-
),
|
|
117
|
-
Tool(
|
|
118
|
-
name="get_top_task",
|
|
119
|
-
description="Get the highest priority incomplete task. Returns the deepest uncompleted task along with its ancestor chain.",
|
|
120
|
-
inputSchema={"type": "object", "properties": {}},
|
|
121
|
-
),
|
|
122
|
-
]
|
|
123
|
-
|
|
124
|
-
# Add workspace-only tools when workspace is specified
|
|
125
|
-
if workspace:
|
|
126
|
-
tools.extend([
|
|
127
|
-
Tool(
|
|
128
|
-
name="get_workspace",
|
|
129
|
-
description="Get details of the current workspace.",
|
|
130
|
-
inputSchema={"type": "object", "properties": {}},
|
|
131
|
-
),
|
|
132
|
-
Tool(
|
|
133
|
-
name="list_workspace_members",
|
|
134
|
-
description="List members of the current workspace.",
|
|
135
|
-
inputSchema={"type": "object", "properties": {}},
|
|
136
|
-
),
|
|
137
|
-
Tool(
|
|
138
|
-
name="list_messages",
|
|
139
|
-
description="List messages in the current workspace.",
|
|
140
|
-
inputSchema={
|
|
141
|
-
"type": "object",
|
|
142
|
-
"properties": {
|
|
143
|
-
"limit": {
|
|
144
|
-
"type": "integer",
|
|
145
|
-
"description": "Number of messages to retrieve (default: 50)",
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
},
|
|
149
|
-
),
|
|
150
|
-
Tool(
|
|
151
|
-
name="create_message",
|
|
152
|
-
description="Send a message to the current workspace.",
|
|
153
|
-
inputSchema={
|
|
154
|
-
"type": "object",
|
|
155
|
-
"properties": {
|
|
156
|
-
"content": {
|
|
157
|
-
"type": "string",
|
|
158
|
-
"description": "Message content (required)",
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
|
-
"required": ["content"],
|
|
162
|
-
},
|
|
163
|
-
),
|
|
164
|
-
])
|
|
165
|
-
|
|
166
|
-
return tools
|
|
167
|
-
|
|
168
|
-
@server.call_tool()
|
|
169
|
-
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
170
|
-
try:
|
|
171
|
-
result = await handle_tool_call(name, arguments, workspace, client)
|
|
172
|
-
return [TextContent(type="text", text=result)]
|
|
173
|
-
except Exception as e:
|
|
174
|
-
return [TextContent(type="text", text=f"Error: {str(e)}")]
|
|
175
|
-
|
|
176
|
-
return server
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
async def handle_tool_call(
|
|
180
|
-
name: str, arguments: dict, workspace: str | None, client: HanaryClient
|
|
181
|
-
) -> str:
|
|
182
|
-
"""Handle individual tool calls."""
|
|
183
|
-
# Task tools
|
|
184
|
-
if name == "list_tasks":
|
|
185
|
-
return await client.list_tasks(
|
|
186
|
-
workspace_slug=workspace,
|
|
187
|
-
include_completed=arguments.get("include_completed", False),
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
elif name == "create_task":
|
|
191
|
-
return await client.create_task(
|
|
192
|
-
title=arguments["title"],
|
|
193
|
-
workspace_slug=workspace,
|
|
194
|
-
description=arguments.get("description"),
|
|
195
|
-
parent_id=arguments.get("parent_id"),
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
elif name == "update_task":
|
|
199
|
-
return await client.update_task(
|
|
200
|
-
task_id=arguments["task_id"],
|
|
201
|
-
title=arguments.get("title"),
|
|
202
|
-
description=arguments.get("description"),
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
elif name == "complete_task":
|
|
206
|
-
return await client.complete_task(task_id=arguments["task_id"])
|
|
207
|
-
|
|
208
|
-
elif name == "uncomplete_task":
|
|
209
|
-
return await client.uncomplete_task(task_id=arguments["task_id"])
|
|
210
|
-
|
|
211
|
-
elif name == "delete_task":
|
|
212
|
-
return await client.delete_task(task_id=arguments["task_id"])
|
|
213
|
-
|
|
214
|
-
elif name == "get_top_task":
|
|
215
|
-
return await client.get_top_task(workspace_slug=workspace)
|
|
216
|
-
|
|
217
|
-
# Workspace tools
|
|
218
|
-
elif name == "get_workspace":
|
|
219
|
-
return await client.get_workspace(workspace_slug=workspace)
|
|
220
|
-
|
|
221
|
-
elif name == "list_workspace_members":
|
|
222
|
-
return await client.list_workspace_members(workspace_slug=workspace)
|
|
223
|
-
|
|
224
|
-
# Message tools
|
|
225
|
-
elif name == "list_messages":
|
|
226
|
-
return await client.list_messages(
|
|
227
|
-
workspace_slug=workspace,
|
|
228
|
-
limit=arguments.get("limit", 50),
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
elif name == "create_message":
|
|
232
|
-
return await client.create_message(
|
|
233
|
-
workspace_slug=workspace,
|
|
234
|
-
content=arguments["content"],
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
else:
|
|
238
|
-
raise ValueError(f"Unknown tool: {name}")
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
async def run_server(workspace: str | None, api_token: str, api_url: str):
|
|
242
|
-
"""Run the MCP server."""
|
|
243
|
-
client = HanaryClient(api_token=api_token, api_url=api_url)
|
|
244
|
-
server = create_server(workspace, client)
|
|
245
|
-
|
|
246
|
-
async with stdio_server() as (read_stream, write_stream):
|
|
247
|
-
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def main():
|
|
251
|
-
"""Main entry point."""
|
|
252
|
-
parser = argparse.ArgumentParser(
|
|
253
|
-
description="Hanary MCP Server - Task management for Claude Code"
|
|
254
|
-
)
|
|
255
|
-
parser.add_argument(
|
|
256
|
-
"--workspace",
|
|
257
|
-
"-w",
|
|
258
|
-
default=None,
|
|
259
|
-
help="Workspace slug to bind to. If not specified, manages personal tasks.",
|
|
260
|
-
)
|
|
261
|
-
parser.add_argument(
|
|
262
|
-
"--token",
|
|
263
|
-
"-t",
|
|
264
|
-
default=os.environ.get("HANARY_API_TOKEN"),
|
|
265
|
-
help="Hanary API token (or set HANARY_API_TOKEN env var)",
|
|
266
|
-
)
|
|
267
|
-
parser.add_argument(
|
|
268
|
-
"--api-url",
|
|
269
|
-
default=os.environ.get("HANARY_API_URL", "https://hanary.org"),
|
|
270
|
-
help="Hanary API URL (default: https://hanary.org)",
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
args = parser.parse_args()
|
|
274
|
-
|
|
275
|
-
# Get API token from argument or environment
|
|
276
|
-
api_token = args.token
|
|
277
|
-
if not api_token:
|
|
278
|
-
print("Error: --token argument or HANARY_API_TOKEN environment variable is required", file=sys.stderr)
|
|
279
|
-
sys.exit(1)
|
|
280
|
-
|
|
281
|
-
import asyncio
|
|
282
|
-
|
|
283
|
-
asyncio.run(run_server(args.workspace, api_token, args.api_url))
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if __name__ == "__main__":
|
|
287
|
-
main()
|
|
File without changes
|
|
File without changes
|