@@ -16,5 +16,6 @@ agent CLIs with generated MCP config, and lets MCPs authenticate lazily.
|
||||
- [gitea-mcp](docs/gitea-mcp.md)
|
||||
- [grafana-mcp](docs/grafana-mcp.md)
|
||||
- [gssh-mcp](docs/gssh-mcp.md)
|
||||
- [searxng-mcp](docs/searxng-mcp.md)
|
||||
- [gssh integration](docs/gssh-integration.md)
|
||||
- [Conventions](docs/conventions.md)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrUnauthorized = errors.New("searxng: bearer rejected")
|
||||
|
||||
type searxngAPI struct {
|
||||
baseURL string
|
||||
token func(context.Context) (string, error)
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type searchParams struct {
|
||||
Query string
|
||||
Categories string
|
||||
Engines string
|
||||
Language string
|
||||
TimeRange string
|
||||
SafeSearch *int
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type searxngSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
NumberOfResults int `json:"number_of_results"`
|
||||
Results []searxngResult `json:"results"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
Answers []string `json:"answers,omitempty"`
|
||||
Corrections []string `json:"corrections,omitempty"`
|
||||
}
|
||||
|
||||
type searxngResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Engine string `json:"engine,omitempty"`
|
||||
Engines []string `json:"engines,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
PublishedDate string `json:"publishedDate,omitempty"`
|
||||
ImgSrc string `json:"img_src,omitempty"`
|
||||
Thumbnail string `json:"thumbnail,omitempty"`
|
||||
}
|
||||
|
||||
func (s *searxngAPI) search(ctx context.Context, p searchParams) (searxngSearchResponse, error) {
|
||||
q := url.Values{
|
||||
"format": []string{"json"},
|
||||
"q": []string{p.Query},
|
||||
}
|
||||
setIf(q, "categories", p.Categories)
|
||||
setIf(q, "engines", p.Engines)
|
||||
setIf(q, "language", p.Language)
|
||||
setIf(q, "time_range", p.TimeRange)
|
||||
if p.SafeSearch != nil {
|
||||
q.Set("safesearch", strconv.Itoa(*p.SafeSearch))
|
||||
}
|
||||
if p.Page > 0 {
|
||||
q.Set("pageno", strconv.Itoa(p.Page))
|
||||
}
|
||||
|
||||
full := s.baseURL + "/search?" + q.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
|
||||
if err != nil {
|
||||
return searxngSearchResponse{}, err
|
||||
}
|
||||
tok, err := s.token(ctx)
|
||||
if err != nil {
|
||||
return searxngSearchResponse{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return searxngSearchResponse{}, err
|
||||
}
|
||||
defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }()
|
||||
if err := checkStatus(resp, req); err != nil {
|
||||
return searxngSearchResponse{}, err
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
|
||||
return searxngSearchResponse{}, fmt.Errorf("searxng: %s returned %q, not JSON; ensure SearXNG enables format=json and Caddy is not serving a login page", req.URL.Path, ct)
|
||||
}
|
||||
|
||||
var out searxngSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return searxngSearchResponse{}, fmt.Errorf("searxng: decode JSON search response: %w", err)
|
||||
}
|
||||
if p.Limit > 0 && len(out.Results) > p.Limit {
|
||||
out.Results = out.Results[:p.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func checkStatus(resp *http.Response, req *http.Request) error {
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("%w by %s (try `sherlock logout searxng` and retry; Caddy must accept the OAuth bearer)", ErrUnauthorized, req.URL.Host)
|
||||
}
|
||||
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther || resp.StatusCode == http.StatusTemporaryRedirect || resp.StatusCode == http.StatusPermanentRedirect {
|
||||
return fmt.Errorf("searxng: %s %s redirected to %q; Caddy is likely requiring browser-session auth instead of accepting the OAuth bearer", req.Method, req.URL.Path, resp.Header.Get("Location"))
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("searxng: %s %s: HTTP %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setIf(q url.Values, k, v string) {
|
||||
if v != "" {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Command searxng-mcp is sherlock's MCP server for SearXNG. It
|
||||
// authenticates as the operator against Authentik (OIDC + PKCE,
|
||||
// loopback callback) and sends the resulting bearer token to the
|
||||
// Caddy-protected SearXNG JSON search API.
|
||||
//
|
||||
// Two modes:
|
||||
//
|
||||
// searxng-mcp start the stdio MCP server (agents spawn this)
|
||||
// searxng-mcp --probe run auth + one search, print result, exit
|
||||
//
|
||||
// Deployment configuration (the Authentik issuer/client ID and the
|
||||
// SearXNG base URL) comes entirely from the sherlock config file; there
|
||||
// are no compiled-in defaults. See docs/configuration.md.
|
||||
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/amacocian/sherlock/internal/agent"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/authn"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/browser"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/config"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
|
||||
)
|
||||
|
||||
// serviceName is the wallet key under which SearXNG tokens live, and
|
||||
// the [services.<serviceName>] section read from the config file. The
|
||||
// sherlock CLI's `logout searxng` clears the same wallet entry.
|
||||
const serviceName = "searxng"
|
||||
|
||||
// Version is overwritten at build time via -ldflags "-X main.Version=...".
|
||||
var Version = "0.0.0-dev"
|
||||
|
||||
// scopes asked from Authentik. `openid` is required for an ID token;
|
||||
// `profile`/`email` populate user-info claims in `sherlock status`.
|
||||
var scopes = []string{"openid", "profile", "email"}
|
||||
|
||||
func main() {
|
||||
probe := flag.Bool("probe", false, "run auth + one SearXNG search, print result, exit")
|
||||
probeQuery := flag.String("probe-query", "sherlock", "query to use with --probe")
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
if *showVersion {
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
|
||||
svc, err := config.LoadService(serviceName)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
|
||||
if errors.Is(err, config.ErrNotFound) {
|
||||
fmt.Fprintln(os.Stderr, "Create it (see docs/configuration.md) or run the sherlock install script.")
|
||||
}
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
store, err := keyring.Open()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
|
||||
os.Exit(3)
|
||||
}
|
||||
|
||||
agent.EnsurePathContainsSiblings()
|
||||
|
||||
lockPath, err := xdg.RefreshLockPath()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
cfg := authn.Config{
|
||||
Issuer: svc.Issuer,
|
||||
ClientID: svc.ClientID,
|
||||
ClientSecret: svc.ClientSecret,
|
||||
Scopes: scopes,
|
||||
}
|
||||
holder := authn.NewTokenHolder()
|
||||
src := authn.NewTokenSource(store, serviceName, cfg, authn.EnsureOptions{
|
||||
LockPath: lockPath,
|
||||
OnAuthURL: func(u string) {
|
||||
fmt.Fprintln(os.Stderr, "searxng-mcp: opening browser for SearXNG OAuth login:")
|
||||
fmt.Fprintln(os.Stderr, " ", u)
|
||||
if err := browser.Open(u); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "searxng-mcp: open browser:", err)
|
||||
fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
|
||||
}
|
||||
},
|
||||
}, holder.Set).Lifetime(ctx)
|
||||
|
||||
// Lazy auth: the token getter triggers the (possibly browser-opening)
|
||||
// OAuth flow on the first tool call and launches the background
|
||||
// renewer. Auth stays off the MCP startup path so the handshake
|
||||
// completes instantly.
|
||||
token := func(reqCtx context.Context) (string, error) {
|
||||
if err := src.EnsureStarted(reqCtx); err != nil {
|
||||
return "", fmt.Errorf("searxng auth: %w", err)
|
||||
}
|
||||
return holder.AccessToken(), nil
|
||||
}
|
||||
|
||||
api := &searxngAPI{
|
||||
baseURL: strings.TrimRight(svc.BaseURL, "/"),
|
||||
token: token,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if *probe {
|
||||
// --probe authenticates eagerly: that's the whole point of a probe.
|
||||
if err := src.EnsureStarted(ctx); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "searxng-mcp: auth:", err)
|
||||
os.Exit(5)
|
||||
}
|
||||
resp, err := api.search(ctx, searchParams{Query: *probeQuery, Limit: 1})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "searxng-mcp: probe:", err)
|
||||
os.Exit(5)
|
||||
}
|
||||
fmt.Printf("OK: searched %s for %q (%d result(s))\n", svc.BaseURL, *probeQuery, len(resp.Results))
|
||||
return
|
||||
}
|
||||
|
||||
server := mcp.NewServer(&mcp.Implementation{Name: "searxng-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, "searxng-mcp:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func registerTools(server *mcp.Server, api *searxngAPI) {
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "search_web",
|
||||
Description: "Search the operator's OAuth-protected SearXNG instance and return JSON search results.",
|
||||
}, searchWebHandler(api))
|
||||
}
|
||||
|
||||
type searchWebInput struct {
|
||||
Query string `json:"query" jsonschema:"search query"`
|
||||
Categories string `json:"categories,omitempty" jsonschema:"optional comma-separated SearXNG categories, e.g. general, images, news"`
|
||||
Engines string `json:"engines,omitempty" jsonschema:"optional comma-separated SearXNG engines"`
|
||||
Language string `json:"language,omitempty" jsonschema:"optional SearXNG language code"`
|
||||
TimeRange string `json:"time_range,omitempty" jsonschema:"optional time range: day, month, year"`
|
||||
SafeSearch *int `json:"safesearch,omitempty" jsonschema:"optional SearXNG safesearch value: 0, 1, or 2"`
|
||||
Page int `json:"page,omitempty" jsonschema:"optional result page number, default 1"`
|
||||
Limit int `json:"limit,omitempty" jsonschema:"max results to return (1-50, default 10)"`
|
||||
}
|
||||
|
||||
type searchWebOutput struct {
|
||||
Query string `json:"query"`
|
||||
NumberOfResults int `json:"number_of_results,omitempty"`
|
||||
Results []searxngResult `json:"results"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
Answers []string `json:"answers,omitempty"`
|
||||
Corrections []string `json:"corrections,omitempty"`
|
||||
}
|
||||
|
||||
func searchWebHandler(api *searxngAPI) mcp.ToolHandlerFor[searchWebInput, searchWebOutput] {
|
||||
return func(ctx context.Context, _ *mcp.CallToolRequest, in searchWebInput) (*mcp.CallToolResult, searchWebOutput, error) {
|
||||
query := strings.TrimSpace(in.Query)
|
||||
if query == "" {
|
||||
return nil, searchWebOutput{}, errors.New("search_web requires query")
|
||||
}
|
||||
limit := clampInt(in.Limit, 1, 50, 10)
|
||||
page := clampInt(in.Page, 1, 100, 1)
|
||||
if in.SafeSearch != nil && (*in.SafeSearch < 0 || *in.SafeSearch > 2) {
|
||||
return nil, searchWebOutput{}, errors.New("safesearch must be 0, 1, or 2")
|
||||
}
|
||||
resp, err := api.search(ctx, searchParams{
|
||||
Query: query,
|
||||
Categories: strings.TrimSpace(in.Categories),
|
||||
Engines: strings.TrimSpace(in.Engines),
|
||||
Language: strings.TrimSpace(in.Language),
|
||||
TimeRange: strings.TrimSpace(in.TimeRange),
|
||||
SafeSearch: in.SafeSearch,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, searchWebOutput{}, err
|
||||
}
|
||||
return nil, searchWebOutput{
|
||||
Query: resp.Query,
|
||||
NumberOfResults: resp.NumberOfResults,
|
||||
Results: resp.Results,
|
||||
Suggestions: resp.Suggestions,
|
||||
Answers: resp.Answers,
|
||||
Corrections: resp.Corrections,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -132,6 +132,11 @@ func runStatus(store keyring.Store, _ []string) {
|
||||
if len(ts.Scopes) > 0 {
|
||||
fmt.Printf(" scopes: %s\n", strings.Join(ts.Scopes, " "))
|
||||
}
|
||||
if !ts.AccessExpAt.IsZero() {
|
||||
fmt.Printf(" access until %s (%s)\n",
|
||||
ts.AccessExpAt.Format(time.RFC3339),
|
||||
humanizeUntil(ts.AccessExpAt))
|
||||
}
|
||||
if !ts.IDExpiresAt.IsZero() {
|
||||
fmt.Printf(" id token until %s (%s)\n",
|
||||
ts.IDExpiresAt.Format(time.RFC3339),
|
||||
@@ -220,6 +225,7 @@ func knownMCPs() (mcp.Servers, error) {
|
||||
"gitea": {Command: "gitea-mcp"},
|
||||
"grafana": {Command: "grafana-mcp"},
|
||||
"gssh": {Command: "gssh-mcp"},
|
||||
"searxng": {Command: "searxng-mcp"},
|
||||
}
|
||||
for name, spec := range specs {
|
||||
abs, err := agent.LookPath(spec.Command)
|
||||
|
||||
@@ -78,9 +78,10 @@ func runUpdate(args []string) {
|
||||
}
|
||||
|
||||
// Non-interactive install against the cache checkout (the installer
|
||||
// syncs it to the latest tag and bakes that version). Config is left
|
||||
// untouched — SeedConfig is off, so an update never opens an editor
|
||||
// or clobbers config.
|
||||
// syncs it to the latest tag and bakes that version). SeedConfig is
|
||||
// off, so update never creates a config or opens an editor, but the
|
||||
// installer may append marked templates for newly shipped config
|
||||
// sections. Existing values are never clobbered.
|
||||
if err := installer.Install(context.Background(), installer.Options{
|
||||
Local: false,
|
||||
SeedConfig: false,
|
||||
|
||||
@@ -26,6 +26,7 @@ complete -c sherlock -n '__fish_seen_subcommand_from run' -f -a 'claude' -d 'An
|
||||
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'gitea' -d 'Forget the gitea session'
|
||||
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'grafana' -d 'Forget the grafana session'
|
||||
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'gssh' -d 'Forget the gssh session'
|
||||
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'searxng' -d 'Forget the searxng session'
|
||||
|
||||
# ── `sherlock update [--force]` ──────────────────────────────────────
|
||||
complete -c sherlock -n '__fish_seen_subcommand_from update' -l force -s f -f -d 'Reinstall the latest release even if not newer'
|
||||
|
||||
@@ -36,3 +36,7 @@ base_url = "https://grafana.example.com"
|
||||
[services.gssh]
|
||||
provider = "sherlock-cli"
|
||||
base_url = "https://terminal.example.com"
|
||||
|
||||
[services.searxng]
|
||||
provider = "sherlock-cli"
|
||||
base_url = "https://search.example.com"
|
||||
|
||||
+3
-2
@@ -13,8 +13,9 @@ equivalent. Unknown agent names exit with usage errors.
|
||||
## Spawn behavior
|
||||
|
||||
On spawn, sherlock resolves installed MCP binaries (`gitea-mcp`, `grafana-mcp`,
|
||||
`gssh-mcp`), skips missing ones with a warning, renders a 0600 `.mcp.json` file
|
||||
under `$XDG_RUNTIME_DIR/sherlock/`, and `exec`s the target agent.
|
||||
`gssh-mcp`, `searxng-mcp`), skips missing ones with a warning, renders a 0600
|
||||
`.mcp.json` file under `$XDG_RUNTIME_DIR/sherlock/`, and `exec`s the target
|
||||
agent.
|
||||
|
||||
The child mostly inherits the parent environment. The Claude profile strips
|
||||
`ANTHROPIC_API_KEY` so a personal key does not override sherlock-managed
|
||||
|
||||
+7
-6
@@ -5,7 +5,7 @@ master session: each MCP authenticates for the service it calls.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
For a service such as `gitea`, `grafana`, or `gssh`, the MCP asks
|
||||
For a service such as `gitea`, `grafana`, `gssh`, or `searxng`, the MCP asks
|
||||
`internal/authn` for a token on first use:
|
||||
|
||||
1. Fresh wallet entry: return it.
|
||||
@@ -15,17 +15,18 @@ For a service such as `gitea`, `grafana`, or `gssh`, the MCP asks
|
||||
serialized by `$XDG_RUNTIME_DIR/sherlock.login.lock`, persist, return.
|
||||
|
||||
Long-running MCPs use `TokenSource` and `TokenHolder` so requests always read
|
||||
the latest bearer. Refresh never opens a browser; if refresh can no longer
|
||||
recover, the next invocation performs a fresh login.
|
||||
the latest bearer. Refresh follows access-token expiry, never opens a browser,
|
||||
and falls back to a fresh login on the next invocation if refresh can no longer
|
||||
recover.
|
||||
|
||||
## Service identity
|
||||
|
||||
The config supplies issuer, client ID/secret, and base URL. Scopes are owned by
|
||||
each MCP because they follow the tool surface, not the deployment.
|
||||
|
||||
Gitea uses Gitea's OAuth2 server. Grafana and Gssh normally reuse the shared
|
||||
Authentik `sherlock-cli` provider. Tokens are stored and refreshed per service
|
||||
and are not reused across unrelated services.
|
||||
Gitea uses Gitea's OAuth2 server. Grafana, Gssh, and SearXNG normally reuse the
|
||||
shared Authentik `sherlock-cli` provider. Tokens are stored and refreshed per
|
||||
service and are not reused across unrelated services.
|
||||
|
||||
## User controls
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ required fields are hard errors.
|
||||
|
||||
Default source: [`config.example.toml`](../config.example.toml).
|
||||
|
||||
Setup and update compare an existing config against the shipped example. Missing
|
||||
provider/service sections are appended as marked templates; existing sections
|
||||
and values are not rewritten.
|
||||
|
||||
## Location
|
||||
|
||||
Resolved in order:
|
||||
|
||||
+3
-2
@@ -30,9 +30,10 @@ Repository rules that should stay true as the project changes.
|
||||
|
||||
## Naming
|
||||
|
||||
- Binaries are kebab-case: `gitea-mcp`, `gssh-mcp`.
|
||||
- Binaries are kebab-case: `gitea-mcp`, `gssh-mcp`, `searxng-mcp`.
|
||||
- Agent names match the wrapped CLI: `copilot`, `claude`.
|
||||
- Service names are the config key and wallet key: `gitea`, `grafana`, `gssh`.
|
||||
- Service names are the config key and wallet key: `gitea`, `grafana`, `gssh`,
|
||||
`searxng`.
|
||||
|
||||
## Commits and CI
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ around the same command.
|
||||
|
||||
The installer clones or updates a cached source checkout, checks out the latest
|
||||
release tag, seeds `config.toml` from `config.example.toml` without overwriting
|
||||
existing values, optionally opens the config in an editor, installs `sherlock`
|
||||
plus MCP binaries, and installs shell completions when supported.
|
||||
existing values, appends marked templates for missing service/provider sections,
|
||||
optionally opens the config in an editor, installs `sherlock` plus MCP binaries,
|
||||
and installs shell completions when supported.
|
||||
|
||||
One implementation backs both bootstrap setup and `sherlock update`.
|
||||
|
||||
@@ -32,7 +33,8 @@ selects the editor.
|
||||
## Updating
|
||||
|
||||
Use `sherlock update` to install a newer release, or `sherlock update --force`
|
||||
to reinstall the latest release. Updates rebuild binaries and completions but do
|
||||
not touch existing config.
|
||||
to reinstall the latest release. Updates rebuild binaries and completions, and
|
||||
may append marked config templates for newly shipped service/provider sections.
|
||||
They do not rewrite existing config values.
|
||||
|
||||
Re-running setup is safe: it preserves config and reinstalls binaries.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# searxng-mcp
|
||||
|
||||
Stdio MCP for a SearXNG instance. It exposes web search through SearXNG's JSON
|
||||
`/search` API. Exact tool schemas live in `cmd/searxng-mcp/tools.go`.
|
||||
|
||||
## Auth
|
||||
|
||||
`searxng-mcp` uses sherlock-managed OAuth with wallet key `searxng`, normally
|
||||
through the shared Authentik `sherlock-cli` provider. It sends a fresh bearer to
|
||||
the Caddy-protected SearXNG origin on every search request.
|
||||
|
||||
Caddy must accept that OAuth bearer for API requests. Browser SSO redirects or
|
||||
session cookies alone are not enough for an MCP subprocess. SearXNG must also
|
||||
enable JSON output for `/search?format=json`.
|
||||
|
||||
Config is `[services.searxng]` with provider or inline OAuth identity plus
|
||||
`base_url`. `sherlock logout searxng` clears the session.
|
||||
|
||||
## Operation
|
||||
|
||||
When `searxng-mcp` is installed, `sherlock <agent>` includes it in the generated
|
||||
MCP config. The first tool call authenticates lazily. `searxng-mcp --probe`
|
||||
verifies auth against Caddy and one SearXNG JSON search without an agent.
|
||||
@@ -205,6 +205,7 @@ func exchangeAndVerify(ctx context.Context, cfg Config, provider *oidc.Provider,
|
||||
AccessToken: tok.AccessToken,
|
||||
RefreshToken: tok.RefreshToken,
|
||||
IDExpiresAt: idTok.Expiry,
|
||||
AccessExpAt: accessExpiry(tok.Expiry, idTok.Expiry),
|
||||
RefreshExpAt: refreshExpiry(tok),
|
||||
Issuer: cfg.Issuer,
|
||||
ClientID: cfg.ClientID,
|
||||
@@ -239,12 +240,20 @@ func splitScope(s string) []string {
|
||||
if i > start {
|
||||
out = append(out, s[start:i])
|
||||
}
|
||||
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func accessExpiry(access, fallback time.Time) time.Time {
|
||||
if !access.IsZero() {
|
||||
return access
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// refreshExpiry pulls Authentik's `refresh_expires_in` (an extension
|
||||
// to RFC 6749). If absent we conservatively assume 30 days, matching
|
||||
// Authentik's default.
|
||||
|
||||
@@ -246,6 +246,9 @@ func TestLogin_HappyPath(t *testing.T) {
|
||||
if res.Tokens.ClientID != "test-client" {
|
||||
t.Fatalf("ClientID not persisted: %q", res.Tokens.ClientID)
|
||||
}
|
||||
if res.Tokens.AccessExpAt.IsZero() {
|
||||
t.Fatal("AccessExpAt was not persisted")
|
||||
}
|
||||
if !strings.HasPrefix(res.Tokens.Issuer, "http://127.0.0.1:") {
|
||||
t.Fatalf("Issuer = %q", res.Tokens.Issuer)
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
||||
)
|
||||
|
||||
// RefreshSkew is how much headroom EnsureFresh leaves before the ID
|
||||
// token actually expires. Refreshing 30s ahead of expiry avoids
|
||||
// RefreshSkew is how much headroom EnsureFresh leaves before the
|
||||
// bearer token actually expires. Refreshing 30s ahead of expiry avoids
|
||||
// shipping a token that will be rejected by clock-skewed resource
|
||||
// servers.
|
||||
const RefreshSkew = 30 * time.Second
|
||||
@@ -39,7 +39,7 @@ type EnsureFreshOptions struct {
|
||||
}
|
||||
|
||||
// EnsureFresh returns the stored TokenSet for service, refreshing it
|
||||
// against its OAuth issuer if the ID token has < RefreshSkew left.
|
||||
// against its OAuth issuer if the bearer token has < RefreshSkew left.
|
||||
// Cross-process safe: takes an exclusive flock on opts.LockPath,
|
||||
// re-reads the keyring after acquiring the lock, and skips the
|
||||
// refresh if a concurrent process already rotated the tokens.
|
||||
@@ -68,7 +68,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts
|
||||
if ts.Empty() {
|
||||
return ts, keyring.ErrNoTokens
|
||||
}
|
||||
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) {
|
||||
if opts.Clock().Add(RefreshSkew).Before(expiryForRefresh(ts)) {
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts
|
||||
if err != nil {
|
||||
return ts, err
|
||||
}
|
||||
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) {
|
||||
if opts.Clock().Add(RefreshSkew).Before(expiryForRefresh(ts)) {
|
||||
return ts, nil
|
||||
}
|
||||
if ts.RefreshToken == "" {
|
||||
@@ -151,7 +151,18 @@ func refreshOnce(ctx context.Context, ts keyring.TokenSet, disc Discoverer) (key
|
||||
out.RefreshToken = tok.RefreshToken
|
||||
}
|
||||
out.IDExpiresAt = idTok.Expiry
|
||||
out.AccessExpAt = accessExpiry(tok.Expiry, idTok.Expiry)
|
||||
out.RefreshExpAt = refreshExpiry(tok)
|
||||
out.Scopes = grantedScopes(tok, ts.Scopes)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func expiryForRefresh(ts keyring.TokenSet) time.Time {
|
||||
if !ts.AccessExpAt.IsZero() {
|
||||
return ts.AccessExpAt
|
||||
}
|
||||
if ts.AccessToken != "" && ts.RefreshToken != "" {
|
||||
return time.Time{}
|
||||
}
|
||||
return ts.IDExpiresAt
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ func (s *TokenSource) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// nextWait computes how long to sleep before the next proactive
|
||||
// renewal: skew before the cached token expires, clamped to minWait so
|
||||
// renewal: skew before the bearer token expires, clamped to minWait so
|
||||
// a very short TTL doesn't spin.
|
||||
func (s *TokenSource) nextWait() time.Duration {
|
||||
s.mu.Lock()
|
||||
@@ -206,7 +206,7 @@ func (s *TokenSource) nextWait() time.Duration {
|
||||
if cur.Empty() {
|
||||
return s.minWait
|
||||
}
|
||||
wait := cur.IDExpiresAt.Sub(s.clock()) - s.skew
|
||||
wait := expiryForRefresh(cur).Sub(s.clock()) - s.skew
|
||||
if wait < s.minWait {
|
||||
return s.minWait
|
||||
}
|
||||
|
||||
@@ -73,13 +73,16 @@ func TestTokenSource_Start_DeliversInitialToken(t *testing.T) {
|
||||
func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) {
|
||||
stub := newStubAuthentik(t)
|
||||
store := fakekeyring.New()
|
||||
// Seed a STALE token (already expired) with a refresh token so the
|
||||
// renewal path fires immediately.
|
||||
// Seed a stale bearer with a still-fresh ID token. This mirrors
|
||||
// providers that issue short-lived access tokens and longer-lived ID
|
||||
// tokens; refresh must follow the bearer lifetime because MCPs send
|
||||
// the access token to services.
|
||||
_ = store.Set("gitea", keyring.TokenSet{
|
||||
IDToken: "old",
|
||||
AccessToken: "at-stale",
|
||||
RefreshToken: "rt-1",
|
||||
IDExpiresAt: time.Now().Add(-time.Minute),
|
||||
AccessExpAt: time.Now().Add(-time.Minute),
|
||||
IDExpiresAt: time.Now().Add(time.Hour),
|
||||
Issuer: stub.srv.URL,
|
||||
ClientID: "test-client",
|
||||
Scopes: []string{"openid"},
|
||||
@@ -114,6 +117,9 @@ func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) {
|
||||
if stored.AccessToken != "at-refreshed" {
|
||||
t.Fatalf("wallet access token = %q, want at-refreshed", stored.AccessToken)
|
||||
}
|
||||
if stored.AccessExpAt.IsZero() {
|
||||
t.Fatal("wallet access token expiry was not persisted")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
|
||||
@@ -53,6 +53,114 @@ func seedConfig(opts *Options, example, cfgPath string) error {
|
||||
return os.WriteFile(cfgPath, data, 0o600)
|
||||
}
|
||||
|
||||
// appendMissingConfigSections compares the operator config with the
|
||||
// shipped example and appends any missing [providers.*] or [services.*]
|
||||
// sections as clearly marked templates. It never rewrites existing
|
||||
// sections, so operator edits are preserved.
|
||||
func appendMissingConfigSections(opts *Options, example, cfgPath string) ([]string, error) {
|
||||
cfg, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
opts.warn("no config found at %s; run setup with --config-only to create one.", cfgPath)
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
exampleBody, err := os.ReadFile(example)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing := sectionSet(string(cfg))
|
||||
blocks := configSectionBlocks(string(exampleBody))
|
||||
var missing []configSectionBlock
|
||||
for _, b := range blocks {
|
||||
if !existing[b.Name] {
|
||||
missing = append(missing, b)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
out.WriteString(string(cfg))
|
||||
if len(cfg) > 0 && cfg[len(cfg)-1] != '\n' {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
for _, b := range missing {
|
||||
out.WriteString("\n# Added by sherlock because this section was missing from config.toml.\n")
|
||||
out.WriteString("# Template copied from config.example.toml; fill placeholders before use.\n")
|
||||
out.WriteString(strings.TrimSpace(b.Body))
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte(out.String()), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(missing))
|
||||
for _, b := range missing {
|
||||
names = append(names, b.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
type configSectionBlock struct {
|
||||
Name string
|
||||
Body string
|
||||
}
|
||||
|
||||
func sectionSet(body string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if name, ok := configSectionName(line); ok {
|
||||
out[name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func configSectionBlocks(body string) []configSectionBlock {
|
||||
lines := strings.Split(body, "\n")
|
||||
var blocks []configSectionBlock
|
||||
for i := 0; i < len(lines); i++ {
|
||||
name, ok := configSectionName(lines[i])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
start := i
|
||||
end := len(lines)
|
||||
for j := i + 1; j < len(lines); j++ {
|
||||
if _, ok := configSectionName(lines[j]); ok {
|
||||
end = j
|
||||
break
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, configSectionBlock{
|
||||
Name: name,
|
||||
Body: strings.Join(lines[start:end], "\n"),
|
||||
})
|
||||
i = end - 1
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func configSectionName(line string) (string, bool) {
|
||||
s := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(s, "[") {
|
||||
return "", false
|
||||
}
|
||||
end := strings.IndexByte(s, ']')
|
||||
if end < 0 {
|
||||
return "", false
|
||||
}
|
||||
name := s[1:end]
|
||||
if strings.HasPrefix(name, "providers.") || strings.HasPrefix(name, "services.") {
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// pickEditor resolves the editor command: $VISUAL, then $EDITOR, then
|
||||
// the first of sensible-editor/nano/vi found on PATH. Returns "" if none.
|
||||
func pickEditor() string {
|
||||
|
||||
@@ -31,7 +31,7 @@ const devVersion = "0.0.0-dev"
|
||||
// binaries are the cmd/<name> packages installed. `go install ./cmd/...`
|
||||
// covers exactly these (the setup entry lives outside cmd/, so it is
|
||||
// never installed).
|
||||
var binaries = []string{"sherlock", "gitea-mcp", "grafana-mcp", "gssh-mcp"}
|
||||
var binaries = []string{"sherlock", "gitea-mcp", "grafana-mcp", "gssh-mcp", "searxng-mcp"}
|
||||
|
||||
// Options controls one Install run.
|
||||
type Options struct {
|
||||
@@ -49,7 +49,8 @@ type Options struct {
|
||||
// SeedConfig copies config.example.toml to the operator's config
|
||||
// path when none exists yet (never clobbers a filled-in file).
|
||||
SeedConfig bool
|
||||
// OpenEditor opens $VISUAL/$EDITOR on the config after seeding.
|
||||
// OpenEditor opens $VISUAL/$EDITOR on the config after seeding and
|
||||
// appending any missing section templates.
|
||||
OpenEditor bool
|
||||
// ConfigOnly stops after the config step (no build/install).
|
||||
ConfigOnly bool
|
||||
@@ -97,14 +98,26 @@ func Install(ctx context.Context, opts Options) error {
|
||||
return fmt.Errorf("config.example.toml not found in the source (%s): %w", example, err)
|
||||
}
|
||||
|
||||
cfgPath, err := config.DefaultPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.SeedConfig {
|
||||
cfgPath, err := config.DefaultPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedConfig(&opts, example, cfgPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
addedSections, err := appendMissingConfigSections(&opts, example, cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(addedSections) > 0 {
|
||||
opts.warn("%s was missing section(s): %s. Appended marked templates from config.example.toml; fill placeholders before use.",
|
||||
cfgPath, strings.Join(addedSections, ", "))
|
||||
}
|
||||
|
||||
if opts.SeedConfig {
|
||||
if opts.OpenEditor {
|
||||
if err := openEditor(&opts, cfgPath); err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,6 +3,7 @@ package installer
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -70,6 +71,68 @@ func TestSeedConfig_DoesNotClobber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendMissingConfigSections(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
example := filepath.Join(dir, "config.example.toml")
|
||||
cfg := filepath.Join(dir, "config.toml")
|
||||
if err := os.WriteFile(example, []byte(`
|
||||
[providers.sherlock-cli]
|
||||
issuer = "https://id.example/"
|
||||
client_id = "REPLACE"
|
||||
|
||||
[services.gitea]
|
||||
issuer = "https://gitea.example"
|
||||
client_id = "gitea"
|
||||
base_url = "https://gitea.example"
|
||||
|
||||
[services.searxng]
|
||||
provider = "sherlock-cli"
|
||||
base_url = "https://search.example"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cfg, []byte(`
|
||||
[providers.sherlock-cli]
|
||||
issuer = "https://id.real/"
|
||||
client_id = "real"
|
||||
|
||||
[services.gitea]
|
||||
issuer = "https://gitea.real"
|
||||
client_id = "gitea-real"
|
||||
base_url = "https://gitea.real"
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
opts := &Options{Out: io_discard{}, Err: io_discard{}}
|
||||
added, err := appendMissingConfigSections(opts, example, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("appendMissingConfigSections: %v", err)
|
||||
}
|
||||
if len(added) != 1 || added[0] != "services.searxng" {
|
||||
t.Fatalf("added = %v, want [services.searxng]", added)
|
||||
}
|
||||
b, err := os.ReadFile(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(b)
|
||||
if !strings.Contains(body, "Added by sherlock") {
|
||||
t.Fatalf("missing marker comment in %q", body)
|
||||
}
|
||||
if strings.Count(body, "[services.searxng]") != 1 {
|
||||
t.Fatalf("searxng section count wrong in %q", body)
|
||||
}
|
||||
|
||||
added, err = appendMissingConfigSections(opts, example, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("second appendMissingConfigSections: %v", err)
|
||||
}
|
||||
if len(added) != 0 {
|
||||
t.Fatalf("second added = %v, want none", added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFishCompletionsDir(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("__fish_config_dir", "")
|
||||
|
||||
@@ -139,6 +139,7 @@ type TokenSet struct {
|
||||
IDToken string `json:"id_token"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
AccessExpAt time.Time `json:"access_expires_at,omitempty"`
|
||||
IDExpiresAt time.Time `json:"id_expires_at"`
|
||||
RefreshExpAt time.Time `json:"refresh_expires_at"`
|
||||
Issuer string `json:"issuer"`
|
||||
|
||||
Reference in New Issue
Block a user