Adds the spec-driven change artifacts for exposing Forgejo organization labels through the MCP server: - proposal: motivation, capabilities, impact - design: decisions, trade-offs, impl approach via DoJSONList - specs: new list_org_labels capability + modified list_repo_labels with include_org_labels merge and scope tagging - tasks: 5-group breakdown covering helper, new tool, augmentation, docs, and verification Tracked as forgejo-mcp-7ch (blocked by forgejo-mcp-xv0 OpenSpec). Closes forgejo-mcp-xv0.
6.2 KiB
Context
Forgejo separates label storage into two scopes:
- Repository labels —
GET /repos/{owner}/{repo}/labels— exposed today vialist_repo_labels(added by the in-flightlist-milestones-labelschange). - Organization labels —
GET /orgs/{org}/labels— usable on issues in any repo owned by the org, but not exposed by the MCP at all.
forgejo-sdk/forgejo/v3@v3.0.0 provides bindings for the repo endpoint only. There is no ListOrgLabels function on the SDK client. The codebase has already established a pattern for working around SDK gaps: pkg/forgejo/raw_http.go exposes DoJSON, DoJSONList, DoMultipart, and DoRaw helpers that share auth, user-agent, and logging with the SDK client. The attachment tools use DoJSONList to call endpoints not bound by the SDK.
The reporter (Codeberg #125) explicitly asks that org labels be merged into list_repo_labels so the model does not need two separate calls.
Goals / Non-Goals
Goals
- Expose org-level labels through the MCP for both discovery (
list_org_labels) and combined listing (list_repo_labelswith merge). - Avoid SDK upgrades — use the existing raw-HTTP helper.
- Preserve current
list_repo_labelssemantics for callers that opt out of the merge. - Make scope unambiguous: the merged response must let a caller tell whether a given label ID is repo- or org-scoped.
Non-Goals
- Creating, editing, or deleting org labels (write-side org-label tools).
- Caching label results across calls.
- Cross-org listing or label-search tools.
- SDK contributions to upstream
forgejo-sdk(tracked separately).
Decisions
D1: Use pkg/forgejo.DoJSONList rather than vendoring the SDK
Two alternatives considered:
- Fork/vendor the SDK and add
ListOrgLabels— high friction, ongoing maintenance burden, breaks our "track upstream SDK" stance. - Use the existing
DoJSONListraw-HTTP helper — already used for attachments, share auth/UA/logging, no new code paths.
Choose (2). When the SDK gains ListOrgLabels, swap the call in one place.
D2: Two tools, not one
Alternatives:
- Single tool
list_repo_labelswithinclude_org_labelsflag, no standalone org tool. - Standalone
list_org_labelstool, no merge inlist_repo_labels. - Both — standalone
list_org_labelsplus opt-in merge inlist_repo_labels. (chosen)
(3) covers the reporter's "one call" need while still giving callers that already know the org name a direct path that doesn't require a repo. Single-responsibility wins for the standalone tool; ergonomics win for the merged form.
D3: include_org_labels defaults to true
Default-on matches the reporter's expectation ("no reason for the model to make 2 separate requests") and matches what the model would naturally want when discovering valid IDs. Callers wanting strict repo-only behavior pass false explicitly.
D4: Stamp each label with scope: "repo" | "org"
Repo labels and org labels live in disjoint ID spaces in Forgejo, but a caller looking at a merged list needs to know which scope each ID belongs to — for documentation, for filtering, and to surface the distinction to the LLM. The scope field is added by the handler, not returned by the underlying API. We define a small wrapper type rather than mutating SDK structs.
D5: Skip the org call when owner is not an org
list_repo_labels already has the owner string. We could:
- Always attempt
/orgs/{owner}/labelsand rely on 404 → empty (DoJSONListhandles this). - Pre-check via
/orgs/{owner}and skip the second call for user-owned repos.
Choose (1). One unconditional GET is simpler than a conditional probe-then-fetch and the helper already maps 404 to an empty list with no error. The trade-off is a wasted round-trip for user-owned repos, which is acceptable given the call is light and rare in practice.
D6: Pagination is shared
include_org_labels reuses the page and limit params. We do not split them, because most repos and orgs have small (≤100) label counts and split params double the parameter surface for marginal benefit. Documented behavior: pagination applies to each scope independently — repo labels paginated by page/limit, org labels paginated by the same page/limit. Callers who care can call list_org_labels directly.
Risks / Trade-offs
- [Org-label call adds latency to
list_repo_labelsfor org-owned repos] → One extra GET only when merge is enabled (default), and the helper is already tuned for low-overhead calls. Caller can disable viainclude_org_labels=false. - [Wasted GET for user-owned repos] → Mitigated by the helper's 404→empty mapping; observable in logs but harmless. Future optimization could cache
is_orgper owner. - [Label name collision between repo and org scope] → Distinct IDs in Forgejo + the
scopefield in the response disambiguate. Documented in the spec. - [
list-milestones-labelschange has not yet archived, so there is nolist_repo_labelscapability spec to delta] → This change shipslist_org_labelsas a new capability immediately; thelist_repo_labelsmodified-capability delta lands once the parent change archives. Implementation can proceed in parallel; only the spec sync is order-dependent. - [SDK gains
ListOrgLabelslater] → Single call site in our handler; swap is trivial. No public contract change.
Migration Plan
Additive only — no migration. New tool plus a new optional parameter on an existing tool.
Rollout:
- Land
list-milestones-labelschange (already in flight). - Land this change. New tool registers; existing callers of
list_repo_labelscontinue to work becauseinclude_org_labelsdefaults totruebut thescopefield is purely additive on the wire. - Update
docs/PROMPTING.mdexamples if any reference label IDs.
Rollback: revert the commit. Tool de-registers cleanly; no persistent state.
Open Questions
- Should
scopebe present on results fromlist_org_labelstoo? Probably yes for consistency — every label out of the system carries scope, regardless of which tool returned it. Decided: yes, scope is always present. - Do we want a
scopefilter onlist_repo_labels(e.g.scope=orgto return only org labels)? Out of scope for this change; revisit if requested.