hoja-cli 0.21.4__py3-none-any.whl
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.
- VERSION +1 -0
- google_workspace/__init__.py +19 -0
- google_workspace/client.py +253 -0
- google_workspace/config.py +39 -0
- google_workspace/errors.py +19 -0
- google_workspace/mcp_server.py +100 -0
- google_workspace/oauth.py +131 -0
- google_workspace/principal.py +83 -0
- google_workspace/py.typed +0 -0
- google_workspace/tools.py +64 -0
- google_workspace/writes.py +44 -0
- hoja/__init__.py +29 -0
- hoja/__main__.py +6 -0
- hoja/auth.py +42 -0
- hoja/cli.py +161 -0
- hoja/cli_state.py +55 -0
- hoja/client.py +268 -0
- hoja/commands/__init__.py +4 -0
- hoja/commands/auth_cmd.py +271 -0
- hoja/commands/comment.py +149 -0
- hoja/commands/cycle.py +75 -0
- hoja/commands/init.py +108 -0
- hoja/commands/issue.py +398 -0
- hoja/commands/label.py +163 -0
- hoja/commands/memory.py +240 -0
- hoja/commands/project.py +56 -0
- hoja/commands/raw.py +50 -0
- hoja/commands/team.py +102 -0
- hoja/commands/update.py +162 -0
- hoja/config.py +362 -0
- hoja/errors.py +107 -0
- hoja/filters.py +150 -0
- hoja/graphql/__init__.py +5 -0
- hoja/graphql/fragments.py +138 -0
- hoja/graphql/mutations.py +160 -0
- hoja/graphql/queries.py +245 -0
- hoja/mcp/__init__.py +27 -0
- hoja/mcp/content.py +151 -0
- hoja/mcp/prompts.py +298 -0
- hoja/mcp/resources.py +213 -0
- hoja/mcp/server.py +189 -0
- hoja/memory_board.py +370 -0
- hoja/models/__init__.py +37 -0
- hoja/models/base.py +64 -0
- hoja/models/cycle.py +32 -0
- hoja/models/enums.py +62 -0
- hoja/models/issue.py +99 -0
- hoja/models/label.py +17 -0
- hoja/models/project.py +60 -0
- hoja/models/team.py +31 -0
- hoja/models/user.py +32 -0
- hoja/models/workflow.py +65 -0
- hoja/output.py +410 -0
- hoja/skill_content/commands.md +222 -0
- hoja/skill_content/examples.md +193 -0
- hoja/skill_content/filters.md +101 -0
- hoja/skill_content/output-formats.md +92 -0
- hoja/skill_content/overview.md +123 -0
- hoja/skill_content/workflows.md +174 -0
- hoja_cli-0.21.4.dist-info/METADATA +279 -0
- hoja_cli-0.21.4.dist-info/RECORD +107 -0
- hoja_cli-0.21.4.dist-info/WHEEL +4 -0
- hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
- hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
- hoja_server/README.md +135 -0
- hoja_server/__init__.py +11 -0
- hoja_server/abac.py +116 -0
- hoja_server/agent_chat.py +280 -0
- hoja_server/agent_conversations.py +91 -0
- hoja_server/agent_durable.py +332 -0
- hoja_server/agent_prompt.py +117 -0
- hoja_server/agent_threads.py +96 -0
- hoja_server/agent_uploads.py +60 -0
- hoja_server/app.py +2189 -0
- hoja_server/cadence.py +231 -0
- hoja_server/cli.py +191 -0
- hoja_server/cloudflare_realtime.py +110 -0
- hoja_server/comment_policy.py +70 -0
- hoja_server/connector_actions.py +119 -0
- hoja_server/connectors_api.py +375 -0
- hoja_server/db.py +1816 -0
- hoja_server/denials.py +18 -0
- hoja_server/deployment.py +28 -0
- hoja_server/email_adapter.py +94 -0
- hoja_server/google_login.py +217 -0
- hoja_server/google_oidc.py +342 -0
- hoja_server/identity_transfer.py +210 -0
- hoja_server/mcp_oauth.py +623 -0
- hoja_server/mcp_tools.py +579 -0
- hoja_server/meeting_policy.py +179 -0
- hoja_server/meetings.py +1332 -0
- hoja_server/meetings_api.py +909 -0
- hoja_server/model_providers.py +205 -0
- hoja_server/realtime.py +690 -0
- hoja_server/resolvers.py +1281 -0
- hoja_server/schema.graphql +477 -0
- hoja_server/schema_sdl.py +8 -0
- hoja_server/secrets_box.py +39 -0
- hoja_server/signup_config.py +292 -0
- hoja_server/signup_gate.py +393 -0
- hoja_server/signup_policy.py +97 -0
- hoja_server/signup_store.py +577 -0
- hoja_server/store.py +2179 -0
- hoja_server/sync.py +249 -0
- hoja_server/tenant_context.py +131 -0
- hoja_server/turnstile.py +81 -0
- hoja_server/writer.py +1262 -0
hoja/mcp/prompts.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""MCP prompt templates.
|
|
2
|
+
|
|
3
|
+
Each prompt returns a multi-line markdown string. The MCP client renders this
|
|
4
|
+
as a user message that walks the agent through running `hoja` commands in
|
|
5
|
+
a specific order. Every prompt:
|
|
6
|
+
|
|
7
|
+
1. Lists the exact shell commands to execute (in order).
|
|
8
|
+
2. Says what to look for in each response.
|
|
9
|
+
3. Includes the standard behavioral reminder at the end.
|
|
10
|
+
|
|
11
|
+
The prompts NEVER perform mutations themselves. They tell the agent which
|
|
12
|
+
`hoja` commands to call via the Bash tool.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
REMINDER = (
|
|
17
|
+
"REMINDER: run each command above via the Bash tool. Do not write Python "
|
|
18
|
+
"wrappers around Linear's GraphQL API — `hoja` is the supported "
|
|
19
|
+
"interface. Use `--output json` when piping or programmatically reading."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def triage(team_key: str) -> str:
|
|
24
|
+
"""Walk the agent through triaging unassigned issues for a team."""
|
|
25
|
+
return f"""# Triage workflow for team `{team_key}`
|
|
26
|
+
|
|
27
|
+
You are about to triage the unassigned backlog for team **{team_key}**.
|
|
28
|
+
|
|
29
|
+
## Step 1 — Scope the queue
|
|
30
|
+
|
|
31
|
+
Run:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
hoja issue list --team {team_key} --no-assignee --state Todo -n 20
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Read the list. Identify the top 3–5 issues that look highest-priority based on
|
|
38
|
+
title + label.
|
|
39
|
+
|
|
40
|
+
## Step 2 — Inspect each candidate
|
|
41
|
+
|
|
42
|
+
For each candidate ID:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
hoja issue get <ID>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Skim the description and recent comments. Decide:
|
|
49
|
+
|
|
50
|
+
- Does it have enough context? If not, comment asking the reporter.
|
|
51
|
+
- Is the priority field obviously wrong? Note the correction.
|
|
52
|
+
- Is the right label set?
|
|
53
|
+
|
|
54
|
+
## Step 3 — Act
|
|
55
|
+
|
|
56
|
+
For each issue you've decided to take action on:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
hoja issue assign <ID> <USER_NAME> # or 'me'
|
|
60
|
+
hoja issue update <ID> --priority <N> --label "<labels>"
|
|
61
|
+
hoja issue state <ID> "<NEXT_STATE>" # e.g. "In Progress"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Step 4 — Document
|
|
65
|
+
|
|
66
|
+
If the triage decision needs a paper trail (e.g. reassigning across teams),
|
|
67
|
+
post a comment:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
hoja comment add <ID> --body "Triaged: assigned to <name>, priority <N> because <reason>"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
{REMINDER}
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def daily_standup() -> str:
|
|
78
|
+
"""Render a standup-shaped report of viewer's current work."""
|
|
79
|
+
return f"""# Daily standup workflow
|
|
80
|
+
|
|
81
|
+
## Step 1 — What I shipped yesterday
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
hoja -o md issue list --assignee me --state Done \\
|
|
85
|
+
--updated-after $(date -d "1 day ago" +%Y-%m-%d) -n 20
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Step 2 — What I'm working on today
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
hoja -o md issue list --assignee me --state "In Progress" -n 20
|
|
92
|
+
hoja -o md issue list --assignee me --state "In Review" -n 20
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Step 3 — Blockers
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
hoja -o md issue list --assignee me --label blocked -n 10
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Step 4 — Compose the standup
|
|
102
|
+
|
|
103
|
+
Combine the three sections above into:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
## Yesterday
|
|
107
|
+
- <ENG-XX>: title
|
|
108
|
+
- ...
|
|
109
|
+
|
|
110
|
+
## Today
|
|
111
|
+
- <ENG-YY>: title
|
|
112
|
+
- ...
|
|
113
|
+
|
|
114
|
+
## Blockers
|
|
115
|
+
- <ENG-ZZ>: title — reason
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
{REMINDER}
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def create_from_error(error_text: str, team_key: str, priority: int = 3) -> str:
|
|
123
|
+
"""Templated issue creation from an error message / stack trace."""
|
|
124
|
+
# Extract a plausible title — first line of the error, capped at ~80 chars
|
|
125
|
+
first_line = (error_text or "").strip().splitlines()[:1]
|
|
126
|
+
title_hint = (first_line[0][:80] if first_line else "Unhandled error").strip()
|
|
127
|
+
return f"""# Create issue from error workflow
|
|
128
|
+
|
|
129
|
+
The user has provided an error trace and asked you to file an issue.
|
|
130
|
+
|
|
131
|
+
## Step 1 — Search for duplicates FIRST
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
hoja issue search "{title_hint}" -n 5
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
If a matching issue exists, do not create a new one — comment on the existing
|
|
138
|
+
issue instead:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
hoja comment add <EXISTING_ID> --body "<<<DELIM
|
|
142
|
+
{error_text}
|
|
143
|
+
DELIM"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Step 2 — If no duplicate, create
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
hoja issue create \\
|
|
150
|
+
--team {team_key} \\
|
|
151
|
+
--title "{title_hint}" \\
|
|
152
|
+
--description "<<<DELIM
|
|
153
|
+
{error_text}
|
|
154
|
+
DELIM" \\
|
|
155
|
+
--priority {priority} \\
|
|
156
|
+
--label "bug"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Step 3 — Capture the new identifier
|
|
160
|
+
|
|
161
|
+
The create response includes the new issue's identifier (e.g. `{team_key}-NN`).
|
|
162
|
+
Surface it to the user along with `hoja issue url <ID>` for a clickable link.
|
|
163
|
+
|
|
164
|
+
{REMINDER}
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def hand_off(issue_id: str, to_user: str, note: str = "") -> str:
|
|
169
|
+
"""Three-command issue hand-off pattern."""
|
|
170
|
+
note_line = note or "context: see related PR / discussion / commit"
|
|
171
|
+
return f"""# Hand-off workflow for {issue_id} → {to_user}
|
|
172
|
+
|
|
173
|
+
A clean hand-off is three commands. Run them in order:
|
|
174
|
+
|
|
175
|
+
## Step 1 — Reassign
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
hoja issue assign {issue_id} "{to_user}"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Step 2 — Move to a review/blocked state
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
hoja issue state {issue_id} "In Review"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
(Substitute the right state name. Use `hoja team states <TEAM_KEY>` if you
|
|
188
|
+
need to confirm available states for this team.)
|
|
189
|
+
|
|
190
|
+
## Step 3 — Document the hand-off
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
hoja comment add {issue_id} --body "Handing off to {to_user} — {note_line}"
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Behavioral rule:** never reassign without a comment explaining why and what
|
|
197
|
+
the receiver needs to know. A bare reassignment creates ambiguity downstream.
|
|
198
|
+
|
|
199
|
+
{REMINDER}
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cycle_review(team_key: str) -> str:
|
|
204
|
+
"""Render a current-cycle review for a team."""
|
|
205
|
+
return f"""# Cycle review workflow for team `{team_key}`
|
|
206
|
+
|
|
207
|
+
## Step 1 — Confirm there is an active cycle
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
hoja cycle current {team_key}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
If the response indicates no active cycle, stop and tell the user — there is
|
|
214
|
+
nothing to review.
|
|
215
|
+
|
|
216
|
+
## Step 2 — Inventory the cycle by state
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
hoja -o json issue list --cycle current --team {team_key} \\
|
|
220
|
+
| jq 'group_by(.state.name) | map({{state: .[0].state.name, count: length, issues: map(.identifier)}})'
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Step 3 — Flag blockers
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
hoja issue list --cycle current --team {team_key} --label blocked
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Step 4 — Flag stale issues (no update in 5+ days)
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
hoja issue list --cycle current --team {team_key} \\
|
|
233
|
+
--updated-before $(date -d "5 days ago" +%Y-%m-%d)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Step 5 — Report
|
|
237
|
+
|
|
238
|
+
Combine the above into a structured summary:
|
|
239
|
+
|
|
240
|
+
- **Done**: N issues
|
|
241
|
+
- **In Progress**: N issues
|
|
242
|
+
- **Todo (not started)**: N issues — risk if cycle ends soon
|
|
243
|
+
- **Blockers**: list with reasons
|
|
244
|
+
- **Stale**: list with last-update dates
|
|
245
|
+
|
|
246
|
+
{REMINDER}
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def issue_investigate(issue_id: str) -> str:
|
|
251
|
+
"""Deep dive on a single issue."""
|
|
252
|
+
return f"""# Investigate {issue_id} — full context dump
|
|
253
|
+
|
|
254
|
+
## Step 1 — Read the issue
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
hoja -o json issue get {issue_id}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Capture: title, description, state, priority, assignee, labels, project, cycle.
|
|
261
|
+
|
|
262
|
+
## Step 2 — Read every comment
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
hoja comment list {issue_id} -n 100
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Step 3 — Find related issues (same labels)
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
LABEL=$(hoja -o json issue get {issue_id} | jq -r '.labels[0].name // empty')
|
|
272
|
+
[ -n "$LABEL" ] && hoja issue list --label "$LABEL" -n 10
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Step 4 — Find related issues (same project / cycle)
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
PROJECT=$(hoja -o json issue get {issue_id} | jq -r '.project.id // empty')
|
|
279
|
+
[ -n "$PROJECT" ] && hoja issue list --project "$PROJECT" -n 10
|
|
280
|
+
|
|
281
|
+
CYCLE=$(hoja -o json issue get {issue_id} | jq -r '.cycle.id // empty')
|
|
282
|
+
[ -n "$CYCLE" ] && hoja -o json issue list --cycle "$CYCLE" | jq '[.[] | .identifier]'
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Step 5 — Suggest next action
|
|
286
|
+
|
|
287
|
+
Based on state + labels + comments, suggest one of:
|
|
288
|
+
- comment with a clarifying question
|
|
289
|
+
- assign (with `hoja issue assign`)
|
|
290
|
+
- move state (with `hoja issue state`)
|
|
291
|
+
- close as duplicate (set state to `Canceled` and link via comment)
|
|
292
|
+
- prioritize (with `hoja issue prio`)
|
|
293
|
+
|
|
294
|
+
Do **not** mutate the issue yet — propose the action and wait for the user's
|
|
295
|
+
go-ahead.
|
|
296
|
+
|
|
297
|
+
{REMINDER}
|
|
298
|
+
"""
|
hoja/mcp/resources.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Read-only Linear resources exposed via MCP.
|
|
2
|
+
|
|
3
|
+
Each resource handler:
|
|
4
|
+
1. Resolves auth (LinearClient via env / config).
|
|
5
|
+
2. Issues a GraphQL query.
|
|
6
|
+
3. Validates response into a Pydantic model.
|
|
7
|
+
4. Returns JSON text (string).
|
|
8
|
+
|
|
9
|
+
If auth is missing, the handler raises `RuntimeError` with a friendly message;
|
|
10
|
+
the FastMCP runtime maps that to a JSON-RPC error so the server keeps running.
|
|
11
|
+
|
|
12
|
+
All resources are **read-only**. Mutations live in the `hoja` CLI — the
|
|
13
|
+
agent is expected to call those via the Bash tool. The `reminder` field on
|
|
14
|
+
the tool response and the prompt bodies reinforce this rule.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from hoja.client import LinearClient
|
|
22
|
+
from hoja.config import load_config, resolve_account, resolve_token
|
|
23
|
+
from hoja.errors import AuthError, HojaError
|
|
24
|
+
from hoja.graphql import queries
|
|
25
|
+
from hoja.models.cycle import Cycle
|
|
26
|
+
from hoja.models.issue import Issue
|
|
27
|
+
from hoja.models.project import Project
|
|
28
|
+
from hoja.models.team import Team
|
|
29
|
+
from hoja.models.user import User
|
|
30
|
+
from hoja.models.workflow import WorkflowState
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Auth helper
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
def _client() -> LinearClient:
|
|
37
|
+
"""Build a LinearClient using the same resolution rules as the CLI.
|
|
38
|
+
|
|
39
|
+
Order: $HOJA_TOKEN env var (deprecated alias $LINEAR_TOKEN) > config.toml.
|
|
40
|
+
The `--token` CLI flag is not applicable here (the MCP server has no CLI
|
|
41
|
+
args for the token).
|
|
42
|
+
"""
|
|
43
|
+
cfg = load_config()
|
|
44
|
+
try:
|
|
45
|
+
_account_name, account_cfg = resolve_account(None, cfg)
|
|
46
|
+
token = resolve_token(None, account_cfg)
|
|
47
|
+
except AuthError as e:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
"hoja-mcp could not resolve an API token. "
|
|
50
|
+
"Set $HOJA_TOKEN, or run `hoja init` and put your token in "
|
|
51
|
+
"~/.config/hoja/config.toml. "
|
|
52
|
+
f"Underlying error: {e}"
|
|
53
|
+
) from e
|
|
54
|
+
return LinearClient(token)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _to_json(model: Any) -> str:
|
|
58
|
+
"""Serialize a Pydantic model (or list of them) to indented JSON."""
|
|
59
|
+
if isinstance(model, list):
|
|
60
|
+
return json.dumps(
|
|
61
|
+
[m.model_dump(mode="json", exclude_none=True) for m in model],
|
|
62
|
+
indent=2,
|
|
63
|
+
ensure_ascii=False,
|
|
64
|
+
)
|
|
65
|
+
if hasattr(model, "model_dump"):
|
|
66
|
+
return model.model_dump_json(indent=2, exclude_none=True)
|
|
67
|
+
return json.dumps(model, indent=2, ensure_ascii=False, default=str)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Resource handlers
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
async def viewer() -> str:
|
|
74
|
+
"""hoja://me — currently-authenticated user."""
|
|
75
|
+
async with _client() as c:
|
|
76
|
+
user = await c.execute_as(
|
|
77
|
+
User, queries.VIEWER, path=["viewer"], operation="Viewer"
|
|
78
|
+
)
|
|
79
|
+
return _to_json(user)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def issue(id: str) -> str:
|
|
83
|
+
"""hoja://issue/{id} — full Issue including comments, labels, subscribers."""
|
|
84
|
+
async with _client() as c:
|
|
85
|
+
issue_model = await c.execute_as(
|
|
86
|
+
Issue,
|
|
87
|
+
queries.ISSUE_BY_IDENTIFIER,
|
|
88
|
+
{"id": id},
|
|
89
|
+
path=["issue"],
|
|
90
|
+
operation="IssueByIdentifier",
|
|
91
|
+
)
|
|
92
|
+
return _to_json(issue_model)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def team(key: str) -> str:
|
|
96
|
+
"""hoja://team/{key} — team + states + members rolled into one payload."""
|
|
97
|
+
async with _client() as c:
|
|
98
|
+
# Resolve team id from key
|
|
99
|
+
team_data = await c.execute(
|
|
100
|
+
queries.TEAM_BY_KEY, {"key": key}, operation="TeamByKey"
|
|
101
|
+
)
|
|
102
|
+
nodes = (team_data.get("teams") or {}).get("nodes") or []
|
|
103
|
+
if not nodes:
|
|
104
|
+
raise RuntimeError(f"No team found with key {key!r}")
|
|
105
|
+
team_model = Team.model_validate(nodes[0])
|
|
106
|
+
|
|
107
|
+
# States
|
|
108
|
+
states_raw = await c.execute(
|
|
109
|
+
queries.TEAM_STATES, {"teamId": team_model.id}, operation="TeamStates"
|
|
110
|
+
)
|
|
111
|
+
state_nodes = (
|
|
112
|
+
(states_raw.get("team") or {}).get("states") or {}
|
|
113
|
+
).get("nodes") or []
|
|
114
|
+
states = [WorkflowState.model_validate(s) for s in state_nodes]
|
|
115
|
+
|
|
116
|
+
# Members
|
|
117
|
+
members_raw = await c.execute(
|
|
118
|
+
queries.TEAM_MEMBERS, {"teamId": team_model.id}, operation="TeamMembers"
|
|
119
|
+
)
|
|
120
|
+
member_nodes = (
|
|
121
|
+
(members_raw.get("team") or {}).get("members") or {}
|
|
122
|
+
).get("nodes") or []
|
|
123
|
+
members = [User.model_validate(m) for m in member_nodes]
|
|
124
|
+
|
|
125
|
+
payload = {
|
|
126
|
+
"team": team_model.model_dump(mode="json", exclude_none=True),
|
|
127
|
+
"states": [s.model_dump(mode="json", exclude_none=True) for s in states],
|
|
128
|
+
"members": [m.model_dump(mode="json", exclude_none=True) for m in members],
|
|
129
|
+
}
|
|
130
|
+
return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def project(id_or_slug: str) -> str:
|
|
134
|
+
"""hoja://project/{id_or_slug} — Project with lead + members."""
|
|
135
|
+
async with _client() as c:
|
|
136
|
+
proj = await c.execute_as(
|
|
137
|
+
Project,
|
|
138
|
+
queries.PROJECT_BY_ID,
|
|
139
|
+
{"id": id_or_slug},
|
|
140
|
+
path=["project"],
|
|
141
|
+
operation="Project",
|
|
142
|
+
)
|
|
143
|
+
return _to_json(proj)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def cycle_current(team_key: str) -> str:
|
|
147
|
+
"""hoja://cycle/current/{team_key} — active cycle or {"active_cycle": null}."""
|
|
148
|
+
async with _client() as c:
|
|
149
|
+
# Resolve team id from key
|
|
150
|
+
team_data = await c.execute(
|
|
151
|
+
queries.TEAM_BY_KEY, {"key": team_key}, operation="TeamByKey"
|
|
152
|
+
)
|
|
153
|
+
nodes = (team_data.get("teams") or {}).get("nodes") or []
|
|
154
|
+
if not nodes:
|
|
155
|
+
raise RuntimeError(f"No team found with key {team_key!r}")
|
|
156
|
+
team_id = nodes[0]["id"]
|
|
157
|
+
|
|
158
|
+
# Active cycle
|
|
159
|
+
cycle_query = """
|
|
160
|
+
query ActiveCycle($teamId: String!) {
|
|
161
|
+
team(id: $teamId) {
|
|
162
|
+
activeCycle { id name number startsAt endsAt completedAt progress }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
"""
|
|
166
|
+
raw = await c.execute(cycle_query, {"teamId": team_id}, operation="ActiveCycle")
|
|
167
|
+
active = (raw.get("team") or {}).get("activeCycle")
|
|
168
|
+
if not active:
|
|
169
|
+
return json.dumps({"active_cycle": None, "team_key": team_key}, indent=2)
|
|
170
|
+
cycle_model = Cycle.model_validate(active)
|
|
171
|
+
return _to_json(cycle_model)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def issues_mine() -> str:
|
|
175
|
+
"""hoja://issues/mine — viewer's open issues (Todo + In Progress + In Review)."""
|
|
176
|
+
async with _client() as c:
|
|
177
|
+
viewer_data = await c.execute(queries.VIEWER, operation="Viewer")
|
|
178
|
+
viewer_id = (viewer_data.get("viewer") or {}).get("id")
|
|
179
|
+
if not viewer_id:
|
|
180
|
+
raise RuntimeError("Could not resolve viewer id")
|
|
181
|
+
|
|
182
|
+
filt = {
|
|
183
|
+
"assignee": {"id": {"eq": viewer_id}},
|
|
184
|
+
"state": {"type": {"in": ["unstarted", "started"]}},
|
|
185
|
+
}
|
|
186
|
+
issues = await c.execute_list(
|
|
187
|
+
Issue,
|
|
188
|
+
queries.ISSUES_LIST,
|
|
189
|
+
{"filter": filt, "first": 50},
|
|
190
|
+
path=["issues"],
|
|
191
|
+
operation="Issues",
|
|
192
|
+
)
|
|
193
|
+
return _to_json(issues)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def issues_team(team_key: str) -> str:
|
|
197
|
+
"""hoja://issues/team/{team_key} — open issues for a team."""
|
|
198
|
+
async with _client() as c:
|
|
199
|
+
filt = {
|
|
200
|
+
"team": {"key": {"eq": team_key}},
|
|
201
|
+
"state": {"type": {"in": ["unstarted", "started"]}},
|
|
202
|
+
}
|
|
203
|
+
try:
|
|
204
|
+
issues = await c.execute_list(
|
|
205
|
+
Issue,
|
|
206
|
+
queries.ISSUES_LIST,
|
|
207
|
+
{"filter": filt, "first": 50},
|
|
208
|
+
path=["issues"],
|
|
209
|
+
operation="Issues",
|
|
210
|
+
)
|
|
211
|
+
except HojaError as e:
|
|
212
|
+
raise RuntimeError(f"Could not list issues for team {team_key!r}: {e}") from e
|
|
213
|
+
return _to_json(issues)
|
hoja/mcp/server.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""hoja-mcp — Model Context Protocol server for the hoja CLI.
|
|
2
|
+
|
|
3
|
+
Run via the `hoja-mcp` console script (registered in pyproject.toml).
|
|
4
|
+
Uses the official `mcp` Python SDK with FastMCP convenience layer.
|
|
5
|
+
|
|
6
|
+
Exposes:
|
|
7
|
+
- 1 tool : hoja_guide(topic) — teaches the agent how to use hoja
|
|
8
|
+
- 7 resources : read-only Linear context
|
|
9
|
+
- 6 prompts : workflow templates
|
|
10
|
+
|
|
11
|
+
By design the server does NOT expose mutation tools. Mutations happen via the
|
|
12
|
+
`hoja` CLI in a shell — the tool's response payload and the prompt bodies
|
|
13
|
+
both carry a behavioral reminder reinforcing this rule.
|
|
14
|
+
|
|
15
|
+
STDIO TRANSPORT NOTE: When running on stdio (the default), stdout is reserved
|
|
16
|
+
for the JSON-RPC stream. ALL logging must go to stderr. We configure that at
|
|
17
|
+
the top of this module before any other import that might log.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
# Route logging to stderr BEFORE importing anything that might log.
|
|
26
|
+
logging.basicConfig(
|
|
27
|
+
stream=sys.stderr,
|
|
28
|
+
level=logging.INFO,
|
|
29
|
+
format="[hoja-mcp] %(levelname)s %(message)s",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Module-level imports for types used in tool/resource/prompt signatures.
|
|
33
|
+
# The MCP SDK uses inspect.signature(..., eval_str=True) which resolves
|
|
34
|
+
# stringified annotations against MODULE globals — so Topic/HojaGuide
|
|
35
|
+
# must be importable at module level, not just inside _build_server().
|
|
36
|
+
from . import prompts as _prompts # noqa: E402
|
|
37
|
+
from . import resources as _resources # noqa: E402
|
|
38
|
+
from .content import HojaGuide, Topic, load_topic # noqa: E402
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> None:
|
|
42
|
+
"""Entry point for the `hoja-mcp` console script.
|
|
43
|
+
|
|
44
|
+
Lazy-imports the MCP SDK so that `import hoja` (without the [mcp] extra)
|
|
45
|
+
does not fail at the top of this module. If the dep is missing, print a
|
|
46
|
+
friendly install hint to stderr and exit 2.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
from mcp.server.mcpserver import MCPServer # noqa: F401
|
|
50
|
+
except ImportError:
|
|
51
|
+
sys.stderr.write(
|
|
52
|
+
"hoja-mcp requires the [mcp] extra.\nInstall with:\n pip install 'hoja[mcp]'\n"
|
|
53
|
+
)
|
|
54
|
+
sys.exit(2)
|
|
55
|
+
|
|
56
|
+
_build_server().run()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_server():
|
|
60
|
+
"""Wire tools, resources, and prompts onto an MCP v2 server."""
|
|
61
|
+
from mcp.server.mcpserver import MCPServer
|
|
62
|
+
|
|
63
|
+
from hoja import __version__
|
|
64
|
+
|
|
65
|
+
mcp = MCPServer(
|
|
66
|
+
name="hoja",
|
|
67
|
+
instructions=(
|
|
68
|
+
"Use this server to learn how to drive Linear from a shell using "
|
|
69
|
+
"the `hoja` CLI. Call `hoja_guide(topic)` BEFORE attempting "
|
|
70
|
+
"any Linear-related task. Read resources for read-only context. "
|
|
71
|
+
"Read prompts for multi-step workflow templates. Perform all "
|
|
72
|
+
"mutations via `hoja <command>` invoked through the Bash tool."
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Tool — single teaching tool
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
@mcp.tool()
|
|
80
|
+
def hoja_guide(topic: Topic = Topic.OVERVIEW) -> HojaGuide:
|
|
81
|
+
"""Return a guide on how to use the `hoja` CLI for a given topic.
|
|
82
|
+
|
|
83
|
+
ALWAYS call this BEFORE any Linear-related task. Then execute hoja
|
|
84
|
+
commands via the Bash tool. Do NOT write Python wrappers around the
|
|
85
|
+
Linear GraphQL API — hoja is the supported interface.
|
|
86
|
+
|
|
87
|
+
Available topics:
|
|
88
|
+
- overview — when to reach for hoja; behavioral rules
|
|
89
|
+
- commands — full command tree with examples
|
|
90
|
+
- workflows — common multi-step patterns
|
|
91
|
+
- filters — `issue list` filter DSL
|
|
92
|
+
- output-formats — when to use --output json vs human vs ids
|
|
93
|
+
- examples — copy-pasteable recipes
|
|
94
|
+
"""
|
|
95
|
+
return load_topic(topic)
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Resources — read-only Linear context
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
@mcp.resource("hoja://me", name="viewer", description="Currently-authenticated Linear user.")
|
|
101
|
+
async def res_me() -> str:
|
|
102
|
+
return await _resources.viewer()
|
|
103
|
+
|
|
104
|
+
@mcp.resource(
|
|
105
|
+
"hoja://issue/{id}",
|
|
106
|
+
name="issue",
|
|
107
|
+
description="Full Linear issue by identifier (e.g. ENG-35), including comments, labels, subscribers.",
|
|
108
|
+
)
|
|
109
|
+
async def res_issue(id: str) -> str:
|
|
110
|
+
return await _resources.issue(id)
|
|
111
|
+
|
|
112
|
+
@mcp.resource(
|
|
113
|
+
"hoja://team/{key}",
|
|
114
|
+
name="team",
|
|
115
|
+
description="Team detail + workflow states + members for a team key (e.g. ENG).",
|
|
116
|
+
)
|
|
117
|
+
async def res_team(key: str) -> str:
|
|
118
|
+
return await _resources.team(key)
|
|
119
|
+
|
|
120
|
+
@mcp.resource(
|
|
121
|
+
"hoja://project/{id_or_slug}",
|
|
122
|
+
name="project",
|
|
123
|
+
description="Linear project by id or slug, including lead, creator, members.",
|
|
124
|
+
)
|
|
125
|
+
async def res_project(id_or_slug: str) -> str:
|
|
126
|
+
return await _resources.project(id_or_slug)
|
|
127
|
+
|
|
128
|
+
@mcp.resource(
|
|
129
|
+
"hoja://cycle/current/{team_key}",
|
|
130
|
+
name="cycle-current",
|
|
131
|
+
description="Active cycle for a team. Returns {active_cycle: null} when none.",
|
|
132
|
+
)
|
|
133
|
+
async def res_cycle(team_key: str) -> str:
|
|
134
|
+
return await _resources.cycle_current(team_key)
|
|
135
|
+
|
|
136
|
+
@mcp.resource(
|
|
137
|
+
"hoja://issues/mine",
|
|
138
|
+
name="issues-mine",
|
|
139
|
+
description="Open issues assigned to the viewer (Todo + In Progress).",
|
|
140
|
+
)
|
|
141
|
+
async def res_issues_mine() -> str:
|
|
142
|
+
return await _resources.issues_mine()
|
|
143
|
+
|
|
144
|
+
@mcp.resource(
|
|
145
|
+
"hoja://issues/team/{team_key}",
|
|
146
|
+
name="issues-team",
|
|
147
|
+
description="Open issues for a team (Todo + In Progress).",
|
|
148
|
+
)
|
|
149
|
+
async def res_issues_team(team_key: str) -> str:
|
|
150
|
+
return await _resources.issues_team(team_key)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# Prompts — workflow templates
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
@mcp.prompt(description="Triage unassigned issues for a team — step-by-step shell commands.")
|
|
156
|
+
def triage(team_key: str) -> str:
|
|
157
|
+
return _prompts.triage(team_key)
|
|
158
|
+
|
|
159
|
+
@mcp.prompt(description="Generate a daily standup report from viewer's recent activity.")
|
|
160
|
+
def daily_standup() -> str:
|
|
161
|
+
return _prompts.daily_standup()
|
|
162
|
+
|
|
163
|
+
@mcp.prompt(description="Create an issue from an error trace, with duplicate-search step.")
|
|
164
|
+
def create_from_error(error_text: str, team_key: str, priority: int = 3) -> str:
|
|
165
|
+
return _prompts.create_from_error(error_text, team_key, priority)
|
|
166
|
+
|
|
167
|
+
@mcp.prompt(description="Hand off an issue to another teammate in 3 commands.")
|
|
168
|
+
def hand_off(issue_id: str, to_user: str, note: str = "") -> str:
|
|
169
|
+
return _prompts.hand_off(issue_id, to_user, note)
|
|
170
|
+
|
|
171
|
+
@mcp.prompt(
|
|
172
|
+
description="Review the current cycle for a team — done/in-progress/blockers/stale."
|
|
173
|
+
)
|
|
174
|
+
def cycle_review(team_key: str) -> str:
|
|
175
|
+
return _prompts.cycle_review(team_key)
|
|
176
|
+
|
|
177
|
+
@mcp.prompt(description="Deep-dive investigation of a single issue.")
|
|
178
|
+
def issue_investigate(issue_id: str) -> str:
|
|
179
|
+
return _prompts.issue_investigate(issue_id)
|
|
180
|
+
|
|
181
|
+
logging.info(
|
|
182
|
+
"hoja-mcp v%s ready (tools=1, resources=7, prompts=6)",
|
|
183
|
+
__version__,
|
|
184
|
+
)
|
|
185
|
+
return mcp
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
main()
|