Add full OpenSpec change `mcp-resource-templates` covering the design for exposing core Forgejo entities as URI-addressable MCP resources: owner, repo, commit, issue, pr, comment, and commit-status. Artifacts: - proposal.md: why + capability list for all 7 entities - design.md: 13 decisions (URI scheme, coexist-vs-replace, bounding, caching, auth, rollout order, client compat matrix) - specs/: 8 spec files with ADDED Requirements + Scenario blocks - tasks.md: 7 slices (core framework → entity-by-entity → docs) Driven by forgejo-mcp-1l5. Implementation via /opsx:apply.
14 KiB
Context
forgejo-mcp exposes Forgejo entities exclusively through MCP tools today. operation/operation.go constructs the server with server.WithLogging() only — no server.WithResourceCapabilities(...) — and every RegisterXTool function calls s.AddTool(...). The fourteen registered tool domains in operation/ cover the same entities this change wants to model as resources (issue, pr, repo, comment, …) plus mutating actions.
Two upstream facts shape the design:
- mcp-go v0.44.0 is in
go.mod. It providesmcp.NewResourceTemplate(uriTemplate, name, opts...)(RFC 6570 templates),server.AddResourceTemplate(template, handler), andserver.WithResourceCapabilities(subscribe, listChanged bool). Handlers return[]mcp.ResourceContents(text or blob variants), so each resource fetch can return one or more content blocks. - Output bounding is a hard rule (
docs/design/output-bounding.md). Any output proportional to repository state must expose a caller-controlled bound and a way to fetch the remainder. Resource handlers must respect this even though MCPresources/readhas no nativelimit/pageparameter — bounding has to happen inside the embedded payload.
Stakeholders: MCP client authors (Claude Code/Desktop, Codex, Cursor) consuming the server; this project's tool surface (must not regress); the Forgejo API rate limiter (resource reads become an additional traffic source).
Goals / Non-Goals
Goals:
- A single coherent URI scheme (
forgejo://…) covering all seven entities so client UX is uniform. - Per-domain
RegisterResources(s *server.MCPServer)entry points that mirror the existingRegisterXToolpattern, keeping the codebase navigable. - Resources coexist with tools: clients without resource-template support keep working unchanged.
- Embedded lists inside resources (e.g. comments on an issue) follow the existing output-bounding contract.
- Cacheability hints set correctly: commit + commit-status are immutable per-sha and surfaced as such; issue/pr/comment are mutable and not flagged cacheable.
Non-Goals:
- Implementation. This change ships proposal + design + specs + tasks only; code lands under a follow-up change.
- Mutating resources. No write-through. Mutations stay on tools.
- Subscriptions.
server.WithResourceCapabilities(subscribe=false, listChanged=false)for v1. Noresources/subscribehandler. - Wiki / projects resources. Blocked upstream (see
docs/plans/wiki-support.md,docs/plans/projects-support.md). - Webhook / event resources. Out of scope.
- Replacing existing read tools.
get_issue_by_index,get_pull_request_by_index, etc. remain. Resources duplicate the read surface for clients that prefer URI addressing; the duplication is intentional during the migration window.
Decisions
D1: URI scheme — forgejo:// (custom) over https://…/api/v1/…
forgejo://repo/{owner}/{repo}/commit/{sha} rather than the upstream REST URL.
Rationale: clients should not have to know which Forgejo instance the server is talking to (Codeberg vs self-hosted). The scheme also gives a clean discriminator between "this is an MCP resource URI" and "this is a web link the agent should open" — important because Forgejo https:// URLs appear constantly in PR bodies and would otherwise collide with the resource namespace.
Alternatives considered:
https://<instance>/<owner>/<repo>/commit/<sha>: tempting because users paste these. Rejected because (a) it leaks instance hostname into URIs (multi-instance deployments break), (b) creates ambiguity with link-fetching behavior, (c) requires URI parsers to special-case whichhttps://URLs are MCP resources.forgejo+resource://: more explicit but ugly and unnecessary; the scheme already identifies the server.
D2: Path shape — forgejo://repo/{owner}/{repo}/<entity>/<key> (entity nested under repo)
All repo-scoped entities sit under repo/{owner}/{repo}/. Owner is a top-level peer: forgejo://owner/{name}.
Rationale: every entity except owner is repo-scoped in Forgejo; the path mirrors the API. Makes URIs predictable and templates easy to write as RFC 6570.
Alternative considered: flatter forgejo://issue/{owner}/{repo}/{index}. Rejected because it splits the natural "this lives in a repo" parent across siblings; harder to scan.
D3: Comment URIs discriminate on parent kind
forgejo://repo/{owner}/{repo}/{kind}/{index}/comment/{id} where {kind} ∈ issue | pr.
Rationale: Forgejo's API uses one comment table internally but the parent context matters for the agent (permissions, rendering, references). Embedding kind makes the URI self-describing — no need to fetch the parent to know whether a comment lives on an issue or PR.
Alternative considered: forgejo://repo/{owner}/{repo}/comment/{id}. Rejected because IDs collide across kinds in unhelpful ways and the agent loses context.
D4: Combined status URI — …/commit/{sha}/status (singular)
forgejo://repo/{owner}/{repo}/commit/{sha}/status returns the combined status (aggregate state + per-context entries). The per-context list view stays a tool (get_commit_statuses) to keep the resource a single addressable thing.
Rationale: aggregate is what callers actually want when they ask "is this sha green?". The per-context list is bounded data that benefits from page/limit parameters tools support natively but resources don't.
D5: Resource capability declaration — WithResourceCapabilities(false, false)
Subscribe = false, listChanged = false.
Rationale: v1 is read-on-demand only. listChanged would force the server to track template changes at runtime; since templates are static at startup, false is correct. Subscribe is explicitly punted — see Non-Goals.
D6: Per-domain RegisterResources(s) entry point
Each domain under operation/{domain}/ that owns one or more resources adds a RegisterResources(s *server.MCPServer) function alongside its existing RegisterTool. operation/operation.go gains a parallel list of RegisterXResources(s) calls.
Rationale: minimises diff against current layout. A reader who knows where the issue tools live finds the issue resources in the same package. No new top-level directory.
Alternative considered: a single operation/resources/ package with all handlers. Rejected because it collects unrelated entity code under one roof, defeating the per-domain locality the rest of the codebase relies on.
D7: Shared URI parser in operation/resource/
A new package (note singular) holding: RFC 6570 template registration helpers, URI parsing into typed structs (e.g. parseCommitURI(req.Params.URI) (owner, repo, sha string, err error)), and embedded-list bounding helpers. Each domain handler depends on this package, not the other way round.
Rationale: avoids duplicating URI parsing across seven domains. Keeps the per-domain handlers tiny — they go straight from typed params to a pkg/forgejo SDK call.
D8: Embedded-list bounding — sentinel + index tool reference
When a resource embeds a variable-size list (issue comments on the issue resource, statuses on a commit-status resource), the embedded payload is capped at N items (default 30, exposed as a server-side constant; revisit if telemetry shows truncation hitting frequently) and ends with a sentinel block:
[truncated: N of M items shown. Use list_<entity> tool with page=2 to fetch more.]
This satisfies docs/design/output-bounding.md sub-rules 1 (no silent truncation) and 3 (always resumable) without inventing a new query parameter on resources/read. The sentinel names the existing list tool, completing the contract.
Alternative considered: parameterise via URI query (?limit=50). Rejected because RFC 6570 template matching makes optional query params painful and most MCP clients don't surface them in URIs the LLM constructs.
D9: MIME types — application/json for entity bodies, text/markdown for rendered text fields
Each resource handler returns one or more mcp.ResourceContents. Primary content is the entity as JSON (application/json). Long markdown fields (issue body, PR description, commit message body) MAY additionally be returned as a sibling text/markdown content block, letting clients pick the form they want.
Rationale: JSON is the lingua franca for structured agent reasoning. Markdown sidecars cost little and dramatically improve LLM comprehension of formatted bodies.
D10: Caching hints — annotations on immutable resources
mcp.WithTemplateAnnotations on mcp-resource-commit and mcp-resource-status carries audience: [Role.Agent] and priority: 1.0 plus a description noting immutability. mcp-go does not currently surface an explicit cache header in the resource result, so the signal lives in the human-readable description until upstream adds one. We do NOT roll our own cache headers.
Risk noted under Risks.
D11: Auth & errors — reuse pkg/forgejo singleton + map 403/404 to MCP errors
Resource handlers go through the same client used by tools. On 403 return MCP error code -32002 (custom — match what existing tools return for access denial); on 404 return code -32003. Both with descriptive messages.
Rationale: identical semantics to today's tool errors. Clients that already handle tool-error patterns get the same UX for resources.
D12: Client compatibility — design for resources/templates/list consumers; gracefully degrade for tool-only clients
Targets clients that implement MCP resources/templates/list and resources/read (Claude Code, Claude Desktop, Codex, current Cursor). Tool-only clients keep working: every entity remains reachable via existing tools, so absence of resource support costs nothing.
Compatibility matrix to publish in README:
| Client | resources/templates/list |
resources/read |
Notes |
|---|---|---|---|
| Claude Code | ✅ | ✅ | Primary target. |
| Claude Desktop | ✅ | ✅ | URL-pasted forgejo:// URIs auto-resolve. |
| Codex | ✅ | ✅ | Same as Claude Code. |
| Cursor (current) | ✅ | ✅ | Verify in implementation. |
| Older / minimal | ⚠️ Tools only | ⚠️ Tools only | No regression — tools unchanged. |
D13: Rollout order — mcp-resources-core + mcp-resource-commit as the thin slice
Implementation slices land in this order, each as an independent PR:
mcp-resources-core(framework + URI parser + capability registration)mcp-resource-commit(highest value — replaces curl-for-statuses workflows)mcp-resource-statusmcp-resource-repo+mcp-resource-ownermcp-resource-issue+mcp-resource-commentmcp-resource-pr
Rationale: slice 1+2 delivers real value (commit/status fetching by sha) on its own. Each subsequent slice is independently shippable behind its own RegisterXResources call.
Risks / Trade-offs
-
[Inconsistent client behavior on resource templates] → Pin compatibility matrix in README; verify each target client during implementation slice 1; keep tools as the fallback contract so non-supporting clients never lose functionality.
-
[Caching not enforceable from server side in mcp-go v0.44.0] → Annotate immutable resources via description; revisit when upstream exposes explicit cache hints. Document the gap in README so client implementers don't assume server-side caching.
-
[Duplicate read surface (tool + resource) confuses agents and inflates LLM context] → Specs explicitly call out coexistence; README publishes a single recommendation table ("prefer resource when you have a sha/index in hand; prefer tool when listing or searching"). If telemetry shows agents picking the wrong one, revisit by deprecating overlap rather than removing it.
-
[Embedded-list truncation pointing at a wrong list tool] → Sentinel format is centralised in
operation/resource/; each entity test asserts the sentinel names a tool that actually exists. Catches drift at compile + test time. -
[Forgejo API rate-limit pressure from resource reads] → Embedded lists are bounded (D8); resource handlers do not pre-fetch related entities; commit + status resources are inherently cacheable so heavy callers can dedupe by sha at the client layer.
-
[URI-scheme collision with future Forgejo features] →
forgejo://is owned by this server's contract. Document the namespace in README; new entities must extend it explicitly via a new change. No catch-all path. -
[
pull_request_target-style security concerns from agents auto-resolving URIs] → Resources are read-only and route through the same auth as tools. No new attack surface beyond existing tool reads. Documented in README security section.
Migration Plan
Strictly additive. No rollback strategy needed for existing tools.
Per-slice rollout (D13). Each slice ships with:
- New
RegisterXResources(s)registration inoperation/operation.go. - Per-entity handler + tests.
- README update appending to the resource table.
Rollback for any slice: revert the registration call in operation/operation.go. Resources disappear from resources/templates/list; tools remain.
Open Questions
- Default embedded-list cap (D8) — 30 the right number? Pulled from existing list-tool defaults. Should validate against typical issue/PR comment thread depth on Codeberg before locking in. Possibly raise per-entity (e.g. PRs have more review comments than issues).
- Should
forgejo://owner/{name}distinguish users vs orgs in the URI? Current proposal does not. If clients want to know without fetching, we'd addforgejo://user/{name}andforgejo://org/{name}instead. Defer until a real use case surfaces. - Markdown sidecar (D9) — return always, or only when the field is non-trivial (> N chars)? Default to always for simplicity; revisit if context bloat becomes a problem.
- Compatibility matrix verification (D12) — needs hands-on testing against each target client in implementation slice 1. Spec acceptance for
mcp-resources-coreshould include a test plan covering this.