@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user