hanary-mcp 0.2.1__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.
@@ -0,0 +1,41 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual environments
24
+ .venv/
25
+ venv/
26
+ ENV/
27
+
28
+ # IDE
29
+ .idea/
30
+ .vscode/
31
+ *.swp
32
+ *.swo
33
+
34
+ # Testing
35
+ .pytest_cache/
36
+ .coverage
37
+ htmlcov/
38
+
39
+ # uv
40
+ .uv/
41
+ uv.lock
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: hanary-mcp
3
+ Version: 0.2.1
4
+ Summary: Hanary MCP Server - Task management for Claude Code
5
+ Author: Hanary
6
+ License: MIT
7
+ Keywords: claude,hanary,mcp,task-management
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: mcp>=1.0.0
17
+ Requires-Dist: requests>=2.32.5
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Hanary MCP Server
21
+
22
+ [Hanary](https://hanary.org) MCP Server for Claude Code - workspace-bound task management.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ # Using uvx (recommended)
28
+ uvx hanary-mcp --workspace my-project
29
+
30
+ # Or install globally
31
+ uv tool install hanary-mcp
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ ### Claude Code Setup
37
+
38
+ Add to your project's `.mcp.json`:
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "hanary": {
44
+ "command": "uvx",
45
+ "args": ["hanary-mcp", "--workspace", "your-workspace-slug"],
46
+ "env": {
47
+ "HANARY_API_TOKEN": "${HANARY_API_TOKEN}"
48
+ }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ Or add via CLI:
55
+
56
+ ```bash
57
+ claude mcp add hanary --transport stdio -- uvx hanary-mcp --workspace your-workspace-slug
58
+ ```
59
+
60
+ ### Environment Variables
61
+
62
+ | Variable | Required | Description |
63
+ |----------|----------|-------------|
64
+ | `HANARY_API_TOKEN` | Yes | Your Hanary API token |
65
+ | `HANARY_API_URL` | No | API URL (default: https://hanary.org) |
66
+
67
+ ## Available Tools
68
+
69
+ ### Task Management
70
+
71
+ - `list_tasks` - List tasks in the workspace
72
+ - `create_task` - Create a new task
73
+ - `update_task` - Update task title/description
74
+ - `complete_task` - Mark task as completed
75
+ - `uncomplete_task` - Mark task as incomplete
76
+ - `delete_task` - Soft delete a task
77
+ - `get_top_task` - Get highest priority incomplete task
78
+
79
+ ### Workspace
80
+
81
+ - `get_workspace` - Get workspace details
82
+ - `list_workspace_members` - List workspace members
83
+
84
+ ### Messages
85
+
86
+ - `list_messages` - List workspace messages
87
+ - `create_message` - Send a message
88
+
89
+ ## Development
90
+
91
+ ```bash
92
+ # Clone and install
93
+ git clone https://github.com/hanary/hanary-mcp.git
94
+ cd hanary-mcp
95
+ uv sync
96
+
97
+ # Run locally
98
+ HANARY_API_TOKEN=your_token uv run hanary-mcp --workspace test
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,84 @@
1
+ # Hanary MCP Server
2
+
3
+ [Hanary](https://hanary.org) MCP Server for Claude Code - workspace-bound task management.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # Using uvx (recommended)
9
+ uvx hanary-mcp --workspace my-project
10
+
11
+ # Or install globally
12
+ uv tool install hanary-mcp
13
+ ```
14
+
15
+ ## Configuration
16
+
17
+ ### Claude Code Setup
18
+
19
+ Add to your project's `.mcp.json`:
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "hanary": {
25
+ "command": "uvx",
26
+ "args": ["hanary-mcp", "--workspace", "your-workspace-slug"],
27
+ "env": {
28
+ "HANARY_API_TOKEN": "${HANARY_API_TOKEN}"
29
+ }
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ Or add via CLI:
36
+
37
+ ```bash
38
+ claude mcp add hanary --transport stdio -- uvx hanary-mcp --workspace your-workspace-slug
39
+ ```
40
+
41
+ ### Environment Variables
42
+
43
+ | Variable | Required | Description |
44
+ |----------|----------|-------------|
45
+ | `HANARY_API_TOKEN` | Yes | Your Hanary API token |
46
+ | `HANARY_API_URL` | No | API URL (default: https://hanary.org) |
47
+
48
+ ## Available Tools
49
+
50
+ ### Task Management
51
+
52
+ - `list_tasks` - List tasks in the workspace
53
+ - `create_task` - Create a new task
54
+ - `update_task` - Update task title/description
55
+ - `complete_task` - Mark task as completed
56
+ - `uncomplete_task` - Mark task as incomplete
57
+ - `delete_task` - Soft delete a task
58
+ - `get_top_task` - Get highest priority incomplete task
59
+
60
+ ### Workspace
61
+
62
+ - `get_workspace` - Get workspace details
63
+ - `list_workspace_members` - List workspace members
64
+
65
+ ### Messages
66
+
67
+ - `list_messages` - List workspace messages
68
+ - `create_message` - Send a message
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ # Clone and install
74
+ git clone https://github.com/hanary/hanary-mcp.git
75
+ cd hanary-mcp
76
+ uv sync
77
+
78
+ # Run locally
79
+ HANARY_API_TOKEN=your_token uv run hanary-mcp --workspace test
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "hanary": {
4
+ "command": "uvx",
5
+ "args": ["hanary-mcp", "--workspace", "your-workspace-slug"],
6
+ "env": {
7
+ "HANARY_API_TOKEN": "${HANARY_API_TOKEN}"
8
+ }
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "hanary-mcp"
3
+ version = "0.2.1"
4
+ description = "Hanary MCP Server - Task management for Claude Code"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Hanary" }]
9
+ keywords = ["mcp", "hanary", "claude", "task-management"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ ]
19
+ dependencies = [
20
+ "mcp>=1.0.0",
21
+ "requests>=2.32.5",
22
+ ]
23
+
24
+ [project.scripts]
25
+ hanary-mcp = "hanary_mcp:main"
26
+
27
+ [build-system]
28
+ requires = ["hatchling"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/hanary_mcp"]
@@ -0,0 +1,5 @@
1
+ """Hanary MCP Server - Task management for Claude Code."""
2
+
3
+ from .server import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,163 @@
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
+ })
@@ -0,0 +1,287 @@
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()