Gitea mcp pre-release
CI / build (push) Successful in 19s

This commit is contained in:
2026-05-25 15:22:30 +02:00
parent e6d29e9710
commit f5109abfad
37 changed files with 2975 additions and 199 deletions
+1 -1
View File
@@ -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)
+93
View File
@@ -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 <PAT>`, not the
// `Authorization: Bearer <OAuth>` 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
}
+27
View File
@@ -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.
}
+179
View File
@@ -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)
}
}
+78
View File
@@ -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)
}
}
+277
View File
@@ -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
}
}
+191
View File
@@ -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
}
}
+144
View File
@@ -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
}
}
+237
View File
@@ -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
}
}
+285
View File
@@ -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
}
}
+53
View File
@@ -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
}
}
+272
View File
@@ -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
}
}
+193
View File
@@ -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
}
}
+99
View File
@@ -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
}
}
+68
View File
@@ -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
}
}
+35
View File
@@ -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
}
}
+128
View File
@@ -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
}
}
+34 -1
View File
@@ -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 {
+3 -4
View File
@@ -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 @<path>` | 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 <path>` | 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 @<path>` | 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 <path>` | 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/<name>.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
+221
View File
@@ -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 <token>` 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=<CLIENT_ID> \
-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=<the secret>` 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 <login> <<email>>
```
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"
-115
View File
@@ -1,115 +0,0 @@
# Service registry
How operators register a downstream service so sherlock can mint per-service tokens.
## Location
`~/.config/sherlock/services.d/<name>.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 `<name>.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: `<SERVICE>_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 <agent>`, 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.
+7 -2
View File
@@ -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
)
+18 -2
View File
@@ -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=
+9 -4
View File
@@ -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 {
+30
View File
@@ -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.
+4 -4
View File
@@ -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 <path> in Claude's `.mcp.json` shape
// (`{"mcpServers": ...}`).
// passed via --mcp-config <path> 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
}
+12 -9
View File
@@ -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
// @<path>` (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 @<path>` (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
}
+25 -2
View File
@@ -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
}
+73
View File
@@ -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)
}
+82
View File
@@ -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)
}
}
}
+6 -4
View File
@@ -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
}
+6 -4
View File
@@ -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,
+4 -3
View File
@@ -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,
+37
View File
@@ -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
}
+4 -3
View File
@@ -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"`
+18 -23
View File
@@ -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": {
// "<name>": { "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/<agentName>.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)
+22 -18
View File
@@ -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 {