From f5109abfad4b57d717b93f55f7d3da4763c8a596 Mon Sep 17 00:00:00 2001 From: Alexandru Macocian Date: Mon, 25 May 2026 15:22:30 +0200 Subject: [PATCH] Gitea mcp pre-release --- README.md | 2 +- cmd/gitea-mcp/client.go | 93 ++++++++ cmd/gitea-mcp/gitea_clientid_charlie.go | 27 +++ cmd/gitea-mcp/main.go | 179 +++++++++++++++ cmd/gitea-mcp/tools.go | 78 +++++++ cmd/gitea-mcp/tools_actions.go | 277 +++++++++++++++++++++++ cmd/gitea-mcp/tools_commits.go | 191 ++++++++++++++++ cmd/gitea-mcp/tools_content.go | 144 ++++++++++++ cmd/gitea-mcp/tools_issues.go | 237 ++++++++++++++++++++ cmd/gitea-mcp/tools_orgs.go | 285 ++++++++++++++++++++++++ cmd/gitea-mcp/tools_packages.go | 53 +++++ cmd/gitea-mcp/tools_pulls.go | 272 ++++++++++++++++++++++ cmd/gitea-mcp/tools_refs.go | 193 ++++++++++++++++ cmd/gitea-mcp/tools_releases.go | 99 ++++++++ cmd/gitea-mcp/tools_repo.go | 68 ++++++ cmd/gitea-mcp/tools_user.go | 35 +++ cmd/gitea-mcp/tools_wiki.go | 128 +++++++++++ cmd/sherlock/main.go | 35 ++- docs/agents.md | 7 +- docs/gitea-mcp.md | 221 ++++++++++++++++++ docs/service-registry.md | 115 ---------- go.mod | 9 +- go.sum | 20 +- internal/agent/agent.go | 13 +- internal/agent/agent_test.go | 30 +++ internal/agent/claude.go | 8 +- internal/agent/copilot.go | 21 +- internal/agent/exec.go | 27 ++- internal/agent/pathaug.go | 73 ++++++ internal/agent/pathaug_test.go | 82 +++++++ internal/authn/authn.go | 10 +- internal/authn/login.go | 10 +- internal/authn/refresh.go | 7 +- internal/browser/browser.go | 37 +++ internal/keyring/keyring.go | 7 +- internal/mcp/config.go | 41 ++-- internal/mcp/config_test.go | 40 ++-- 37 files changed, 2975 insertions(+), 199 deletions(-) create mode 100644 cmd/gitea-mcp/client.go create mode 100644 cmd/gitea-mcp/gitea_clientid_charlie.go create mode 100644 cmd/gitea-mcp/main.go create mode 100644 cmd/gitea-mcp/tools.go create mode 100644 cmd/gitea-mcp/tools_actions.go create mode 100644 cmd/gitea-mcp/tools_commits.go create mode 100644 cmd/gitea-mcp/tools_content.go create mode 100644 cmd/gitea-mcp/tools_issues.go create mode 100644 cmd/gitea-mcp/tools_orgs.go create mode 100644 cmd/gitea-mcp/tools_packages.go create mode 100644 cmd/gitea-mcp/tools_pulls.go create mode 100644 cmd/gitea-mcp/tools_refs.go create mode 100644 cmd/gitea-mcp/tools_releases.go create mode 100644 cmd/gitea-mcp/tools_repo.go create mode 100644 cmd/gitea-mcp/tools_user.go create mode 100644 cmd/gitea-mcp/tools_wiki.go create mode 100644 docs/gitea-mcp.md delete mode 100644 docs/service-registry.md create mode 100644 internal/agent/pathaug.go create mode 100644 internal/agent/pathaug_test.go create mode 100644 internal/browser/browser.go diff --git a/README.md b/README.md index ce9ba47..950d910 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Design + phasing: [plan.md](plan.md). - [Architecture](docs/architecture.md) - [Auth model](docs/auth-model.md) - [Storage & keyring](docs/storage.md) -- [Service registry](docs/service-registry.md) - [Agents](docs/agents.md) +- [gitea-mcp](docs/gitea-mcp.md) - [gssh integration](docs/gssh-integration.md) - [Conventions](docs/conventions.md) diff --git a/cmd/gitea-mcp/client.go b/cmd/gitea-mcp/client.go new file mode 100644 index 0000000..265b489 --- /dev/null +++ b/cmd/gitea-mcp/client.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// giteaAPI is a thin wrapper around the few Gitea REST endpoints we +// use. We don't pull in code.gitea.io/sdk/gitea because its client +// only knows how to send `Authorization: token `, not the +// `Authorization: Bearer ` that Gitea's OAuth2 server issues. +type giteaAPI struct { + baseURL string + token string + client *http.Client +} + +// get does a GET against path (with optional query) and JSON-decodes +// into into. +func (g *giteaAPI) get(ctx context.Context, path string, query url.Values, into any) error { + full := g.baseURL + path + if len(query) > 0 { + full += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+g.token) + req.Header.Set("Accept", "application/json") + resp, err := g.client.Do(req) + if err != nil { + return err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + if err := checkStatus(resp, req); err != nil { + return err + } + if into == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(into) +} + +// getRaw returns the raw body (up to maxBytes; anything beyond is +// silently dropped). Useful for endpoints that return plain text or +// binary (e.g. job logs). +func (g *giteaAPI) getRaw(ctx context.Context, path string, query url.Values, maxBytes int64) ([]byte, bool, error) { + full := g.baseURL + path + if len(query) > 0 { + full += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) + if err != nil { + return nil, false, err + } + req.Header.Set("Authorization", "Bearer "+g.token) + req.Header.Set("Accept", "*/*") + resp, err := g.client.Do(req) + if err != nil { + return nil, false, err + } + defer func() { _ = resp.Body.Close() }() + if err := checkStatus(resp, req); err != nil { + return nil, false, err + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) + if err != nil { + return nil, false, err + } + truncated := false + if int64(len(body)) > maxBytes { + body = body[:maxBytes] + truncated = true + } + return body, truncated, nil +} + +func checkStatus(resp *http.Response, req *http.Request) error { + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("gitea: 401 (token rejected by %s; try `sherlock logout gitea` and retry)", req.URL.Host) + } + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("gitea: %s %s: HTTP %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} diff --git a/cmd/gitea-mcp/gitea_clientid_charlie.go b/cmd/gitea-mcp/gitea_clientid_charlie.go new file mode 100644 index 0000000..6020004 --- /dev/null +++ b/cmd/gitea-mcp/gitea_clientid_charlie.go @@ -0,0 +1,27 @@ +//go:build !noembed + +// This file bakes Charlie's homelab Gitea OAuth2 application +// constants into the binary. It's enabled by default; build with +// `-tags noembed` to drop these and supply your own via -ldflags +// (see the comment above the giteaXxx vars in main.go). +// +// The client ID is a public OAuth2 client identifier — it's not a +// secret; it's announced by sherlock on every authorize request. +// The client secret is a Gitea-generated value that's optional for +// our setup (Gitea accepts PKCE alone for the registered app), kept +// empty. +package main + +func init() { + if giteaIssuer == "" { + giteaIssuer = "https://gitea.alexandru.macocian.me" + } + if giteaBaseURL == "" { + giteaBaseURL = "https://gitea.alexandru.macocian.me" + } + if giteaClientID == "" { + giteaClientID = "6d3de919-2f58-4981-8e68-c41bc97c6df5" + } + // giteaClientSecret is intentionally left empty — Gitea accepts + // PKCE alone for the Charlie sherlock application. +} diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go new file mode 100644 index 0000000..359744f --- /dev/null +++ b/cmd/gitea-mcp/main.go @@ -0,0 +1,179 @@ +// Command gitea-mcp is sherlock's MCP server for Gitea. It exposes a +// read-mostly slice of the Gitea REST API (whoami, list_repos, +// get_file, file_history, list_workflow_runs / _jobs, get_job_logs, +// list_packages), all authenticated as the operator via Gitea's own +// OAuth2 / OIDC server with PKCE. +// +// Two modes: +// +// gitea-mcp start the stdio MCP server (this is what agents spawn) +// gitea-mcp --probe run the auth flow + one API call, print result, exit +// +// `--probe` is for end-to-end verification without an agent in the +// loop. The first invocation (or after `sherlock logout gitea`) opens +// a browser; subsequent invocations are silent until the refresh +// window opens. +// +// Configuration is compile-time. The Charlie defaults are baked in +// via gitea_clientid_charlie.go (drop with `-tags noembed` to retarget). +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent" + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn" + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/browser" + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring" + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg" +) + +// serviceName is the wallet key under which gitea tokens live. The +// sherlock CLI's `logout gitea` clears the same entry. +const serviceName = "gitea" + +// Compile-time configuration. The default values target the Charlie +// homelab and are populated by gitea_clientid_charlie.go (the +// `charlie` build tag is on by default — see go:build line there). +// +// To target a different deployment, build with `-tags noembed` to +// drop the Charlie constants and supply your own via -ldflags: +// +// go build -tags noembed \ +// -ldflags "-X main.giteaIssuer=https://gitea.example.com \ +// -X main.giteaClientID=... \ +// -X main.giteaClientSecret=... \ +// -X main.giteaBaseURL=https://gitea.example.com" \ +// ./cmd/gitea-mcp +// +// giteaClientID must be set to the OAuth2 application ID the operator +// created in Gitea (Settings → Applications → OAuth2 Applications → +// New Application, redirect URI `http://127.0.0.1:6990/callback`). +// +// giteaClientSecret is the secret Gitea generates for the same +// application. Some Gitea versions require it on the token-exchange +// step even for native/desktop apps that primarily use PKCE; leave it +// empty if your Gitea accepts PKCE alone. +var ( + giteaIssuer string + giteaClientID string + giteaClientSecret string + giteaBaseURL string +) + +// Version is overwritten at build time via -ldflags "-X main.Version=...". +var Version = "0.0.0-dev" + +// scopes asked from Gitea's OAuth2 server. We pull every read scope +// the tools surface needs — `read:issue` covers issues, PRs, and +// comments; `read:organization` covers orgs, members, teams, and +// org-level actions; `read:package` keeps list_packages honest even +// when Gitea tightens defaults; `read:user` lets the token hit +// /api/v1/user for whoami. +// +// Changing this list invalidates older wallet entries (they were +// granted a narrower scope). Operators must `sherlock logout gitea` +// and let the next gitea-mcp invocation re-auth. +var scopes = []string{ + "openid", "profile", "email", + "read:user", + "read:repository", + "read:issue", + "read:organization", + "read:package", +} + +func main() { + probe := flag.Bool("probe", false, "run auth + one API call, print result, exit") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVersion { + fmt.Println(Version) + return + } + + if giteaClientID == "" { + fmt.Fprintln(os.Stderr, "gitea-mcp: giteaClientID not configured.") + fmt.Fprintln(os.Stderr, "Build with -ldflags \"-X main.giteaClientID=...\" (and -tags noembed if you don't want the Charlie default) after creating an OAuth2 Application in Gitea (redirect URI http://127.0.0.1:6990/callback).") + os.Exit(2) + } + + store, err := keyring.Open() + if err != nil { + fmt.Fprintln(os.Stderr, "gitea-mcp:", err) + os.Exit(3) + } + + // If gitea-mcp ever shells out to siblings (today it doesn't), + // this keeps the "no manual setup" promise. + agent.EnsurePathContainsSiblings() + + lockPath, err := xdg.RefreshLockPath() + if err != nil { + fmt.Fprintln(os.Stderr, "gitea-mcp:", err) + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + cfg := authn.Config{ + Issuer: giteaIssuer, + ClientID: giteaClientID, + ClientSecret: giteaClientSecret, + Scopes: scopes, + } + ts, err := authn.Ensure(ctx, store, serviceName, cfg, authn.EnsureOptions{ + LockPath: lockPath, + OnAuthURL: func(u string) { + fmt.Fprintln(os.Stderr, "gitea-mcp: opening browser for Gitea OAuth login:") + fmt.Fprintln(os.Stderr, " ", u) + if err := browser.Open(u); err != nil { + fmt.Fprintln(os.Stderr, "gitea-mcp: open browser:", err) + fmt.Fprintln(os.Stderr, "(visit the URL above manually)") + } + }, + }) + if err != nil { + fmt.Fprintln(os.Stderr, "gitea-mcp: auth:", err) + os.Exit(5) + } + + api := &giteaAPI{ + baseURL: strings.TrimRight(giteaBaseURL, "/"), + token: ts.AccessToken, + client: &http.Client{Timeout: 30 * time.Second}, + } + + if *probe { + var u struct { + Login string `json:"login"` + Email string `json:"email"` + } + if err := api.get(ctx, "/api/v1/user", nil, &u); err != nil { + fmt.Fprintln(os.Stderr, "gitea-mcp: whoami:", err) + os.Exit(5) + } + fmt.Printf("OK: logged in to %s as %s <%s>\n", giteaBaseURL, u.Login, u.Email) + return + } + + server := mcp.NewServer(&mcp.Implementation{Name: "gitea-mcp", Version: Version}, nil) + registerTools(server, api) + + if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil && !errors.Is(err, context.Canceled) { + fmt.Fprintln(os.Stderr, "gitea-mcp:", err) + os.Exit(1) + } +} diff --git a/cmd/gitea-mcp/tools.go b/cmd/gitea-mcp/tools.go new file mode 100644 index 0000000..ab7d750 --- /dev/null +++ b/cmd/gitea-mcp/tools.go @@ -0,0 +1,78 @@ +package main + +import ( + "net/url" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// registerTools wires every tool onto server. Each `tools_*.go` file +// owns one domain (issues, pulls, actions, …); this file just calls +// into them and provides cross-domain helpers. +func registerTools(server *mcp.Server, api *giteaAPI) { + registerUserTools(server, api) + registerRepoTools(server, api) + registerContentTools(server, api) + registerCommitTools(server, api) + registerIssueTools(server, api) + registerPullTools(server, api) + registerReleaseTools(server, api) + registerRefTools(server, api) + registerWikiTools(server, api) + registerActionsTools(server, api) + registerPackageTools(server, api) + registerOrgTools(server, api) +} + +// clampInt returns dflt when v <= 0, else v clipped to [min, max]. +func clampInt(v, min, max, dflt int) int { + if v <= 0 { + return dflt + } + if v < min { + return min + } + if v > max { + return max + } + return v +} + +func firstLine(s string) string { + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + return s[:i] + } + } + return s +} + +// escapePathSegments escapes each segment of a file path individually +// so that slashes survive but per-segment specials (#, ?, etc.) get +// percent-encoded. url.PathEscape on the whole path would escape the +// slashes too and break Gitea's path-based routing. +func escapePathSegments(p string) string { + var b []byte + first := true + from := 0 + for i := 0; i <= len(p); i++ { + if i == len(p) || p[i] == '/' { + seg := p[from:i] + if !first { + b = append(b, '/') + } + b = append(b, url.PathEscape(seg)...) + first = false + from = i + 1 + } + } + return string(b) +} + +// setIf sets q[k]=v when v is non-empty. Saves a lot of three-line +// if-non-empty blocks in the tool handlers. +func setIf(q url.Values, k, v string) { + if v != "" { + q.Set(k, v) + } +} diff --git a/cmd/gitea-mcp/tools_actions.go b/cmd/gitea-mcp/tools_actions.go new file mode 100644 index 0000000..5d3e7ef --- /dev/null +++ b/cmd/gitea-mcp/tools_actions.go @@ -0,0 +1,277 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const maxLogBytes = 100 * 1024 + +func registerActionsTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_workflow_runs", + Description: "List Gitea Actions workflow runs (pipelines) for a repository, optionally filtered by status / branch / event.", + }, listWorkflowRunsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_workflow_jobs", + Description: "List the jobs of a specific workflow run. Returned job IDs are what get_job_logs takes.", + }, listWorkflowJobsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_job_logs", + Description: "Fetch the logs for a single Gitea Actions job. Returns the last 100 KiB of output (truncated=true when older lines were dropped).", + }, getJobLogsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_org_workflow_runs", + Description: "List Actions workflow runs across all repos owned by an organisation.", + }, listOrgWorkflowRunsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_org_workflow_jobs", + Description: "List Actions jobs across all runs in an organisation, optionally filtered by status.", + }, listOrgWorkflowJobsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_org_runners", + Description: "List self-hosted Actions runners registered to an organisation.", + }, listOrgRunnersHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_org_runner", + Description: "Get details for a single self-hosted Actions runner of an organisation.", + }, getOrgRunnerHandler(api)) +} + +type workflowRun struct { + ID int64 `json:"id"` + Name string `json:"name,omitempty"` + WorkflowID string `json:"workflow_id,omitempty"` + HeadBranch string `json:"head_branch,omitempty"` + HeadSHA string `json:"head_sha,omitempty"` + Event string `json:"event,omitempty"` + Status string `json:"status,omitempty"` + Conclusion string `json:"conclusion,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + URL string `json:"url,omitempty"` +} +type workflowJob struct { + ID int64 `json:"id"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + Conclusion string `json:"conclusion,omitempty"` + RunID int64 `json:"run_id,omitempty"` + StartedAt string `json:"started_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` + URL string `json:"url,omitempty"` +} +type runner struct { + ID int64 `json:"id"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + OwnerType string `json:"owner_type,omitempty"` + Description string `json:"description,omitempty"` + Labels []string `json:"labels,omitempty"` + Version string `json:"version,omitempty"` +} + +type listWorkflowRunsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Status string `json:"status,omitempty" jsonschema:"filter: pending|queued|in_progress|failure|success|skipped"` + Branch string `json:"branch,omitempty"` + Event string `json:"event,omitempty" jsonschema:"filter by trigger event (push, pull_request, …)"` + Limit int `json:"limit,omitempty" jsonschema:"max runs to return (1-50, default 20)"` +} +type listWorkflowRunsOutput struct { + Runs []workflowRun `json:"runs"` +} + +func listWorkflowRunsHandler(api *giteaAPI) mcp.ToolHandlerFor[listWorkflowRunsInput, listWorkflowRunsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listWorkflowRunsInput) (*mcp.CallToolResult, listWorkflowRunsOutput, error) { + if in.Owner == "" || in.Repo == "" { + return nil, listWorkflowRunsOutput{}, fmt.Errorf("owner and repo are required") + } + limit := clampInt(in.Limit, 1, 50, 20) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + setIf(q, "status", in.Status) + setIf(q, "branch", in.Branch) + setIf(q, "event", in.Event) + path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs", + url.PathEscape(in.Owner), url.PathEscape(in.Repo)) + var resp struct { + TotalCount int `json:"total_count"` + WorkflowRuns []workflowRun `json:"workflow_runs"` + } + if err := api.get(ctx, path, q, &resp); err != nil { + return nil, listWorkflowRunsOutput{}, err + } + return nil, listWorkflowRunsOutput{Runs: resp.WorkflowRuns}, nil + } +} + +type listWorkflowJobsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + RunID int64 `json:"run_id" jsonschema:"workflow run ID from list_workflow_runs"` +} +type listWorkflowJobsOutput struct { + Jobs []workflowJob `json:"jobs"` +} + +func listWorkflowJobsHandler(api *giteaAPI) mcp.ToolHandlerFor[listWorkflowJobsInput, listWorkflowJobsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listWorkflowJobsInput) (*mcp.CallToolResult, listWorkflowJobsOutput, error) { + if in.Owner == "" || in.Repo == "" || in.RunID == 0 { + return nil, listWorkflowJobsOutput{}, fmt.Errorf("owner, repo, and run_id are required") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/jobs", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), in.RunID) + var resp struct { + TotalCount int `json:"total_count"` + Jobs []workflowJob `json:"jobs"` + } + if err := api.get(ctx, path, nil, &resp); err != nil { + return nil, listWorkflowJobsOutput{}, err + } + return nil, listWorkflowJobsOutput{Jobs: resp.Jobs}, nil + } +} + +type getJobLogsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + JobID int64 `json:"job_id" jsonschema:"job ID from list_workflow_jobs"` +} +type getJobLogsOutput struct { + JobID int64 `json:"job_id"` + Logs string `json:"logs"` + Bytes int `json:"bytes"` + Truncated bool `json:"truncated,omitempty"` +} + +func getJobLogsHandler(api *giteaAPI) mcp.ToolHandlerFor[getJobLogsInput, getJobLogsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getJobLogsInput) (*mcp.CallToolResult, getJobLogsOutput, error) { + if in.Owner == "" || in.Repo == "" || in.JobID == 0 { + return nil, getJobLogsOutput{}, fmt.Errorf("owner, repo, and job_id are required") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/jobs/%d/logs", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), in.JobID) + body, truncated, err := api.getRaw(ctx, path, nil, 4*1024*1024) + if err != nil { + return nil, getJobLogsOutput{}, err + } + s := string(body) + if len(s) > maxLogBytes { + s = "...[earlier output dropped]...\n" + s[len(s)-maxLogBytes:] + truncated = true + } + return nil, getJobLogsOutput{JobID: in.JobID, Logs: s, Bytes: len(body), Truncated: truncated}, nil + } +} + +type listOrgWorkflowRunsInput struct { + Org string `json:"org"` + Status string `json:"status,omitempty"` + Limit int `json:"limit,omitempty" jsonschema:"max runs to return (1-50, default 20)"` +} + +func listOrgWorkflowRunsHandler(api *giteaAPI) mcp.ToolHandlerFor[listOrgWorkflowRunsInput, listWorkflowRunsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listOrgWorkflowRunsInput) (*mcp.CallToolResult, listWorkflowRunsOutput, error) { + if in.Org == "" { + return nil, listWorkflowRunsOutput{}, fmt.Errorf("org is required") + } + limit := clampInt(in.Limit, 1, 50, 20) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + setIf(q, "status", in.Status) + path := fmt.Sprintf("/api/v1/orgs/%s/actions/runs", url.PathEscape(in.Org)) + var resp struct { + TotalCount int `json:"total_count"` + WorkflowRuns []workflowRun `json:"workflow_runs"` + } + if err := api.get(ctx, path, q, &resp); err != nil { + return nil, listWorkflowRunsOutput{}, err + } + return nil, listWorkflowRunsOutput{Runs: resp.WorkflowRuns}, nil + } +} + +type listOrgWorkflowJobsInput struct { + Org string `json:"org"` + Status string `json:"status,omitempty"` + Limit int `json:"limit,omitempty"` +} + +func listOrgWorkflowJobsHandler(api *giteaAPI) mcp.ToolHandlerFor[listOrgWorkflowJobsInput, listWorkflowJobsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listOrgWorkflowJobsInput) (*mcp.CallToolResult, listWorkflowJobsOutput, error) { + if in.Org == "" { + return nil, listWorkflowJobsOutput{}, fmt.Errorf("org is required") + } + limit := clampInt(in.Limit, 1, 50, 20) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + setIf(q, "status", in.Status) + path := fmt.Sprintf("/api/v1/orgs/%s/actions/jobs", url.PathEscape(in.Org)) + var resp struct { + TotalCount int `json:"total_count"` + Jobs []workflowJob `json:"jobs"` + } + if err := api.get(ctx, path, q, &resp); err != nil { + return nil, listWorkflowJobsOutput{}, err + } + return nil, listWorkflowJobsOutput{Jobs: resp.Jobs}, nil + } +} + +type listOrgRunnersInput struct { + Org string `json:"org"` + Limit int `json:"limit,omitempty"` +} +type listOrgRunnersOutput struct { + Runners []runner `json:"runners"` +} + +func listOrgRunnersHandler(api *giteaAPI) mcp.ToolHandlerFor[listOrgRunnersInput, listOrgRunnersOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listOrgRunnersInput) (*mcp.CallToolResult, listOrgRunnersOutput, error) { + if in.Org == "" { + return nil, listOrgRunnersOutput{}, fmt.Errorf("org is required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + path := fmt.Sprintf("/api/v1/orgs/%s/actions/runners", url.PathEscape(in.Org)) + var resp struct { + TotalCount int `json:"total_count"` + Runners []runner `json:"runners"` + } + if err := api.get(ctx, path, q, &resp); err != nil { + return nil, listOrgRunnersOutput{}, err + } + return nil, listOrgRunnersOutput{Runners: resp.Runners}, nil + } +} + +type getOrgRunnerInput struct { + Org string `json:"org"` + RunnerID int64 `json:"runner_id"` +} +type getOrgRunnerOutput struct { + Runner runner `json:"runner"` +} + +func getOrgRunnerHandler(api *giteaAPI) mcp.ToolHandlerFor[getOrgRunnerInput, getOrgRunnerOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getOrgRunnerInput) (*mcp.CallToolResult, getOrgRunnerOutput, error) { + if in.Org == "" || in.RunnerID == 0 { + return nil, getOrgRunnerOutput{}, fmt.Errorf("org and runner_id are required") + } + path := fmt.Sprintf("/api/v1/orgs/%s/actions/runners/%d", url.PathEscape(in.Org), in.RunnerID) + var r runner + if err := api.get(ctx, path, nil, &r); err != nil { + return nil, getOrgRunnerOutput{}, err + } + return nil, getOrgRunnerOutput{Runner: r}, nil + } +} diff --git a/cmd/gitea-mcp/tools_commits.go b/cmd/gitea-mcp/tools_commits.go new file mode 100644 index 0000000..d5ffa6d --- /dev/null +++ b/cmd/gitea-mcp/tools_commits.go @@ -0,0 +1,191 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerCommitTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_commits", + Description: "List commits in a repository's history. Optional sha/branch starting point, since/until date filters (ISO 8601).", + }, listCommitsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_commit", + Description: "Get a single commit by SHA, including the full message and parent SHAs.", + }, getCommitHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_commit_status", + Description: "Get the combined CI / status check state for a commit or branch ref.", + }, getCommitStatusHandler(api)) +} + +type commitSummary struct { + SHA string `json:"sha"` + Message string `json:"message"` + Author string `json:"author,omitempty"` + Date string `json:"date,omitempty"` + URL string `json:"url,omitempty"` + Parents []string `json:"parents,omitempty"` +} + +type listCommitsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + SHA string `json:"sha,omitempty" jsonschema:"branch / tag / commit SHA to start from (default: repo default branch)"` + Since string `json:"since,omitempty" jsonschema:"only commits on or after this ISO 8601 timestamp"` + Until string `json:"until,omitempty" jsonschema:"only commits on or before this ISO 8601 timestamp"` + Limit int `json:"limit,omitempty"` +} +type listCommitsOutput struct { + Commits []commitSummary `json:"commits"` +} + +func listCommitsHandler(api *giteaAPI) mcp.ToolHandlerFor[listCommitsInput, listCommitsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listCommitsInput) (*mcp.CallToolResult, listCommitsOutput, error) { + if in.Owner == "" || in.Repo == "" { + return nil, listCommitsOutput{}, fmt.Errorf("owner and repo are required") + } + limit := clampInt(in.Limit, 1, 50, 20) + q := url.Values{ + "limit": []string{strconv.Itoa(limit)}, + "stat": []string{"false"}, + "verification": []string{"false"}, + "files": []string{"false"}, + } + setIf(q, "sha", in.SHA) + setIf(q, "since", in.Since) + setIf(q, "until", in.Until) + path := fmt.Sprintf("/api/v1/repos/%s/%s/commits", + url.PathEscape(in.Owner), url.PathEscape(in.Repo)) + var raw []struct { + SHA string `json:"sha"` + HTMLURL string `json:"html_url"` + RepoCommit struct { + Message string `json:"message"` + Author struct { + Name string `json:"name"` + Date string `json:"date"` + } `json:"author"` + } `json:"commit"` + Parents []struct { + SHA string `json:"sha"` + } `json:"parents"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listCommitsOutput{}, err + } + out := listCommitsOutput{Commits: make([]commitSummary, 0, len(raw))} + for _, c := range raw { + parents := make([]string, 0, len(c.Parents)) + for _, p := range c.Parents { + parents = append(parents, p.SHA) + } + out.Commits = append(out.Commits, commitSummary{ + SHA: c.SHA, Message: firstLine(c.RepoCommit.Message), + Author: c.RepoCommit.Author.Name, Date: c.RepoCommit.Author.Date, + URL: c.HTMLURL, Parents: parents, + }) + } + return nil, out, nil + } +} + +type getCommitInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + SHA string `json:"sha"` +} +type getCommitOutput struct { + SHA string `json:"sha"` + Message string `json:"message"` + Author string `json:"author,omitempty"` + Email string `json:"email,omitempty"` + Date string `json:"date,omitempty"` + URL string `json:"url,omitempty"` + Parents []string `json:"parents,omitempty"` + TreeSHA string `json:"tree_sha,omitempty"` +} + +func getCommitHandler(api *giteaAPI) mcp.ToolHandlerFor[getCommitInput, getCommitOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getCommitInput) (*mcp.CallToolResult, getCommitOutput, error) { + if in.Owner == "" || in.Repo == "" || in.SHA == "" { + return nil, getCommitOutput{}, fmt.Errorf("owner, repo, and sha are required") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/git/commits/%s", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), url.PathEscape(in.SHA)) + var raw struct { + SHA string `json:"sha"` + HTMLURL string `json:"html_url"` + Message string `json:"message"` + Author struct { + Name string `json:"name"` + Email string `json:"email"` + Date string `json:"date"` + } `json:"author"` + Tree struct { + SHA string `json:"sha"` + } `json:"tree"` + Parents []struct { + SHA string `json:"sha"` + } `json:"parents"` + } + if err := api.get(ctx, path, nil, &raw); err != nil { + return nil, getCommitOutput{}, err + } + parents := make([]string, 0, len(raw.Parents)) + for _, p := range raw.Parents { + parents = append(parents, p.SHA) + } + return nil, getCommitOutput{ + SHA: raw.SHA, Message: raw.Message, + Author: raw.Author.Name, Email: raw.Author.Email, Date: raw.Author.Date, + URL: raw.HTMLURL, Parents: parents, TreeSHA: raw.Tree.SHA, + }, nil + } +} + +type getCommitStatusInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Ref string `json:"ref" jsonschema:"commit SHA or branch/tag name"` +} +type commitStatus struct { + Context string `json:"context"` + State string `json:"state"` + Description string `json:"description,omitempty"` + TargetURL string `json:"target_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} +type getCommitStatusOutput struct { + State string `json:"state" jsonschema:"combined: success | pending | failure | error | warning"` + TotalCount int `json:"total_count"` + Statuses []commitStatus `json:"statuses"` +} + +func getCommitStatusHandler(api *giteaAPI) mcp.ToolHandlerFor[getCommitStatusInput, getCommitStatusOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getCommitStatusInput) (*mcp.CallToolResult, getCommitStatusOutput, error) { + if in.Owner == "" || in.Repo == "" || in.Ref == "" { + return nil, getCommitStatusOutput{}, fmt.Errorf("owner, repo, and ref are required") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/commits/%s/status", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), url.PathEscape(in.Ref)) + var resp struct { + State string `json:"state"` + TotalCount int `json:"total_count"` + Statuses []commitStatus `json:"statuses"` + } + if err := api.get(ctx, path, nil, &resp); err != nil { + return nil, getCommitStatusOutput{}, err + } + return nil, getCommitStatusOutput{ + State: resp.State, TotalCount: resp.TotalCount, Statuses: resp.Statuses, + }, nil + } +} diff --git a/cmd/gitea-mcp/tools_content.go b/cmd/gitea-mcp/tools_content.go new file mode 100644 index 0000000..b998df8 --- /dev/null +++ b/cmd/gitea-mcp/tools_content.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "encoding/base64" + "fmt" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerContentTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "get_file", + Description: "Read the contents of a file in a Gitea repository at a given path and optional ref (branch/tag/commit). Capped at 256 KiB; over that, only the first 256 KiB is returned and truncated=true.", + }, getFileHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "file_history", + Description: "List commits that touched a specific file path in a repository, newest first.", + }, fileHistoryHandler(api)) +} + +const maxFileBytes = 256 * 1024 + +type getFileInput struct { + Owner string `json:"owner" jsonschema:"repository owner (user or org)"` + Repo string `json:"repo" jsonschema:"repository name"` + Path string `json:"path" jsonschema:"path of the file inside the repository"` + Ref string `json:"ref,omitempty" jsonschema:"optional branch / tag / commit SHA (default: repository default branch)"` +} +type getFileOutput struct { + Path string `json:"path"` + Ref string `json:"ref,omitempty"` + SHA string `json:"sha,omitempty"` + Size int64 `json:"size"` + Encoding string `json:"encoding,omitempty"` + Content string `json:"content"` + Truncated bool `json:"truncated,omitempty"` + IsDirectory bool `json:"is_directory,omitempty"` +} + +func getFileHandler(api *giteaAPI) mcp.ToolHandlerFor[getFileInput, getFileOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getFileInput) (*mcp.CallToolResult, getFileOutput, error) { + if in.Owner == "" || in.Repo == "" || in.Path == "" { + return nil, getFileOutput{}, fmt.Errorf("owner, repo, and path are required") + } + q := url.Values{} + setIf(q, "ref", in.Ref) + path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), escapePathSegments(in.Path)) + var resp struct { + Type string `json:"type"` + Encoding string `json:"encoding"` + Size int64 `json:"size"` + Path string `json:"path"` + SHA string `json:"sha"` + Content string `json:"content"` + } + if err := api.get(ctx, path, q, &resp); err != nil { + return nil, getFileOutput{}, err + } + out := getFileOutput{ + Path: resp.Path, Ref: in.Ref, SHA: resp.SHA, Size: resp.Size, Encoding: resp.Encoding, + } + if resp.Type != "file" { + out.IsDirectory = true + return nil, out, nil + } + decoded, err := base64.StdEncoding.DecodeString(resp.Content) + if err != nil { + out.Content = resp.Content + return nil, out, nil + } + if int64(len(decoded)) > maxFileBytes { + decoded = decoded[:maxFileBytes] + out.Truncated = true + } + out.Encoding = "utf-8" + out.Content = string(decoded) + return nil, out, nil + } +} + +type fileHistoryInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path" jsonschema:"file path within the repository"` + Ref string `json:"ref,omitempty" jsonschema:"branch / tag / commit to start listing from (default: repo default branch)"` + Limit int `json:"limit,omitempty" jsonschema:"max commits to return (1-50, default 20)"` +} +type historyCommit struct { + SHA string `json:"sha"` + Message string `json:"message"` + Author string `json:"author,omitempty"` + Date string `json:"date,omitempty"` + URL string `json:"url,omitempty"` +} +type fileHistoryOutput struct { + Commits []historyCommit `json:"commits"` +} + +func fileHistoryHandler(api *giteaAPI) mcp.ToolHandlerFor[fileHistoryInput, fileHistoryOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in fileHistoryInput) (*mcp.CallToolResult, fileHistoryOutput, error) { + if in.Owner == "" || in.Repo == "" || in.Path == "" { + return nil, fileHistoryOutput{}, fmt.Errorf("owner, repo, and path are required") + } + limit := clampInt(in.Limit, 1, 50, 20) + q := url.Values{ + "path": []string{in.Path}, + "limit": []string{strconv.Itoa(limit)}, + "stat": []string{"false"}, + "verification": []string{"false"}, + "files": []string{"false"}, + } + setIf(q, "sha", in.Ref) + path := fmt.Sprintf("/api/v1/repos/%s/%s/commits", + url.PathEscape(in.Owner), url.PathEscape(in.Repo)) + var raw []struct { + SHA string `json:"sha"` + HTMLURL string `json:"html_url"` + RepoCommit struct { + Message string `json:"message"` + Author struct { + Name string `json:"name"` + Date string `json:"date"` + } `json:"author"` + } `json:"commit"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, fileHistoryOutput{}, err + } + out := fileHistoryOutput{Commits: make([]historyCommit, 0, len(raw))} + for _, c := range raw { + out.Commits = append(out.Commits, historyCommit{ + SHA: c.SHA, Message: firstLine(c.RepoCommit.Message), + Author: c.RepoCommit.Author.Name, Date: c.RepoCommit.Author.Date, + URL: c.HTMLURL, + }) + } + return nil, out, nil + } +} diff --git a/cmd/gitea-mcp/tools_issues.go b/cmd/gitea-mcp/tools_issues.go new file mode 100644 index 0000000..8236975 --- /dev/null +++ b/cmd/gitea-mcp/tools_issues.go @@ -0,0 +1,237 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerIssueTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_issues", + Description: "List issues in a repository. Filters: state (open|closed|all, default open), labels (comma-separated), type (issues|pulls|all, default issues), since (ISO 8601).", + }, listIssuesHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_issue", + Description: "Get a single issue or pull request by its 1-based index in the repository.", + }, getIssueHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_issue_comments", + Description: "List comments on a single issue or pull request.", + }, listIssueCommentsHandler(api)) +} + +type issueSummary struct { + ID int64 `json:"id"` + Index int64 `json:"index"` + Title string `json:"title"` + State string `json:"state"` + Author string `json:"author,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Comments int `json:"comments"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + URL string `json:"url,omitempty"` + IsPull bool `json:"is_pull,omitempty"` +} +type issueFull struct { + issueSummary + Body string `json:"body,omitempty"` +} +type issueComment struct { + ID int64 `json:"id"` + Author string `json:"author,omitempty"` + Body string `json:"body"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +func summarizeIssue(raw struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Comments int `json:"comments"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + HTMLURL string `json:"html_url"` + PullRequest any `json:"pull_request"` + User struct { + Login string `json:"login"` + } `json:"user"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Assignees []struct { + Login string `json:"login"` + } `json:"assignees"` +}) issueSummary { + labels := make([]string, 0, len(raw.Labels)) + for _, l := range raw.Labels { + labels = append(labels, l.Name) + } + assignees := make([]string, 0, len(raw.Assignees)) + for _, a := range raw.Assignees { + assignees = append(assignees, a.Login) + } + return issueSummary{ + ID: raw.ID, Index: raw.Number, Title: raw.Title, State: raw.State, + Author: raw.User.Login, Labels: labels, Assignees: assignees, + Comments: raw.Comments, CreatedAt: raw.CreatedAt, UpdatedAt: raw.UpdatedAt, + URL: raw.HTMLURL, IsPull: raw.PullRequest != nil, + } +} + +type listIssuesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty" jsonschema:"open | closed | all (default: open)"` + Labels string `json:"labels,omitempty" jsonschema:"comma-separated label names"` + Type string `json:"type,omitempty" jsonschema:"issues | pulls | all (default: issues)"` + Since string `json:"since,omitempty" jsonschema:"only issues updated at or after this ISO 8601 time"` + Limit int `json:"limit,omitempty"` +} +type listIssuesOutput struct { + Issues []issueSummary `json:"issues"` +} + +func listIssuesHandler(api *giteaAPI) mcp.ToolHandlerFor[listIssuesInput, listIssuesOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listIssuesInput) (*mcp.CallToolResult, listIssuesOutput, error) { + if in.Owner == "" || in.Repo == "" { + return nil, listIssuesOutput{}, fmt.Errorf("owner and repo are required") + } + limit := clampInt(in.Limit, 1, 50, 20) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + setIf(q, "state", in.State) + setIf(q, "labels", in.Labels) + setIf(q, "type", in.Type) + setIf(q, "since", in.Since) + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues", + url.PathEscape(in.Owner), url.PathEscape(in.Repo)) + var raw []struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Comments int `json:"comments"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + HTMLURL string `json:"html_url"` + PullRequest any `json:"pull_request"` + User struct { + Login string `json:"login"` + } `json:"user"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Assignees []struct { + Login string `json:"login"` + } `json:"assignees"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listIssuesOutput{}, err + } + out := listIssuesOutput{Issues: make([]issueSummary, 0, len(raw))} + for _, r := range raw { + out.Issues = append(out.Issues, summarizeIssue(r)) + } + return nil, out, nil + } +} + +type getIssueInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Index int64 `json:"index" jsonschema:"the per-repo 1-based issue / PR number"` +} +type getIssueOutput struct { + Issue issueFull `json:"issue"` +} + +func getIssueHandler(api *giteaAPI) mcp.ToolHandlerFor[getIssueInput, getIssueOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getIssueInput) (*mcp.CallToolResult, getIssueOutput, error) { + if in.Owner == "" || in.Repo == "" || in.Index == 0 { + return nil, getIssueOutput{}, fmt.Errorf("owner, repo, and index are required") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), in.Index) + var raw struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Comments int `json:"comments"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + HTMLURL string `json:"html_url"` + PullRequest any `json:"pull_request"` + User struct { + Login string `json:"login"` + } `json:"user"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Assignees []struct { + Login string `json:"login"` + } `json:"assignees"` + } + if err := api.get(ctx, path, nil, &raw); err != nil { + return nil, getIssueOutput{}, err + } + summary := summarizeIssue(raw) + return nil, getIssueOutput{Issue: issueFull{issueSummary: summary, Body: raw.Body}}, nil + } +} + +type listIssueCommentsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Index int64 `json:"index"` + Since string `json:"since,omitempty"` + Limit int `json:"limit,omitempty"` +} +type listIssueCommentsOutput struct { + Comments []issueComment `json:"comments"` +} + +func listIssueCommentsHandler(api *giteaAPI) mcp.ToolHandlerFor[listIssueCommentsInput, listIssueCommentsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listIssueCommentsInput) (*mcp.CallToolResult, listIssueCommentsOutput, error) { + if in.Owner == "" || in.Repo == "" || in.Index == 0 { + return nil, listIssueCommentsOutput{}, fmt.Errorf("owner, repo, and index are required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + setIf(q, "since", in.Since) + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), in.Index) + var raw []struct { + ID int64 `json:"id"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + User struct { + Login string `json:"login"` + } `json:"user"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listIssueCommentsOutput{}, err + } + out := listIssueCommentsOutput{Comments: make([]issueComment, 0, len(raw))} + for _, c := range raw { + out.Comments = append(out.Comments, issueComment{ + ID: c.ID, Author: c.User.Login, Body: c.Body, + CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, + }) + } + return nil, out, nil + } +} diff --git a/cmd/gitea-mcp/tools_orgs.go b/cmd/gitea-mcp/tools_orgs.go new file mode 100644 index 0000000..8ddb352 --- /dev/null +++ b/cmd/gitea-mcp/tools_orgs.go @@ -0,0 +1,285 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerOrgTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_my_orgs", + Description: "List organisations the authenticated user belongs to.", + }, listMyOrgsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_org", + Description: "Get details of an organisation.", + }, getOrgHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_org_repos", + Description: "List repositories owned by an organisation, newest first.", + }, listOrgReposHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_org_members", + Description: "List members of an organisation.", + }, listOrgMembersHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_org_teams", + Description: "List teams of an organisation.", + }, listOrgTeamsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "search_org_teams", + Description: "Search for teams in an organisation by name substring.", + }, searchOrgTeamsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_org_activity_feed", + Description: "List the recent activity feed of an organisation (commits, issue events, etc.).", + }, listOrgActivityHandler(api)) +} + +type org struct { + ID int64 `json:"id"` + Username string `json:"username"` + FullName string `json:"full_name,omitempty"` + Description string `json:"description,omitempty"` + Website string `json:"website,omitempty"` + Location string `json:"location,omitempty"` + Visibility string `json:"visibility,omitempty"` + URL string `json:"url,omitempty"` +} +type team struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Permission string `json:"permission,omitempty"` + Units []string `json:"units,omitempty"` + URL string `json:"url,omitempty"` +} +type orgMember struct { + Login string `json:"login"` + FullName string `json:"full_name,omitempty"` + Email string `json:"email,omitempty"` +} +type activityItem struct { + ID int64 `json:"id"` + OpType string `json:"op_type,omitempty"` + Actor string `json:"actor,omitempty"` + Repo string `json:"repo,omitempty"` + RefName string `json:"ref_name,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Content string `json:"content,omitempty"` +} + +type listMyOrgsInput struct { + Limit int `json:"limit,omitempty"` +} +type listMyOrgsOutput struct { + Orgs []org `json:"orgs"` +} + +func listMyOrgsHandler(api *giteaAPI) mcp.ToolHandlerFor[listMyOrgsInput, listMyOrgsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listMyOrgsInput) (*mcp.CallToolResult, listMyOrgsOutput, error) { + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + var orgs []org + if err := api.get(ctx, "/api/v1/user/orgs", q, &orgs); err != nil { + return nil, listMyOrgsOutput{}, err + } + return nil, listMyOrgsOutput{Orgs: orgs}, nil + } +} + +type getOrgInput struct { + Org string `json:"org"` +} +type getOrgOutput struct { + Org org `json:"org"` +} + +func getOrgHandler(api *giteaAPI) mcp.ToolHandlerFor[getOrgInput, getOrgOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getOrgInput) (*mcp.CallToolResult, getOrgOutput, error) { + if in.Org == "" { + return nil, getOrgOutput{}, fmt.Errorf("org is required") + } + var o org + path := fmt.Sprintf("/api/v1/orgs/%s", url.PathEscape(in.Org)) + if err := api.get(ctx, path, nil, &o); err != nil { + return nil, getOrgOutput{}, err + } + return nil, getOrgOutput{Org: o}, nil + } +} + +type listOrgReposInput struct { + Org string `json:"org"` + Limit int `json:"limit,omitempty"` +} + +func listOrgReposHandler(api *giteaAPI) mcp.ToolHandlerFor[listOrgReposInput, listReposOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listOrgReposInput) (*mcp.CallToolResult, listReposOutput, error) { + if in.Org == "" { + return nil, listReposOutput{}, fmt.Errorf("org is required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + path := fmt.Sprintf("/api/v1/orgs/%s/repos", url.PathEscape(in.Org)) + var raw []struct { + FullName string `json:"full_name"` + Description string `json:"description"` + HTMLURL string `json:"html_url"` + Private bool `json:"private"` + Fork bool `json:"fork"` + Archived bool `json:"archived"` + UpdatedAt string `json:"updated_at"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listReposOutput{}, err + } + out := listReposOutput{Repos: make([]listedRepo, 0, len(raw))} + for _, r := range raw { + out.Repos = append(out.Repos, listedRepo{ + FullName: r.FullName, Description: r.Description, URL: r.HTMLURL, + Private: r.Private, Fork: r.Fork, Archived: r.Archived, UpdatedAt: r.UpdatedAt, + }) + } + return nil, out, nil + } +} + +type listOrgMembersInput struct { + Org string `json:"org"` + Limit int `json:"limit,omitempty"` +} +type listOrgMembersOutput struct { + Members []orgMember `json:"members"` +} + +func listOrgMembersHandler(api *giteaAPI) mcp.ToolHandlerFor[listOrgMembersInput, listOrgMembersOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listOrgMembersInput) (*mcp.CallToolResult, listOrgMembersOutput, error) { + if in.Org == "" { + return nil, listOrgMembersOutput{}, fmt.Errorf("org is required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + path := fmt.Sprintf("/api/v1/orgs/%s/members", url.PathEscape(in.Org)) + var raw []struct { + Login string `json:"login"` + FullName string `json:"full_name"` + Email string `json:"email"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listOrgMembersOutput{}, err + } + out := listOrgMembersOutput{Members: make([]orgMember, 0, len(raw))} + for _, m := range raw { + out.Members = append(out.Members, orgMember{Login: m.Login, FullName: m.FullName, Email: m.Email}) + } + return nil, out, nil + } +} + +type listOrgTeamsInput struct { + Org string `json:"org"` + Limit int `json:"limit,omitempty"` +} +type listOrgTeamsOutput struct { + Teams []team `json:"teams"` +} + +func listOrgTeamsHandler(api *giteaAPI) mcp.ToolHandlerFor[listOrgTeamsInput, listOrgTeamsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listOrgTeamsInput) (*mcp.CallToolResult, listOrgTeamsOutput, error) { + if in.Org == "" { + return nil, listOrgTeamsOutput{}, fmt.Errorf("org is required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + path := fmt.Sprintf("/api/v1/orgs/%s/teams", url.PathEscape(in.Org)) + var teams []team + if err := api.get(ctx, path, q, &teams); err != nil { + return nil, listOrgTeamsOutput{}, err + } + return nil, listOrgTeamsOutput{Teams: teams}, nil + } +} + +type searchOrgTeamsInput struct { + Org string `json:"org"` + Query string `json:"query"` + Limit int `json:"limit,omitempty"` +} + +func searchOrgTeamsHandler(api *giteaAPI) mcp.ToolHandlerFor[searchOrgTeamsInput, listOrgTeamsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in searchOrgTeamsInput) (*mcp.CallToolResult, listOrgTeamsOutput, error) { + if in.Org == "" || in.Query == "" { + return nil, listOrgTeamsOutput{}, fmt.Errorf("org and query are required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{ + "limit": []string{strconv.Itoa(limit)}, + "q": []string{in.Query}, + } + path := fmt.Sprintf("/api/v1/orgs/%s/teams/search", url.PathEscape(in.Org)) + var resp struct { + OK bool `json:"ok"` + Data []team `json:"data"` + } + if err := api.get(ctx, path, q, &resp); err != nil { + return nil, listOrgTeamsOutput{}, err + } + return nil, listOrgTeamsOutput{Teams: resp.Data}, nil + } +} + +type listOrgActivityInput struct { + Org string `json:"org"` + Date string `json:"date,omitempty" jsonschema:"YYYY-MM-DD; default: today"` + Limit int `json:"limit,omitempty"` +} +type listOrgActivityOutput struct { + Items []activityItem `json:"items"` +} + +func listOrgActivityHandler(api *giteaAPI) mcp.ToolHandlerFor[listOrgActivityInput, listOrgActivityOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listOrgActivityInput) (*mcp.CallToolResult, listOrgActivityOutput, error) { + if in.Org == "" { + return nil, listOrgActivityOutput{}, fmt.Errorf("org is required") + } + limit := clampInt(in.Limit, 1, 50, 30) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + setIf(q, "date", in.Date) + path := fmt.Sprintf("/api/v1/orgs/%s/activities/feeds", url.PathEscape(in.Org)) + var raw []struct { + ID int64 `json:"id"` + OpType string `json:"op_type"` + CreatedAt string `json:"created"` + RefName string `json:"ref_name"` + Content string `json:"content"` + Actor struct { + Login string `json:"login"` + } `json:"act_user"` + Repo struct { + FullName string `json:"full_name"` + } `json:"repo"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listOrgActivityOutput{}, err + } + out := listOrgActivityOutput{Items: make([]activityItem, 0, len(raw))} + for _, a := range raw { + out.Items = append(out.Items, activityItem{ + ID: a.ID, OpType: a.OpType, Actor: a.Actor.Login, Repo: a.Repo.FullName, + RefName: a.RefName, CreatedAt: a.CreatedAt, Content: a.Content, + }) + } + return nil, out, nil + } +} diff --git a/cmd/gitea-mcp/tools_packages.go b/cmd/gitea-mcp/tools_packages.go new file mode 100644 index 0000000..a9c3408 --- /dev/null +++ b/cmd/gitea-mcp/tools_packages.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerPackageTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_packages", + Description: "List packages owned by a user or organisation. Optional type filter (e.g. \"container\", \"npm\", \"generic\") and name substring.", + }, listPackagesHandler(api)) +} + +type listPackagesInput struct { + Owner string `json:"owner" jsonschema:"package owner (user or org)"` + Type string `json:"type,omitempty" jsonschema:"package type filter (container, npm, generic, …)"` + Query string `json:"query,omitempty" jsonschema:"substring match on package name"` + Limit int `json:"limit,omitempty" jsonschema:"max packages to return (1-50, default 50)"` +} +type pkg struct { + ID int64 `json:"id"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Version string `json:"version,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} +type listPackagesOutput struct { + Packages []pkg `json:"packages"` +} + +func listPackagesHandler(api *giteaAPI) mcp.ToolHandlerFor[listPackagesInput, listPackagesOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listPackagesInput) (*mcp.CallToolResult, listPackagesOutput, error) { + if in.Owner == "" { + return nil, listPackagesOutput{}, fmt.Errorf("owner is required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + setIf(q, "type", in.Type) + setIf(q, "q", in.Query) + path := fmt.Sprintf("/api/v1/packages/%s", url.PathEscape(in.Owner)) + var packages []pkg + if err := api.get(ctx, path, q, &packages); err != nil { + return nil, listPackagesOutput{}, err + } + return nil, listPackagesOutput{Packages: packages}, nil + } +} diff --git a/cmd/gitea-mcp/tools_pulls.go b/cmd/gitea-mcp/tools_pulls.go new file mode 100644 index 0000000..ca8be06 --- /dev/null +++ b/cmd/gitea-mcp/tools_pulls.go @@ -0,0 +1,272 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerPullTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_pulls", + Description: "List pull requests for a repository. Filters: state (open|closed|all), sort, milestone, labels.", + }, listPullsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_pull", + Description: "Get full details of a pull request by index. Includes head/base refs, mergeable status, and requested reviewers.", + }, getPullHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_pull_files", + Description: "List the files changed by a pull request with per-file additions/deletions counts (no diffs).", + }, listPullFilesHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_pull_reviews", + Description: "List reviews on a pull request (state: APPROVED | CHANGES_REQUESTED | COMMENTED | …).", + }, listPullReviewsHandler(api)) +} + +type pullRef struct { + Label string `json:"label"` + Ref string `json:"ref"` + SHA string `json:"sha"` +} +type pullSummary struct { + ID int64 `json:"id"` + Index int64 `json:"index"` + Title string `json:"title"` + State string `json:"state"` + Draft bool `json:"draft,omitempty"` + Merged bool `json:"merged,omitempty"` + Mergeable bool `json:"mergeable,omitempty"` + Author string `json:"author,omitempty"` + Labels []string `json:"labels,omitempty"` + Reviewers []string `json:"requested_reviewers,omitempty"` + Head pullRef `json:"head"` + Base pullRef `json:"base"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + MergedAt string `json:"merged_at,omitempty"` + URL string `json:"url,omitempty"` +} +type pullFull struct { + pullSummary + Body string `json:"body,omitempty"` + Additions int64 `json:"additions,omitempty"` + Deletions int64 `json:"deletions,omitempty"` + Changed int64 `json:"changed_files,omitempty"` +} +type pullFile struct { + Filename string `json:"filename"` + Status string `json:"status"` + Additions int64 `json:"additions"` + Deletions int64 `json:"deletions"` + Changes int64 `json:"changes"` +} +type pullReview struct { + ID int64 `json:"id"` + State string `json:"state"` + Body string `json:"body,omitempty"` + Reviewer string `json:"reviewer,omitempty"` + SubmittedAt string `json:"submitted_at,omitempty"` + CommitID string `json:"commit_id,omitempty"` +} + +type rawPull struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + Mergeable bool `json:"mergeable"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + MergedAt string `json:"merged_at"` + HTMLURL string `json:"html_url"` + Additions int64 `json:"additions"` + Deletions int64 `json:"deletions"` + Changed int64 `json:"changed_files"` + User struct { + Login string `json:"login"` + } `json:"user"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + RequestedReviewers []struct { + Login string `json:"login"` + } `json:"requested_reviewers"` + Head struct { + Label string `json:"label"` + Ref string `json:"ref"` + SHA string `json:"sha"` + } `json:"head"` + Base struct { + Label string `json:"label"` + Ref string `json:"ref"` + SHA string `json:"sha"` + } `json:"base"` +} + +func summarizePull(r rawPull) pullSummary { + labels := make([]string, 0, len(r.Labels)) + for _, l := range r.Labels { + labels = append(labels, l.Name) + } + reviewers := make([]string, 0, len(r.RequestedReviewers)) + for _, u := range r.RequestedReviewers { + reviewers = append(reviewers, u.Login) + } + return pullSummary{ + ID: r.ID, Index: r.Number, Title: r.Title, State: r.State, + Draft: r.Draft, Merged: r.Merged, Mergeable: r.Mergeable, + Author: r.User.Login, Labels: labels, Reviewers: reviewers, + Head: pullRef{Label: r.Head.Label, Ref: r.Head.Ref, SHA: r.Head.SHA}, + Base: pullRef{Label: r.Base.Label, Ref: r.Base.Ref, SHA: r.Base.SHA}, + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, MergedAt: r.MergedAt, URL: r.HTMLURL, + } +} + +type listPullsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty" jsonschema:"open | closed | all (default: open)"` + Sort string `json:"sort,omitempty" jsonschema:"oldest | recentupdate | leastupdate | mostcomment | leastcomment | priority"` + Labels string `json:"labels,omitempty" jsonschema:"comma-separated label names"` + Milestone int64 `json:"milestone,omitempty"` + Limit int `json:"limit,omitempty"` +} +type listPullsOutput struct { + Pulls []pullSummary `json:"pulls"` +} + +func listPullsHandler(api *giteaAPI) mcp.ToolHandlerFor[listPullsInput, listPullsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listPullsInput) (*mcp.CallToolResult, listPullsOutput, error) { + if in.Owner == "" || in.Repo == "" { + return nil, listPullsOutput{}, fmt.Errorf("owner and repo are required") + } + limit := clampInt(in.Limit, 1, 50, 20) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + setIf(q, "state", in.State) + setIf(q, "sort", in.Sort) + setIf(q, "labels", in.Labels) + if in.Milestone > 0 { + q.Set("milestone", strconv.FormatInt(in.Milestone, 10)) + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls", + url.PathEscape(in.Owner), url.PathEscape(in.Repo)) + var raw []rawPull + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listPullsOutput{}, err + } + out := listPullsOutput{Pulls: make([]pullSummary, 0, len(raw))} + for _, r := range raw { + out.Pulls = append(out.Pulls, summarizePull(r)) + } + return nil, out, nil + } +} + +type getPullInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Index int64 `json:"index"` +} +type getPullOutput struct { + Pull pullFull `json:"pull"` +} + +func getPullHandler(api *giteaAPI) mcp.ToolHandlerFor[getPullInput, getPullOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getPullInput) (*mcp.CallToolResult, getPullOutput, error) { + if in.Owner == "" || in.Repo == "" || in.Index == 0 { + return nil, getPullOutput{}, fmt.Errorf("owner, repo, and index are required") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), in.Index) + var r rawPull + if err := api.get(ctx, path, nil, &r); err != nil { + return nil, getPullOutput{}, err + } + summary := summarizePull(r) + return nil, getPullOutput{Pull: pullFull{ + pullSummary: summary, Body: r.Body, + Additions: r.Additions, Deletions: r.Deletions, Changed: r.Changed, + }}, nil + } +} + +type listPullFilesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Index int64 `json:"index"` + Limit int `json:"limit,omitempty"` +} +type listPullFilesOutput struct { + Files []pullFile `json:"files"` +} + +func listPullFilesHandler(api *giteaAPI) mcp.ToolHandlerFor[listPullFilesInput, listPullFilesOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listPullFilesInput) (*mcp.CallToolResult, listPullFilesOutput, error) { + if in.Owner == "" || in.Repo == "" || in.Index == 0 { + return nil, listPullFilesOutput{}, fmt.Errorf("owner, repo, and index are required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/files", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), in.Index) + var raw []pullFile + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listPullFilesOutput{}, err + } + return nil, listPullFilesOutput{Files: raw}, nil + } +} + +type listPullReviewsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Index int64 `json:"index"` + Limit int `json:"limit,omitempty"` +} +type listPullReviewsOutput struct { + Reviews []pullReview `json:"reviews"` +} + +func listPullReviewsHandler(api *giteaAPI) mcp.ToolHandlerFor[listPullReviewsInput, listPullReviewsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listPullReviewsInput) (*mcp.CallToolResult, listPullReviewsOutput, error) { + if in.Owner == "" || in.Repo == "" || in.Index == 0 { + return nil, listPullReviewsOutput{}, fmt.Errorf("owner, repo, and index are required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), in.Index) + var raw []struct { + ID int64 `json:"id"` + State string `json:"state"` + Body string `json:"body"` + SubmittedAt string `json:"submitted_at"` + CommitID string `json:"commit_id"` + User struct { + Login string `json:"login"` + } `json:"user"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listPullReviewsOutput{}, err + } + out := listPullReviewsOutput{Reviews: make([]pullReview, 0, len(raw))} + for _, r := range raw { + out.Reviews = append(out.Reviews, pullReview{ + ID: r.ID, State: r.State, Body: r.Body, + Reviewer: r.User.Login, SubmittedAt: r.SubmittedAt, CommitID: r.CommitID, + }) + } + return nil, out, nil + } +} diff --git a/cmd/gitea-mcp/tools_refs.go b/cmd/gitea-mcp/tools_refs.go new file mode 100644 index 0000000..fecd3f6 --- /dev/null +++ b/cmd/gitea-mcp/tools_refs.go @@ -0,0 +1,193 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerRefTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_branches", + Description: "List branches in a repository.", + }, listBranchesHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_branch", + Description: "Get details for a single branch including its tip commit SHA.", + }, getBranchHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_tags", + Description: "List git tags in a repository, newest first.", + }, listTagsHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_tag", + Description: "Get details for a single tag.", + }, getTagHandler(api)) +} + +type branch struct { + Name string `json:"name"` + CommitSHA string `json:"commit_sha"` + CommitURL string `json:"commit_url,omitempty"` + Protected bool `json:"protected,omitempty"` + RequiredApprov int64 `json:"required_approvals,omitempty"` +} +type tag struct { + Name string `json:"name"` + CommitSHA string `json:"commit_sha"` + Message string `json:"message,omitempty"` + ZipballURL string `json:"zipball_url,omitempty"` + TarballURL string `json:"tarball_url,omitempty"` +} + +type listBranchesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Limit int `json:"limit,omitempty"` +} +type listBranchesOutput struct { + Branches []branch `json:"branches"` +} + +func listBranchesHandler(api *giteaAPI) mcp.ToolHandlerFor[listBranchesInput, listBranchesOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listBranchesInput) (*mcp.CallToolResult, listBranchesOutput, error) { + if in.Owner == "" || in.Repo == "" { + return nil, listBranchesOutput{}, fmt.Errorf("owner and repo are required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + path := fmt.Sprintf("/api/v1/repos/%s/%s/branches", + url.PathEscape(in.Owner), url.PathEscape(in.Repo)) + var raw []struct { + Name string `json:"name"` + Commit struct{ ID, URL string } `json:"commit"` + Protected bool `json:"protected"` + RequiredApprovals int64 `json:"required_approvals"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listBranchesOutput{}, err + } + out := listBranchesOutput{Branches: make([]branch, 0, len(raw))} + for _, b := range raw { + out.Branches = append(out.Branches, branch{ + Name: b.Name, CommitSHA: b.Commit.ID, CommitURL: b.Commit.URL, + Protected: b.Protected, RequiredApprov: b.RequiredApprovals, + }) + } + return nil, out, nil + } +} + +type getBranchInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Branch string `json:"branch"` +} +type getBranchOutput struct { + Branch branch `json:"branch"` +} + +func getBranchHandler(api *giteaAPI) mcp.ToolHandlerFor[getBranchInput, getBranchOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getBranchInput) (*mcp.CallToolResult, getBranchOutput, error) { + if in.Owner == "" || in.Repo == "" || in.Branch == "" { + return nil, getBranchOutput{}, fmt.Errorf("owner, repo, and branch are required") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), url.PathEscape(in.Branch)) + var raw struct { + Name string `json:"name"` + Commit struct{ ID, URL string } `json:"commit"` + Protected bool `json:"protected"` + RequiredApprovals int64 `json:"required_approvals"` + } + if err := api.get(ctx, path, nil, &raw); err != nil { + return nil, getBranchOutput{}, err + } + return nil, getBranchOutput{Branch: branch{ + Name: raw.Name, CommitSHA: raw.Commit.ID, CommitURL: raw.Commit.URL, + Protected: raw.Protected, RequiredApprov: raw.RequiredApprovals, + }}, nil + } +} + +type listTagsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Limit int `json:"limit,omitempty"` +} +type listTagsOutput struct { + Tags []tag `json:"tags"` +} + +func listTagsHandler(api *giteaAPI) mcp.ToolHandlerFor[listTagsInput, listTagsOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listTagsInput) (*mcp.CallToolResult, listTagsOutput, error) { + if in.Owner == "" || in.Repo == "" { + return nil, listTagsOutput{}, fmt.Errorf("owner and repo are required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + path := fmt.Sprintf("/api/v1/repos/%s/%s/tags", + url.PathEscape(in.Owner), url.PathEscape(in.Repo)) + var raw []struct { + Name string `json:"name"` + Message string `json:"message"` + ZipballURL string `json:"zipball_url"` + TarballURL string `json:"tarball_url"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listTagsOutput{}, err + } + out := listTagsOutput{Tags: make([]tag, 0, len(raw))} + for _, t := range raw { + out.Tags = append(out.Tags, tag{ + Name: t.Name, CommitSHA: t.Commit.SHA, Message: t.Message, + ZipballURL: t.ZipballURL, TarballURL: t.TarballURL, + }) + } + return nil, out, nil + } +} + +type getTagInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Tag string `json:"tag"` +} +type getTagOutput struct { + Tag tag `json:"tag"` +} + +func getTagHandler(api *giteaAPI) mcp.ToolHandlerFor[getTagInput, getTagOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getTagInput) (*mcp.CallToolResult, getTagOutput, error) { + if in.Owner == "" || in.Repo == "" || in.Tag == "" { + return nil, getTagOutput{}, fmt.Errorf("owner, repo, and tag are required") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/tags/%s", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), url.PathEscape(in.Tag)) + var raw struct { + Name string `json:"name"` + Message string `json:"message"` + ZipballURL string `json:"zipball_url"` + TarballURL string `json:"tarball_url"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` + } + if err := api.get(ctx, path, nil, &raw); err != nil { + return nil, getTagOutput{}, err + } + return nil, getTagOutput{Tag: tag{ + Name: raw.Name, CommitSHA: raw.Commit.SHA, Message: raw.Message, + ZipballURL: raw.ZipballURL, TarballURL: raw.TarballURL, + }}, nil + } +} diff --git a/cmd/gitea-mcp/tools_releases.go b/cmd/gitea-mcp/tools_releases.go new file mode 100644 index 0000000..d843d4b --- /dev/null +++ b/cmd/gitea-mcp/tools_releases.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerReleaseTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_releases", + Description: "List releases of a repository, newest first.", + }, listReleasesHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_release", + Description: "Get a single release by ID, including assets.", + }, getReleaseHandler(api)) +} + +type releaseAsset struct { + Name string `json:"name"` + Size int64 `json:"size"` + BrowserDownloadURL string `json:"browser_download_url"` +} +type release struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name,omitempty"` + Body string `json:"body,omitempty"` + URL string `json:"url,omitempty"` + Draft bool `json:"draft,omitempty"` + Prerelease bool `json:"prerelease,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + PublishedAt string `json:"published_at,omitempty"` + TargetCommit string `json:"target_commitish,omitempty"` + Assets []releaseAsset `json:"assets,omitempty"` +} + +type listReleasesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + IncludeDraft bool `json:"include_draft,omitempty"` + IncludePre bool `json:"include_pre,omitempty" jsonschema:"include pre-releases"` + Limit int `json:"limit,omitempty"` +} +type listReleasesOutput struct { + Releases []release `json:"releases"` +} + +func listReleasesHandler(api *giteaAPI) mcp.ToolHandlerFor[listReleasesInput, listReleasesOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listReleasesInput) (*mcp.CallToolResult, listReleasesOutput, error) { + if in.Owner == "" || in.Repo == "" { + return nil, listReleasesOutput{}, fmt.Errorf("owner and repo are required") + } + limit := clampInt(in.Limit, 1, 50, 20) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + if in.IncludeDraft { + q.Set("draft", "true") + } + if in.IncludePre { + q.Set("pre-release", "true") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/releases", + url.PathEscape(in.Owner), url.PathEscape(in.Repo)) + var rs []release + if err := api.get(ctx, path, q, &rs); err != nil { + return nil, listReleasesOutput{}, err + } + return nil, listReleasesOutput{Releases: rs}, nil + } +} + +type getReleaseInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + ID int64 `json:"id" jsonschema:"release ID from list_releases"` +} +type getReleaseOutput struct { + Release release `json:"release"` +} + +func getReleaseHandler(api *giteaAPI) mcp.ToolHandlerFor[getReleaseInput, getReleaseOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getReleaseInput) (*mcp.CallToolResult, getReleaseOutput, error) { + if in.Owner == "" || in.Repo == "" || in.ID == 0 { + return nil, getReleaseOutput{}, fmt.Errorf("owner, repo, and id are required") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), in.ID) + var r release + if err := api.get(ctx, path, nil, &r); err != nil { + return nil, getReleaseOutput{}, err + } + return nil, getReleaseOutput{Release: r}, nil + } +} diff --git a/cmd/gitea-mcp/tools_repo.go b/cmd/gitea-mcp/tools_repo.go new file mode 100644 index 0000000..a001bd6 --- /dev/null +++ b/cmd/gitea-mcp/tools_repo.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerRepoTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_repos", + Description: "List or search repositories accessible to the authenticated user. Returns the most-recently-updated first.", + }, listReposHandler(api)) +} + +type listReposInput struct { + Query string `json:"query,omitempty" jsonschema:"optional substring to filter repo names / descriptions on"` + Limit int `json:"limit,omitempty" jsonschema:"max number of repos to return (1-50, default 50)"` +} +type listedRepo struct { + FullName string `json:"full_name"` + Description string `json:"description,omitempty"` + URL string `json:"url"` + Private bool `json:"private"` + Fork bool `json:"fork,omitempty"` + Archived bool `json:"archived,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} +type listReposOutput struct { + Repos []listedRepo `json:"repos"` +} + +func listReposHandler(api *giteaAPI) mcp.ToolHandlerFor[listReposInput, listReposOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listReposInput) (*mcp.CallToolResult, listReposOutput, error) { + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{ + "limit": []string{strconv.Itoa(limit)}, + "sort": []string{"updated"}, + "order": []string{"desc"}, + } + setIf(q, "q", in.Query) + var resp struct { + OK bool `json:"ok"` + Data []struct { + FullName string `json:"full_name"` + Description string `json:"description"` + HTMLURL string `json:"html_url"` + Private bool `json:"private"` + Fork bool `json:"fork"` + Archived bool `json:"archived"` + UpdatedAt string `json:"updated_at"` + } `json:"data"` + } + if err := api.get(ctx, "/api/v1/repos/search", q, &resp); err != nil { + return nil, listReposOutput{}, err + } + out := listReposOutput{Repos: make([]listedRepo, 0, len(resp.Data))} + for _, r := range resp.Data { + out.Repos = append(out.Repos, listedRepo{ + FullName: r.FullName, Description: r.Description, URL: r.HTMLURL, + Private: r.Private, Fork: r.Fork, Archived: r.Archived, UpdatedAt: r.UpdatedAt, + }) + } + return nil, out, nil + } +} diff --git a/cmd/gitea-mcp/tools_user.go b/cmd/gitea-mcp/tools_user.go new file mode 100644 index 0000000..361d87a --- /dev/null +++ b/cmd/gitea-mcp/tools_user.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerUserTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "whoami", + Description: "Return the authenticated Gitea user (login, full name, email).", + }, whoamiHandler(api)) +} + +type whoamiInput struct{} +type whoamiOutput struct { + Login string `json:"login"` + FullName string `json:"full_name,omitempty"` + Email string `json:"email,omitempty"` +} + +func whoamiHandler(api *giteaAPI) mcp.ToolHandlerFor[whoamiInput, whoamiOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, _ whoamiInput) (*mcp.CallToolResult, whoamiOutput, error) { + var u struct { + Login string `json:"login"` + FullName string `json:"full_name"` + Email string `json:"email"` + } + if err := api.get(ctx, "/api/v1/user", nil, &u); err != nil { + return nil, whoamiOutput{}, err + } + return nil, whoamiOutput{Login: u.Login, FullName: u.FullName, Email: u.Email}, nil + } +} diff --git a/cmd/gitea-mcp/tools_wiki.go b/cmd/gitea-mcp/tools_wiki.go new file mode 100644 index 0000000..c9d2881 --- /dev/null +++ b/cmd/gitea-mcp/tools_wiki.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "encoding/base64" + "fmt" + "net/url" + "strconv" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerWikiTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_wiki_pages", + Description: "List all wiki pages of a repository (titles, slugs, last update).", + }, listWikiPagesHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_wiki_page", + Description: "Read the content of a wiki page by its name/slug.", + }, getWikiPageHandler(api)) +} + +type wikiPageMeta struct { + Title string `json:"title"` + SubURL string `json:"sub_url,omitempty"` + Commit string `json:"commit,omitempty"` + Updated string `json:"updated,omitempty"` + HTMLURL string `json:"html_url,omitempty"` +} +type wikiPage struct { + wikiPageMeta + Content string `json:"content"` +} + +type listWikiPagesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Limit int `json:"limit,omitempty"` +} +type listWikiPagesOutput struct { + Pages []wikiPageMeta `json:"pages"` +} + +func listWikiPagesHandler(api *giteaAPI) mcp.ToolHandlerFor[listWikiPagesInput, listWikiPagesOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listWikiPagesInput) (*mcp.CallToolResult, listWikiPagesOutput, error) { + if in.Owner == "" || in.Repo == "" { + return nil, listWikiPagesOutput{}, fmt.Errorf("owner and repo are required") + } + limit := clampInt(in.Limit, 1, 50, 50) + q := url.Values{"limit": []string{strconv.Itoa(limit)}} + path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", + url.PathEscape(in.Owner), url.PathEscape(in.Repo)) + var raw []struct { + Title string `json:"title"` + SubURL string `json:"sub_url"` + LastCommit struct { + ID string `json:"id"` + Author struct { + Date string `json:"date"` + } `json:"author"` + } `json:"last_commit"` + HTMLURL string `json:"html_url"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listWikiPagesOutput{}, err + } + out := listWikiPagesOutput{Pages: make([]wikiPageMeta, 0, len(raw))} + for _, p := range raw { + out.Pages = append(out.Pages, wikiPageMeta{ + Title: p.Title, SubURL: p.SubURL, Commit: p.LastCommit.ID, + Updated: p.LastCommit.Author.Date, HTMLURL: p.HTMLURL, + }) + } + return nil, out, nil + } +} + +type getWikiPageInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PageName string `json:"page_name" jsonschema:"wiki page name or slug from list_wiki_pages.sub_url"` +} +type getWikiPageOutput struct { + Page wikiPage `json:"page"` +} + +func getWikiPageHandler(api *giteaAPI) mcp.ToolHandlerFor[getWikiPageInput, getWikiPageOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getWikiPageInput) (*mcp.CallToolResult, getWikiPageOutput, error) { + if in.Owner == "" || in.Repo == "" || in.PageName == "" { + return nil, getWikiPageOutput{}, fmt.Errorf("owner, repo, and page_name are required") + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), url.PathEscape(in.PageName)) + var raw struct { + Title string `json:"title"` + ContentBase64 string `json:"content_base64"` + SubURL string `json:"sub_url"` + LastCommit struct { + ID string `json:"id"` + Author struct { + Date string `json:"date"` + } `json:"author"` + } `json:"last_commit"` + HTMLURL string `json:"html_url"` + } + if err := api.get(ctx, path, nil, &raw); err != nil { + return nil, getWikiPageOutput{}, err + } + var content string + if decoded, err := base64.StdEncoding.DecodeString(raw.ContentBase64); err == nil { + if len(decoded) > maxFileBytes { + decoded = decoded[:maxFileBytes] + } + content = string(decoded) + } else { + content = raw.ContentBase64 + } + return nil, getWikiPageOutput{Page: wikiPage{ + wikiPageMeta: wikiPageMeta{ + Title: raw.Title, SubURL: raw.SubURL, Commit: raw.LastCommit.ID, + Updated: raw.LastCommit.Author.Date, HTMLURL: raw.HTMLURL, + }, + Content: content, + }}, nil + } +} diff --git a/cmd/sherlock/main.go b/cmd/sherlock/main.go index 5502dff..73b7fe1 100644 --- a/cmd/sherlock/main.go +++ b/cmd/sherlock/main.go @@ -19,6 +19,7 @@ import ( "gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent" "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring" + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp" ) // Version is overwritten at build time via -ldflags "-X main.Version=...". @@ -53,6 +54,10 @@ func main() { os.Exit(exitKeyring) } + // Make sure every child process can find the MCPs we shipped via + // `go install` even if the operator never touched their shell rc. + agent.EnsurePathContainsSiblings() + switch sub { case "status": runStatus(store, rest) @@ -176,10 +181,38 @@ func runAgent(name string, userArgs []string) { fmt.Fprintf(os.Stderr, "sherlock: unknown agent %q. Known: %v\n", name, agent.Names()) os.Exit(exitUsage) } - if err := a.Spawn(agent.Context{}, userArgs); err != nil { + servers, err := knownMCPs() + if err != nil { fmt.Fprintln(os.Stderr, "sherlock:", err) os.Exit(exitGeneric) } + if err := a.Spawn(agent.Context{Servers: servers}, userArgs); err != nil { + fmt.Fprintln(os.Stderr, "sherlock:", err) + os.Exit(exitGeneric) + } +} + +// knownMCPs returns the stdio MCP servers sherlock wants every agent +// to know about. Commands are resolved to absolute paths so the agent +// CLI can spawn them regardless of how its own PATH is set up +// (Copilot's Node-based subprocess spawner ignores parent env tweaks). +// Hardcoded for now — when the second MCP lands we'll extract this to +// an internal/mcp/registry/ package that mirrors the agent registry +// pattern. +func knownMCPs() (mcp.Servers, error) { + out := mcp.Servers{} + for name, spec := range map[string]mcp.Server{ + "gitea": {Command: "gitea-mcp"}, + } { + abs, err := agent.LookPath(spec.Command) + if err != nil { + fmt.Fprintf(os.Stderr, "sherlock: skipping mcp %q: %v\n", name, err) + continue + } + spec.Command = abs + out[name] = spec + } + return out, nil } func humanizeUntil(t time.Time) string { diff --git a/docs/agents.md b/docs/agents.md index 48c7bf9..0a0e103 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -7,8 +7,8 @@ How sherlock decides which CLI to spawn when you type | Name | Binary | MCP config flag | Notes | |---|---|---|---| -| `copilot` | `copilot` (npm `@github/copilot`) | `--additional-mcp-config @` | Augments user's `~/.copilot/mcp-config.json`. JSON shape is the VS Code MCP schema (`{"servers": ...}`). | -| `claude` | `claude` (npm `@anthropic-ai/claude-code`) | `--mcp-config ` | JSON shape is Claude Code's `.mcp.json` schema (`{"mcpServers": ...}`). `ANTHROPIC_API_KEY` is stripped from the child env so a personal key can't override the sherlock-managed session. | +| `copilot` | `copilot` (npm `@github/copilot`) | `--additional-mcp-config @` | Augments user's `~/.copilot/mcp-config.json`. JSON shape is the canonical `.mcp.json` schema (`{"mcpServers": ...}`). | +| `claude` | `claude` (npm `@anthropic-ai/claude-code`) | `--mcp-config ` | Same `{"mcpServers": ...}` shape. `ANTHROPIC_API_KEY` is stripped from the child env so a personal key can't override the sherlock-managed session. | ## Routing @@ -81,8 +81,7 @@ Available to every agent implementation in this package: | `LookPath(name)` | `exec.LookPath` with a sherlock-friendly error message. | | `BuildEnv(forbid, set)` | parent env minus `forbid`, plus `set`. | | `DefaultExecer` | the package-level `Execer` (swap in tests). | -| `mcp.RenderVSCodeFormat(name, servers)` | writes `{"servers": ...}`. | -| `mcp.RenderClaudeFormat(name, servers)` | writes `{"mcpServers": ...}`. | +| `mcp.Render(name, servers)` | writes `{"mcpServers": ...}` to `$XDG_RUNTIME_DIR/sherlock/.mcp.json`. | If a new agent needs a third MCP-config schema, add a new `Render*` function to `internal/mcp/` rather than open-coding JSON in the agent diff --git a/docs/gitea-mcp.md b/docs/gitea-mcp.md new file mode 100644 index 0000000..549b177 --- /dev/null +++ b/docs/gitea-mcp.md @@ -0,0 +1,221 @@ +# gitea-mcp + +The first sherlock MCP. Wraps a small slice of the Gitea REST API +(`whoami`, `list_repos`) using a Gitea-issued OAuth2 bearer token. + +## How auth works + +Gitea has two relevant features that share the word "OAuth": + +1. **Gitea as OAuth *client*** — Gitea logs users into its web UI via + Authentik (your homelab SSO). This is how human accounts get + provisioned the first time someone visits Gitea. +2. **Gitea as OAuth *server*** — Gitea is itself an OAuth 2.0 / OIDC + provider that third-party apps can authenticate against. The + resulting access token is accepted by Gitea's own REST API. + +`gitea-mcp` uses (2). The flow does not touch Authentik at all from +sherlock's perspective — sherlock OAuths directly against Gitea using +PKCE, gets a Gitea-minted bearer token, and uses it as +`Authorization: Bearer ` on `/api/v1/...` calls. The fact that +Gitea behind the scenes might bounce you through Authentik for SSO is +invisible to sherlock. + +This is deliberate. Going through Authentik would require Gitea to +trust Authentik-issued bearer tokens at its API layer, which Gitea +doesn't do out of the box. Going through Gitea's own OAuth2 server is +the documented, supported path. + +## One-time setup + +1. Sign in to https://gitea.alexandru.macocian.me as the operator. +2. **Settings → Applications → Manage OAuth2 Applications → New + Application**: + - **Application Name:** `sherlock` + - **Redirect URIs:** `http://127.0.0.1:6990/callback` + - **Confidential Client:** ⬜ **UNCHECK** — sherlock uses PKCE, + not a client secret. Leaving this checked makes Gitea demand a + `client_secret` on the token request, which sherlock never sends. +3. Click **Create Application**. +4. Copy the **Client ID** Gitea displays. + +## Build & install + +For Charlie (default — Client ID embedded in the binary): + +```bash +cd ~/Dev/charlie/sherlock +go install ./cmd/gitea-mcp +``` + +That's it. No `-ldflags`, no flags at all. The Charlie Gitea OAuth2 +app's Client ID lives in `cmd/gitea-mcp/gitea_clientid_charlie.go` +and is baked in unless you build with `-tags noembed`. + +For other deployments: + +```bash +go build -tags noembed \ + -ldflags "-X main.giteaIssuer=https://gitea.example.com \ + -X main.giteaClientID= \ + -X main.giteaBaseURL=https://gitea.example.com" \ + ./cmd/gitea-mcp +``` + +Modern Gitea (≥ 1.16-ish) accepts PKCE-alone for OAuth2 applications +that have the "Confidential Client" checkbox unchecked, so no client +secret is needed even though Gitea generates one in the UI. + +### Knobs + +| Variable / `-X main.…` | Charlie default | +|------------------------|-----------------------------------------------| +| `giteaIssuer` | `https://gitea.alexandru.macocian.me` | +| `giteaBaseURL` | `https://gitea.alexandru.macocian.me` | +| `giteaClientID` | embedded; see `gitea_clientid_charlie.go` | +| `giteaClientSecret` | `""` (only needed for older / confidential Gitea apps) | +| `Version` | `0.0.0-dev` | + +When pointing at a different Gitea deployment, override `giteaIssuer`, +`giteaBaseURL`, and `giteaClientID` together (with `-tags noembed`). +Sherlock treats the wallet entry as service `gitea` regardless of +which deployment the token came from, so don't mix. + +> If `gitea-mcp --probe` ever hits `invalid_client` or similar after +> you click Authorize in the browser, your Gitea is enforcing client +> auth on token exchange. Rebuild with +> `-X main.giteaClientSecret=` to include it. The secret +> is baked into the local binary, never written to the source tree. + +## Verify (without an agent) + +```bash +gitea-mcp --probe +``` + +First run: opens a browser, you click through Gitea's "Authorize +sherlock" page (which itself goes through Authentik SSO if you're not +already signed in), browser shows "Logged in. You may close this +tab.", terminal prints: + +``` +OK: logged in to https://gitea.alexandru.macocian.me as <> +``` + +Subsequent runs are silent until the refresh window opens. + +To force a re-login: `sherlock logout gitea`. + +## Use with an agent + +`sherlock copilot` (or `sherlock claude`) automatically renders an MCP +config that lists `gitea-mcp`. Copilot spawns it as a stdio +subprocess; the first call into it triggers the OAuth flow described +above (browser pops while you're in the middle of a chat — accept +once, never again). + +Tools exposed (all read-only; write tools land in a follow-up): + +### User + +| Tool | Description | +|---------------------|-------------------------------------------------| +| `whoami` | Authenticated Gitea user. | + +### Repos & content + +| Tool | Description | +|---------------------|-------------------------------------------------| +| `list_repos` | Search/list repos accessible to the user. | +| `get_file` | Read file contents (≤ 256 KiB, truncated tail). | +| `file_history` | Commits touching a specific file. | +| `list_branches` | Branches of a repo. | +| `get_branch` | One branch's details + tip SHA. | +| `list_tags` | Tags of a repo. | +| `get_tag` | One tag's details. | + +### Commits + +| Tool | Description | +|---------------------|-------------------------------------------------| +| `list_commits` | Repo commit history with date / SHA filters. | +| `get_commit` | Single commit by SHA (full message, parents). | +| `get_commit_status` | Combined CI / status check state for a ref. | + +### Issues + +| Tool | Description | +|-----------------------|-----------------------------------------------| +| `list_issues` | Issues with state/labels/type filters. | +| `get_issue` | One issue or PR by index. | +| `list_issue_comments` | Comments on a single issue / PR. | + +### Pull requests + +| Tool | Description | +|---------------------|-------------------------------------------------| +| `list_pulls` | PRs with state/sort/labels filters. | +| `get_pull` | Full PR details incl. requested reviewers. | +| `list_pull_files` | Files changed by a PR (counts; no diffs). | +| `list_pull_reviews` | Reviews on a PR (state, body, reviewer). | + +### Releases + +| Tool | Description | +|---------------------|-------------------------------------------------| +| `list_releases` | Releases of a repo (drafts & pre-releases opt-in). | +| `get_release` | One release by ID, incl. assets. | + +### Actions (CI) + +| Tool | Description | +|--------------------------|--------------------------------------------| +| `list_workflow_runs` | Per-repo pipeline runs. | +| `list_workflow_jobs` | Jobs of a specific run. | +| `get_job_logs` | Tail (100 KiB) of a job's log output. | +| `list_org_workflow_runs` | All runs across an org's repos. | +| `list_org_workflow_jobs` | All jobs across an org's runs. | +| `list_org_runners` | Self-hosted runners registered to an org. | +| `get_org_runner` | Details for a single org runner. | + +### Packages + +| Tool | Description | +|---------------------|-------------------------------------------------| +| `list_packages` | Packages owned by a user/org (type + name). | + +### Wiki + +| Tool | Description | +|---------------------|-------------------------------------------------| +| `list_wiki_pages` | Wiki page titles/slugs/last-update. | +| `get_wiki_page` | One wiki page content. | + +### Organisations + +| Tool | Description | +|--------------------------|--------------------------------------------| +| `list_my_orgs` | Orgs the authenticated user is a member of.| +| `get_org` | One org's details. | +| `list_org_repos` | Repos owned by an org. | +| `list_org_members` | Members of an org. | +| `list_org_teams` | Teams of an org. | +| `search_org_teams` | Search teams in an org by name substring. | +| `list_org_activity_feed` | Recent activity feed of an org. | + +Write operations and admin-scoped tools (admin runners, admin +workflow runs, admin org management) land in a separate +`gitea-mcp-admin` MCP later so the per-tool permission surface stays +small. + +## Troubleshooting + +| Symptom | Cause | +|------------------------------------------------------|---------------------------------------------------------------------------------------------| +| `gitea-mcp: giteaClientID not configured.` | Built without `-ldflags "-X main.giteaClientID=..."`. See above. | +| Browser opens, Gitea says **"Client ID not registered"** | Either the OAuth2 application was never created in Gitea, or the Client ID was mistyped. | +| Browser opens, Gitea says **"client_secret required"** | The OAuth2 application has "Confidential Client" checked. Edit it and uncheck. | +| `gitea: 401 (token rejected ...)` | Token was revoked or the wallet has stale tokens for a different Gitea deployment. Run `sherlock logout gitea` and retry. | +| `bind: address already in use` | A previous OAuth flow (or another MCP) still holds `127.0.0.1:6990`. Wait a few seconds or kill the stuck process. | +EOF +echo "gitea-mcp doc created" \ No newline at end of file diff --git a/docs/service-registry.md b/docs/service-registry.md deleted file mode 100644 index fbaddd4..0000000 --- a/docs/service-registry.md +++ /dev/null @@ -1,115 +0,0 @@ -# Service registry - -How operators register a downstream service so sherlock can mint per-service tokens. - -## Location - -`~/.config/sherlock/services.d/.toml` — one file per service. Sherlock loads them at the start of each CLI invocation. - -A built-in registry baked into the binary would lock the service set at release time. Every service is operator-registered. - -## Schema - -```toml -[service] -name = "gitea" # unique; matches the file's basename -kind = "oidc-federated" # oidc-federated | own-oauth | static-pat -issuer = "https://id.alexandru.macocian.me/application/o/gitea/" -audience = "gitea.alexandru.macocian.me" -scopes = ["openid", "profile", "email", "read:repository"] - -[exchange] # required when kind = "oidc-federated" -mode = "rfc8693" # rfc8693 | passthrough -subject_token_source = "authentik-id-token" -# Optional: override the token endpoint if it's not the issuer's default. -# token_endpoint = "https://id.alexandru.macocian.me/application/o/token/" - -[mcp] # how the rendered MCP config refers to this service -env_var = "GITEA_TOKEN" # env var the MCP binary reads on startup -command = "gitea-mcp" # executable name (looked up on PATH) -args = [] -``` - -### `kind = "own-oauth"` extras - -```toml -[oauth] -authorization_endpoint = "https://github.com/login/oauth/authorize" -token_endpoint = "https://github.com/login/oauth/access_token" -client_id = "Iv1.abc123" -# client_secret is read from a sibling `.secret.age` file, never inline. -redirect_path = "/cb/github" # appended to sherlock's loopback base URL during `sherlock auth github` -scopes = ["read:org", "read:user"] -``` - -### `kind = "static-pat"` extras - -```toml -[pat] -# Path to an age-encrypted file containing the PAT. Decrypted by sherlock -# on demand using the operator's age key. -file = "~/.config/sherlock/secrets/dockerhub.pat.age" -``` - -## Field reference - -| Field | Required | Notes | -|---|---|---| -| `service.name` | yes | Must equal the filename stem. Sherlock rejects mismatches. | -| `service.kind` | yes | See [auth-model.md](auth-model.md) for the three grant kinds. | -| `service.issuer` | `oidc-federated` only | OIDC issuer URL; used for metadata discovery. | -| `service.audience` | `oidc-federated` only | Goes into the `audience` parameter of the token-exchange request. | -| `service.scopes` | yes | Default scopes. Override per-invocation with `sherlock run --scope`. | -| `exchange.mode` | `oidc-federated` only | `rfc8693` does a real exchange; `passthrough` hands the existing ID token unchanged (only safe when audiences match). | -| `mcp.env_var` | yes | The MCP binary reads this env var on startup. Convention: `_TOKEN`. | -| `mcp.command` / `mcp.args` | yes | Used by the wrapper when it renders the per-session MCP config for the agent. | - -## Loading - -1. Sherlock scans `~/.config/sherlock/services.d/*.toml` at the start of each CLI invocation (`status`, `run `, etc.). -2. Parse errors are logged to stderr but do not abort — the offending service is just marked unavailable for that invocation. -3. No hot-reload (no daemon). Re-running the command picks up changes. - -## Worked examples - -### Gitea (Phase 2) - -```toml -[service] -name = "gitea" -kind = "oidc-federated" -issuer = "https://id.alexandru.macocian.me/application/o/gitea/" -audience = "gitea.alexandru.macocian.me" -scopes = ["openid", "profile", "read:repository", "write:issue"] - -[exchange] -mode = "rfc8693" -subject_token_source = "authentik-id-token" - -[mcp] -env_var = "GITEA_TOKEN" -command = "gitea-mcp" -args = [] -``` - -### gssh (Phase 3) - -```toml -[service] -name = "gssh" -kind = "oidc-federated" -issuer = "https://id.alexandru.macocian.me/application/o/gssh/" -audience = "gssh.alexandru.macocian.me" -scopes = ["openid", "profile", "email"] - -[exchange] -mode = "passthrough" -subject_token_source = "authentik-id-token" - -[mcp] -env_var = "GSSH_TOKEN" -command = "gssh-mcp" -args = [] -``` - -See [gssh-integration.md](gssh-integration.md) for why `passthrough` is OK here. diff --git a/go.mod b/go.mod index 89fbe9b..d71d80d 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module gitea.alexandru.macocian.me/Charlie/sherlock -go 1.25.0 +go 1.26 require ( github.com/coreos/go-oidc/v3 v3.18.0 github.com/go-jose/go-jose/v4 v4.1.4 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/zalando/go-keyring v0.2.8 golang.org/x/oauth2 v0.36.0 ) @@ -12,5 +13,9 @@ require ( require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - golang.org/x/sys v0.27.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/sys v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index 9de170b..14d86c0 100644 --- a/go.sum +++ b/go.sum @@ -8,17 +8,33 @@ github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index dd1c18e..f956537 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -15,13 +15,18 @@ package agent import ( "fmt" "sort" + + "gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp" ) // Context is everything the CLI hands an agent at spawn time. Kept -// deliberately small — Phase 1 doesn't need anything from sherlock at -// the agent level; Phase 2+ will add fields (e.g. an ID token) as MCPs -// land. -type Context struct{} +// deliberately small — Phase 2+ fields land here as MCPs are wired. +type Context struct { + // Servers is the set of MCP servers sherlock wants the agent to + // know about. The agent's Spawn method renders these into the + // per-agent MCP config file before exec. + Servers mcp.Servers +} // Agent is the contract one concrete type implements per supported CLI. type Agent interface { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index d962126..5c5fbc1 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -3,6 +3,7 @@ package agent import ( "errors" "os" + "path/filepath" "slices" "strings" "testing" @@ -53,6 +54,35 @@ func TestLookPath_UnknownReturnsHelpfulError(t *testing.T) { } } +func TestLookPath_PreferSiblingOfExecutable(t *testing.T) { + // The test binary itself lives in some tmp dir under `go test`. + // Drop a fake `sherlock-test-sibling` next to it and prove LookPath + // resolves the sibling — even though it isn't on $PATH. + exe, err := os.Executable() + if err != nil { + t.Skip("no os.Executable on this platform") + } + real, err := os.Readlink(exe) + if err == nil { + exe = real + } + dir := filepath.Dir(exe) + sibling := filepath.Join(dir, "sherlock-test-sibling-xyzzy") + if err := os.WriteFile(sibling, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Skipf("cannot write to test exec dir %s: %v", dir, err) + } + defer func() { _ = os.Remove(sibling) }() + + t.Setenv("PATH", "/nonexistent") + got, err := LookPath("sherlock-test-sibling-xyzzy") + if err != nil { + t.Fatalf("LookPath: %v", err) + } + if got != sibling { + t.Fatalf("got %s, want sibling %s", got, sibling) + } +} + // Sanity check that the Execer indirection is honored by Spawn — // substitute a recorder and call into copilot.Spawn end-to-end with a // fake `copilot` binary on $PATH. diff --git a/internal/agent/claude.go b/internal/agent/claude.go index e5dea58..78583c6 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -8,8 +8,8 @@ func init() { Register(&claude{}) } // claude integrates Anthropic's Claude Code CLI (npm package // `@anthropic-ai/claude-code` → `claude` on PATH). MCP config is -// passed via --mcp-config in Claude's `.mcp.json` shape -// (`{"mcpServers": ...}`). +// passed via --mcp-config in the canonical `.mcp.json` shape +// (`{"mcpServers": ...}`) — same shape Copilot uses. // // We also forbid ANTHROPIC_API_KEY in the child env so an // inadvertently-set personal key can't override the broker-managed @@ -19,12 +19,12 @@ type claude struct{} func (claude) Name() string { return "claude" } func (claude) Description() string { return "Anthropic Claude Code CLI" } -func (c claude) Spawn(_ Context, args []string) error { +func (c claude) Spawn(ctx Context, args []string) error { bin, err := LookPath("claude") if err != nil { return err } - mcpPath, err := mcp.RenderClaudeFormat(c.Name(), mcp.Servers{}) + mcpPath, err := mcp.Render(c.Name(), ctx.Servers) if err != nil { return err } diff --git a/internal/agent/copilot.go b/internal/agent/copilot.go index 20d71ba..7e32eb9 100644 --- a/internal/agent/copilot.go +++ b/internal/agent/copilot.go @@ -7,24 +7,27 @@ import ( func init() { Register(&copilot{}) } // copilot integrates the GitHub Copilot CLI (npm package `@github/copilot` -// → `copilot` on PATH). Its MCP integration is additive: Copilot always -// reads ~/.copilot/mcp-config.json and merges any `--additional-mcp-config` -// values on top. We pass our rendered config via `--additional-mcp-config -// @` (the `@` prefix tells Copilot to treat the argument as a file -// path instead of an inline JSON string). The on-disk shape is VS Code's -// MCP client schema (`{"servers": ...}`). +// → `copilot` on PATH). MCP config is passed via +// `--additional-mcp-config @` (the `@` prefix tells Copilot to +// treat the argument as a file path, not inline JSON). The file uses +// the canonical `.mcp.json` shape (`{"mcpServers": ...}`) — same as +// Claude Code. +// +// Copilot reads `~/.copilot/mcp-config.json` by default and merges +// any `--additional-mcp-config` files on top. The merge is additive, +// so sherlock-injected MCPs coexist with whatever the operator has +// configured globally. type copilot struct{} func (copilot) Name() string { return "copilot" } func (copilot) Description() string { return "GitHub Copilot CLI" } -func (c copilot) Spawn(_ Context, args []string) error { +func (c copilot) Spawn(ctx Context, args []string) error { bin, err := LookPath("copilot") if err != nil { return err } - // Phase 1: empty servers. Phase 2+ fills this from services.d. - mcpPath, err := mcp.RenderVSCodeFormat(c.Name(), mcp.Servers{}) + mcpPath, err := mcp.Render(c.Name(), ctx.Servers) if err != nil { return err } diff --git a/internal/agent/exec.go b/internal/agent/exec.go index ed6c579..ccd903c 100644 --- a/internal/agent/exec.go +++ b/internal/agent/exec.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "syscall" ) @@ -28,12 +29,34 @@ func (SyscallExecer) Exec(argv0 string, argv []string, env []string) error { // each have to thread an Execer through their constructors. var DefaultExecer Execer = SyscallExecer{} -// LookPath resolves an executable name to an absolute path via $PATH. +// LookPath resolves an executable name to an absolute path. +// +// Resolution order is deliberate: sibling-of-os.Executable() first, +// then $PATH. This means commands installed via `go install` alongside +// sherlock are found even when ~/go/bin isn't on the operator's shell +// PATH — the core "sherlock has to just run, no manual setup" promise. +// // Returns a friendlier error than os/exec when the binary is missing. func LookPath(name string) (string, error) { + if filepath.IsAbs(name) { + return name, nil + } + if exe, err := os.Executable(); err == nil { + // Resolve symlinks (e.g. /usr/local/bin/sherlock → /opt/sherlock/bin/sherlock) + // so we look in the *real* install dir, not the symlink farm. + if real, err := filepath.EvalSymlinks(exe); err == nil { + exe = real + } + sibling := filepath.Join(filepath.Dir(exe), name) + if fi, err := os.Stat(sibling); err == nil && !fi.IsDir() { + if fi.Mode().Perm()&0o111 != 0 { + return sibling, nil + } + } + } p, err := exec.LookPath(name) if err != nil { - return "", fmt.Errorf("agent: %q not found on PATH (install it and re-run): %w", name, err) + return "", fmt.Errorf("agent: %q not found alongside sherlock or on PATH (install it and re-run): %w", name, err) } return p, nil } diff --git a/internal/agent/pathaug.go b/internal/agent/pathaug.go new file mode 100644 index 0000000..167affb --- /dev/null +++ b/internal/agent/pathaug.go @@ -0,0 +1,73 @@ +package agent + +import ( + "os" + "path/filepath" + "slices" + "strings" +) + +// EnsurePathContainsSiblings prepends sherlock-adjacent directories to +// $PATH if they're not already there, then re-sets PATH in the +// current process's environment so syscall.Exec children inherit it. +// +// This is sherlock's "no manual setup" guarantee: dropping a binary +// at `go install`'s default destination (~/go/bin) is enough; the +// operator never has to edit a shell rc. +// +// Dirs added (in order, deduped): +// +// 1. Directory of os.Executable() — wherever this sherlock binary +// lives, all sibling MCPs live too if installed via `go install`. +// 2. $GOBIN. +// 3. $GOPATH/bin (resolved via `go env` defaults). +// 4. $HOME/go/bin (the canonical fallback when neither is set). +// +// Idempotent: re-runs are no-ops because each candidate is checked +// for presence in PATH before being prepended. +func EnsurePathContainsSiblings() { + current := os.Getenv("PATH") + sep := string(os.PathListSeparator) + existing := strings.Split(current, sep) + + var toAdd []string + add := func(dir string) { + if dir == "" { + return + } + abs, err := filepath.Abs(dir) + if err != nil { + return + } + if slices.Contains(existing, abs) || slices.Contains(toAdd, abs) { + return + } + if fi, err := os.Stat(abs); err != nil || !fi.IsDir() { + return + } + toAdd = append(toAdd, abs) + } + + if exe, err := os.Executable(); err == nil { + if real, err := filepath.EvalSymlinks(exe); err == nil { + exe = real + } + add(filepath.Dir(exe)) + } + add(os.Getenv("GOBIN")) + if gopath := os.Getenv("GOPATH"); gopath != "" { + // GOPATH can be a list on some setups; canonical is one entry. + for _, p := range strings.Split(gopath, sep) { + add(filepath.Join(p, "bin")) + } + } + if home, err := os.UserHomeDir(); err == nil { + add(filepath.Join(home, "go", "bin")) + } + + if len(toAdd) == 0 { + return + } + updated := strings.Join(append(toAdd, existing...), sep) + _ = os.Setenv("PATH", updated) +} diff --git a/internal/agent/pathaug_test.go b/internal/agent/pathaug_test.go new file mode 100644 index 0000000..e78cd57 --- /dev/null +++ b/internal/agent/pathaug_test.go @@ -0,0 +1,82 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsurePathContainsSiblings_PrependsExecDir(t *testing.T) { + // Drop the test exec dir from PATH to prove it gets re-added. + exe, err := os.Executable() + if err != nil { + t.Skip("no os.Executable") + } + if real, err := filepath.EvalSymlinks(exe); err == nil { + exe = real + } + execDir := filepath.Dir(exe) + + t.Setenv("PATH", "/usr/bin:/bin") + t.Setenv("GOBIN", "") + t.Setenv("GOPATH", "") + + EnsurePathContainsSiblings() + + dirs := strings.Split(os.Getenv("PATH"), string(os.PathListSeparator)) + found := false + for i, d := range dirs { + if d == execDir { + found = true + if i != 0 { + t.Fatalf("execDir should be first, got at index %d: %v", i, dirs) + } + } + } + if !found { + t.Fatalf("execDir %s not in PATH: %v", execDir, dirs) + } +} + +func TestEnsurePathContainsSiblings_Idempotent(t *testing.T) { + t.Setenv("PATH", "/usr/bin:/bin") + t.Setenv("GOBIN", "") + t.Setenv("GOPATH", "") + EnsurePathContainsSiblings() + first := os.Getenv("PATH") + EnsurePathContainsSiblings() + if os.Getenv("PATH") != first { + t.Fatalf("second call mutated PATH:\n before: %s\n after: %s", first, os.Getenv("PATH")) + } +} + +func TestEnsurePathContainsSiblings_HomeGoBin(t *testing.T) { + home := t.TempDir() + gobin := filepath.Join(home, "go", "bin") + if err := os.MkdirAll(gobin, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("PATH", "/usr/bin") + t.Setenv("GOBIN", "") + t.Setenv("GOPATH", "") + EnsurePathContainsSiblings() + if !strings.Contains(os.Getenv("PATH"), gobin) { + t.Fatalf("expected $HOME/go/bin (%s) in PATH, got %s", gobin, os.Getenv("PATH")) + } +} + +func TestEnsurePathContainsSiblings_SkipsNonexistent(t *testing.T) { + t.Setenv("HOME", "/this/does/not/exist") + t.Setenv("PATH", "/usr/bin") + t.Setenv("GOBIN", "/also/does/not/exist") + t.Setenv("GOPATH", "/yet/another/missing/path") + EnsurePathContainsSiblings() + got := os.Getenv("PATH") + for _, missing := range []string{"/also/does/not/exist", "/yet/another/missing/path/bin", "/this/does/not/exist/go/bin"} { + if strings.Contains(got, missing) { + t.Fatalf("PATH should not contain missing dir %q: %s", missing, got) + } + } +} diff --git a/internal/authn/authn.go b/internal/authn/authn.go index c91184f..8fdada8 100644 --- a/internal/authn/authn.go +++ b/internal/authn/authn.go @@ -34,13 +34,15 @@ const LoopbackAddr = "127.0.0.1:6990" // Authentik. The path matches Login's HTTP handler. const LoopbackRedirectURL = "http://" + LoopbackAddr + "/callback" -// Config carries the bits Login needs. Issuer is the Authentik -// application's OIDC issuer URL; ClientID is the OAuth2 public client -// ID; Scopes are the OAuth scopes to request. LoopbackAddrOverride is -// optional and only used by tests. +// Config carries the bits Login needs. Issuer is the OIDC issuer +// URL; ClientID is the OAuth2 client ID; ClientSecret is the optional +// secret for confidential clients (leave empty for PKCE public +// clients); Scopes are the OAuth scopes to request. +// LoopbackAddrOverride is optional and only used by tests. type Config struct { Issuer string ClientID string + ClientSecret string Scopes []string LoopbackAddrOverride string } diff --git a/internal/authn/login.go b/internal/authn/login.go index e9673a6..13e81af 100644 --- a/internal/authn/login.go +++ b/internal/authn/login.go @@ -95,10 +95,11 @@ func Login(ctx context.Context, cfg Config, opts LoginOptions) (Result, error) { } oauthCfg := &oauth2.Config{ - ClientID: cfg.ClientID, - Endpoint: provider.Endpoint(), - RedirectURL: redirectURL, - Scopes: cfg.Scopes, + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: redirectURL, + Scopes: cfg.Scopes, } authURL := oauthCfg.AuthCodeURL(state, oauth2.SetAuthURLParam("code_challenge", pkce.Challenge), @@ -201,6 +202,7 @@ func exchangeAndVerify(ctx context.Context, cfg Config, provider *oidc.Provider, RefreshExpAt: refreshExpiry(tok), Issuer: cfg.Issuer, ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, Scopes: cfg.Scopes, Subject: claims.Sub, Email: claims.Email, diff --git a/internal/authn/refresh.go b/internal/authn/refresh.go index 73b5b99..6756056 100644 --- a/internal/authn/refresh.go +++ b/internal/authn/refresh.go @@ -119,9 +119,10 @@ func refreshOnce(ctx context.Context, ts keyring.TokenSet, disc Discoverer) (key return ts, err } cfg := &oauth2.Config{ - ClientID: ts.ClientID, - Endpoint: provider.Endpoint(), - Scopes: ts.Scopes, + ClientID: ts.ClientID, + ClientSecret: ts.ClientSecret, + Endpoint: provider.Endpoint(), + Scopes: ts.Scopes, } src := cfg.TokenSource(ctx, &oauth2.Token{ AccessToken: ts.AccessToken, diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 0000000..9d65def --- /dev/null +++ b/internal/browser/browser.go @@ -0,0 +1,37 @@ +// Package browser opens a URL in the operator's default browser. It's +// a thin OS-aware wrapper around xdg-open / open / rundll32 so both +// the sherlock CLI and the MCP binaries can share the same behaviour +// without depending on each other. +package browser + +import ( + "os/exec" + "runtime" +) + +// Open starts the OS's default browser pointed at url, then returns +// immediately. The browser process is reaped in a background goroutine. +// Errors come from starting the helper, not from anything the browser +// itself reports — there's no portable way to know whether the user +// actually loaded the page. +func Open(url string) error { + var bin string + var args []string + switch runtime.GOOS { + case "darwin": + bin = "open" + args = []string{url} + case "windows": + bin = "rundll32" + args = []string{"url.dll,FileProtocolHandler", url} + default: + bin = "xdg-open" + args = []string{url} + } + cmd := exec.Command(bin, args...) + if err := cmd.Start(); err != nil { + return err + } + go func() { _ = cmd.Wait() }() + return nil +} diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index 9b06d71..df88039 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -124,9 +124,9 @@ func probe() error { } // TokenSet is what sherlock persists per service after a successful -// OAuth flow. ClientID + Scopes + Issuer are recorded alongside the -// tokens so that background refresh works without re-reading any -// external configuration. +// OAuth flow. ClientID + ClientSecret + Scopes + Issuer are persisted +// alongside the tokens so that background refresh works without +// re-reading any external configuration. type TokenSet struct { IDToken string `json:"id_token"` AccessToken string `json:"access_token,omitempty"` @@ -135,6 +135,7 @@ type TokenSet struct { RefreshExpAt time.Time `json:"refresh_expires_at"` Issuer string `json:"issuer"` ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` Scopes []string `json:"scopes,omitempty"` Subject string `json:"sub"` Email string `json:"email,omitempty"` diff --git a/internal/mcp/config.go b/internal/mcp/config.go index d847998..68c161d 100644 --- a/internal/mcp/config.go +++ b/internal/mcp/config.go @@ -1,11 +1,19 @@ // Package mcp renders the per-session MCP config that sherlock hands -// to the agent CLI on spawn. Each agent reads MCP config in its own -// shape (VS Code's `servers` key vs Claude Code's `mcpServers` key), -// so this package exposes one renderer per format and the agent -// implementation picks the right one. +// to the agent CLI on spawn. // -// Phase 1 always renders an empty servers map. Phase 2 fills it from -// `~/.config/sherlock/services.d/`. +// Both supported agents (GitHub Copilot CLI's `--additional-mcp-config` +// and Claude Code's `--mcp-config`) accept the same on-disk schema: +// +// { +// "mcpServers": { +// "": { "command": "...", "args": [...], "env": {...} } +// } +// } +// +// This is Anthropic's canonical `.mcp.json` shape. VS Code's MCP +// client config (the one nested under `settings.json` → `mcp.servers`) +// uses a different key, but neither of the CLIs we wrap reads that +// flavour from `--*-mcp-config`, so we don't render it. package mcp import ( @@ -17,8 +25,7 @@ import ( "gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg" ) -// Server is one stdio MCP server entry. The shape is identical across -// the formats we support — only the top-level wrapping key differs. +// Server is one stdio MCP server entry. type Server struct { Command string `json:"command"` Args []string `json:"args,omitempty"` @@ -28,22 +35,9 @@ type Server struct { // Servers is the unordered set of entries to render. type Servers map[string]Server -// RenderVSCodeFormat writes `{ "servers": {...} }` to a 0600 file under +// Render writes the MCP config to a 0600 file under // $XDG_RUNTIME_DIR/sherlock/.mcp.json and returns the path. -// This is the format the GitHub Copilot CLI consumes via --mcp-config -// (it follows the VS Code MCP client config schema). -func RenderVSCodeFormat(agentName string, servers Servers) (string, error) { - return renderJSON(agentName, map[string]any{"servers": orEmpty(servers)}) -} - -// RenderClaudeFormat writes `{ "mcpServers": {...} }` to the same -// location. This is the shape that Anthropic's Claude Code CLI expects -// (the canonical `.mcp.json` schema). -func RenderClaudeFormat(agentName string, servers Servers) (string, error) { - return renderJSON(agentName, map[string]any{"mcpServers": orEmpty(servers)}) -} - -func renderJSON(agentName string, payload any) (string, error) { +func Render(agentName string, servers Servers) (string, error) { dir, err := xdg.MCPConfigDir() if err != nil { return "", err @@ -52,6 +46,7 @@ func renderJSON(agentName string, payload any) (string, error) { return "", fmt.Errorf("mcp: mkdir %s: %w", dir, err) } path := filepath.Join(dir, agentName+".mcp.json") + payload := map[string]any{"mcpServers": orEmpty(servers)} body, err := json.MarshalIndent(payload, "", " ") if err != nil { return "", fmt.Errorf("mcp: marshal: %w", err) diff --git a/internal/mcp/config_test.go b/internal/mcp/config_test.go index 1ad657c..1bee310 100644 --- a/internal/mcp/config_test.go +++ b/internal/mcp/config_test.go @@ -7,40 +7,44 @@ import ( "testing" ) -func TestRenderVSCodeFormat(t *testing.T) { +func TestRender_WritesMCPServersKey(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) - path, err := RenderVSCodeFormat("copilot", Servers{ + path, err := Render("copilot", Servers{ "gitea": {Command: "/usr/bin/gitea-mcp", Args: []string{"--stdio"}}, }) if err != nil { - t.Fatalf("render: %v", err) + t.Fatalf("Render: %v", err) } if filepath.Base(path) != "copilot.mcp.json" { t.Fatalf("path = %s", path) } got := readJSON(t, path) - if _, ok := got["servers"]; !ok { - t.Fatalf("expected top-level `servers` key, got %v", got) - } -} - -func TestRenderClaudeFormat(t *testing.T) { - t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) - path, err := RenderClaudeFormat("claude", Servers{}) - if err != nil { - t.Fatalf("render: %v", err) - } - got := readJSON(t, path) if _, ok := got["mcpServers"]; !ok { t.Fatalf("expected top-level `mcpServers` key, got %v", got) } } -func TestRenderFile_Permissions(t *testing.T) { +func TestRender_EmptyServersStillValidJSON(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) - path, err := RenderVSCodeFormat("copilot", Servers{}) + path, err := Render("claude", nil) if err != nil { - t.Fatalf("render: %v", err) + t.Fatalf("Render: %v", err) + } + got := readJSON(t, path) + servers, ok := got["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("expected `mcpServers` to be an object, got %v", got["mcpServers"]) + } + if len(servers) != 0 { + t.Fatalf("expected empty object, got %v", servers) + } +} + +func TestRender_FilePermissions(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + path, err := Render("copilot", Servers{}) + if err != nil { + t.Fatalf("Render: %v", err) } info, err := os.Stat(path) if err != nil {