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
140 lines
4.4 KiB
Go
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")
|
|
}
|