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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hanary-mcp
3
- Version: 0.2.1
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 - workspace-bound task management.
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 --workspace my-project
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", "--workspace", "your-workspace-slug"],
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 --workspace your-workspace-slug
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 workspace
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
- ### Workspace
79
+ ### Squad
80
80
 
81
- - `get_workspace` - Get workspace details
82
- - `list_workspace_members` - List workspace members
81
+ - `get_squad` - Get squad details
82
+ - `list_squad_members` - List squad members
83
83
 
84
84
  ### Messages
85
85
 
86
- - `list_messages` - List workspace messages
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 --workspace test
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 - workspace-bound task management.
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 --workspace my-project
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", "--workspace", "your-workspace-slug"],
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 --workspace your-workspace-slug
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 workspace
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
- ### Workspace
60
+ ### Squad
61
61
 
62
- - `get_workspace` - Get workspace details
63
- - `list_workspace_members` - List workspace members
62
+ - `get_squad` - Get squad details
63
+ - `list_squad_members` - List squad members
64
64
 
65
65
  ### Messages
66
66
 
67
- - `list_messages` - List workspace messages
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 --workspace test
79
+ HANARY_API_TOKEN=your_token uv run hanary-mcp --squad test
80
80
  ```
81
81
 
82
82
  ## License
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "hanary": {
4
4
  "command": "uvx",
5
- "args": ["hanary-mcp", "--workspace", "your-workspace-slug"],
5
+ "args": ["hanary-mcp", "--squad", "your-squad-slug"],
6
6
  "env": {
7
7
  "HANARY_API_TOKEN": "${HANARY_API_TOKEN}"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hanary-mcp"
3
- version = "0.2.1"
3
+ version = "0.8.0"
4
4
  description = "Hanary MCP Server - Task management for Claude Code"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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