forgejo-mcp/operation/user/resources_owner.go
Christoph Görn 19f1efd000
fix: 🐛 owner resource: surface orgErr on transport failure, explicit -32003 on both-404
ownerResourceHandler had two related bugs in the user→org fallback:
- when GetOrg failed with orgResp==nil (network/DNS/context-cancel),
  the handler returned MapForgejoError(uri, userErr), masking the
  real transport error behind the original user 404;
- if userErr happened to be nil (u==nil, err==nil SDK edge), the
  same call produced MapForgejoError(nil)==nil and the handler
  returned (nil, nil), violating MCP semantics.

Surface orgErr on transport failure. On both-404 (or any other
ambiguous outcome) return an explicit *ResourceError with code
-32003.

Closes forgejo-mcp-91j.
Ref PR #172 review https://codeberg.org/goern/forgejo-mcp/pulls/172#issuecomment-16022165
2026-05-28 16:11:26 +02:00

140 lines
4.4 KiB
Go

package user
import (
"context"
"encoding/json"
"fmt"
"strings"
"codeberg.org/goern/forgejo-mcp/v2/operation/resource"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
"codeberg.org/goern/forgejo-mcp/v2/pkg/log"
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const ownerResourceURITemplate = "forgejo://owner/{owner}"
// ownerResourcePayload is the JSON body returned for user or org.
type ownerResourcePayload struct {
Login string `json:"login"`
FullName string `json:"full_name,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
Kind string `json:"kind"` // "user" or "org"
Description string `json:"description,omitempty"`
Location string `json:"location,omitempty"`
Website string `json:"website,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
FollowersCount int `json:"followers_count,omitempty"`
FollowingCount int `json:"following_count,omitempty"`
PublicReposCount int `json:"public_repos_count,omitempty"`
}
// RegisterOwnerResource registers the forgejo://owner/{owner} resource template.
func RegisterOwnerResource(s *server.MCPServer) {
resource.RegisterTemplate(
s,
ownerResourceURITemplate,
"Forgejo Owner",
ownerResourceHandler,
mcp.WithTemplateDescription(
"User or organization profile addressed by login. "+
"Tries user first; falls back to org if user returns 404. "+
"URI: forgejo://owner/{owner}.",
),
mcp.WithTemplateMIMEType("application/json"),
)
log.Debug("Registered owner resource template")
}
func ownerResourceHandler(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := req.Params.URI
params, err := resource.ParseOwner(uri)
if err != nil {
return nil, resource.MapForgejoError(uri, err)
}
client, err := forgejo.Client(ctx)
if err != nil {
return nil, fmt.Errorf("forgejo client: %w", err)
}
u, userResp, userErr := client.GetUserInfo(params.Owner)
var payload ownerResourcePayload
if userErr == nil && u != nil {
payload = ownerResourcePayload{
Login: u.UserName,
FullName: u.FullName,
HTMLURL: u.HTMLURL,
Kind: "user",
Description: u.Description,
Location: u.Location,
Website: u.Website,
CreatedAt: u.Created.Format("2006-01-02T15:04:05Z07:00"),
FollowersCount: u.FollowerCount,
FollowingCount: u.FollowingCount,
}
} else {
// if user fetch failed for a reason other than 404, surface it immediately
if userErr != nil && !is404(userResp, userErr) {
if userResp != nil {
return nil, resource.MapForgejoError(uri, fmt.Errorf("%d %s", userResp.StatusCode, userErr.Error()))
}
return nil, resource.MapForgejoError(uri, userErr)
}
// fall back to org
org, orgResp, orgErr := client.GetOrg(params.Owner)
if orgErr != nil {
// Both user and org are "not found" → explicit -32003 with a clear message.
// This also guards the SDK edge where userErr==nil (u==nil, err==nil).
if is404(orgResp, orgErr) && (userErr == nil || is404(userResp, userErr)) {
return nil, &resource.ResourceError{URI: uri, Code: -32003, Message: "owner not found: " + uri}
}
// orgResp != nil (non-404 HTTP error): surface the status + org message.
if orgResp != nil {
return nil, resource.MapForgejoError(uri, fmt.Errorf("%d %s", orgResp.StatusCode, orgErr.Error()))
}
// orgResp == nil: real transport/network/context error — surface orgErr.
return nil, resource.MapForgejoError(uri, orgErr)
}
payload = ownerResourcePayload{
Login: org.UserName,
FullName: org.FullName,
Kind: "org",
Description: org.Description,
Location: org.Location,
Website: org.Website,
}
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal owner payload: %w", err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: uri,
MIMEType: "application/json",
Text: string(jsonBytes),
},
}, nil
}
// is404 returns true if the response status or error message indicates HTTP 404.
func is404(resp *forgejo_sdk.Response, err error) bool {
if resp != nil && resp.StatusCode == 404 {
return true
}
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "404") || strings.Contains(msg, "Not Found") || strings.Contains(msg, "not found")
}