Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# pkgsite-mcp

MCP tools for looking up current Go module and package information from the
official `pkg.go.dev/v1beta` API.
official `pkg.go.dev/v1` API.

Use it when you want a coding agent to answer Go dependency questions with
structured pkg.go.dev data instead of guessing from model memory, scraping HTML,
Expand Down Expand Up @@ -146,7 +146,7 @@ Health endpoint:
http://localhost:8080/health
```

Start optional Redis for local caching and rate limiting:
Start Redis for local caching and distributed rate limiting:

```sh
just up
Expand Down Expand Up @@ -179,7 +179,7 @@ http://localhost:8080/mcp
## Configuration

```text
PKGSITE_BASE_URL=https://pkg.go.dev/v1beta
PKGSITE_BASE_URL=https://pkg.go.dev/v1
KV_REDIS_URL=redis://localhost:9736/0
KV_REDIS_POOL_SIZE=4
KV_REDIS_MIN_IDLE_CONNS=2
Expand All @@ -205,8 +205,9 @@ O11Y_ENABLE_LOGS=true
O11Y_ENABLE_METRICS=true
```

Redis is optional. Without `KV_REDIS_URL`, requests go directly to pkg.go.dev and
IP rate limiting is disabled. When Redis is configured, it backs both pkg.go.dev
response caching and fixed-window IP rate limiting for `/mcp`.
Redis backs response caching, the distributed outbound pkg.go.dev 45-QPS limiter,
and fixed-window IP rate limiting for `/mcp`. Without `KV_REDIS_URL`, caching and
both Redis-backed limiters are disabled; requests use direct upstream access
without a process-local fallback limiter.

Sentry is optional. Without `SENTRY_DSN`, observability calls stay no-op.
2 changes: 1 addition & 1 deletion fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ primary_region = 'iad'
[build]

[env]
PKGSITE_BASE_URL = 'https://pkg.go.dev/v1beta'
PKGSITE_BASE_URL = 'https://pkg.go.dev/v1'
PKGSITE_CACHE_DISABLED = 'false'

[http_service]
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func read(getenv func(string) string) (Config, error) {
EnableMetrics: p.boolean("O11Y_ENABLE_METRICS", true),
},
Pkgsite: Pkgsite{
BaseURL: p.str("PKGSITE_BASE_URL", "https://pkg.go.dev/v1beta"),
BaseURL: p.str("PKGSITE_BASE_URL", "https://pkg.go.dev/v1"),
HTTPTimeout: p.duration("PKGSITE_HTTP_TIMEOUT", 10*time.Second),
CacheDisabled: p.boolean("PKGSITE_CACHE_DISABLED", false),
},
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func TestReadDefaults(t *testing.T) {
if got.Observability.FlushTimeout != 2*time.Second {
t.Fatalf("FlushTimeout = %s, want 2s", got.Observability.FlushTimeout)
}
if got.Pkgsite.BaseURL != "https://pkg.go.dev/v1beta" {
if got.Pkgsite.BaseURL != "https://pkg.go.dev/v1" {
t.Fatalf("BaseURL = %q, want default", got.Pkgsite.BaseURL)
}
if got.Pkgsite.HTTPTimeout != 10*time.Second {
Expand Down
11 changes: 11 additions & 0 deletions internal/mcpserver/skills/docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,14 @@ Use `pkgsite_module` for module metadata, `pkgsite_versions` for available versi
Use `pkgsite_package` for package documentation metadata and `pkgsite_symbols` for exported API facts. `pkgsite_symbols` is usually the highest-signal tool for coding agents.

Use `pkgsite_vulns` before making security-sensitive recommendations. Use `pkgsite_imported_by` sparingly because the result set can be large.

List operations accept an upstream `filter` written as a Go expression that returns a boolean. The supported subset is:

- values `true`, `false`, and `nil`;
- `==` and `!=` on any value;
- `+`, `-`, `*`, `/`, and `%` on integers;
- `+` on strings;
- `<`, `<=`, `>`, and `>=` on strings and integers; and
- parenthesized expressions.

The functions `contains(s, sub)`, `hasPrefix(s, pre)`, `hasSuffix(s, suf)`, and `matches(s, re)` are also available. `matches` takes a regular expression; a bare regular expression is not a valid filter. Each route exposes its JSON fields as variables, so examples include `name == "main"` for packages, `kind == "Type"` for symbols, and `hasPrefix(version, "v2.")` for versions. Pass the expression as plain text; the client percent-encodes query parameters.
2 changes: 1 addition & 1 deletion internal/mcpserver/skills/docs/overview.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# pkgsite-mcp overview

Use pkgsite-mcp when you need current structured facts from `pkg.go.dev/v1beta` about Go modules, packages, versions, exported symbols, imported-by relationships, or vulnerabilities.
Use pkgsite-mcp when you need current structured facts from `pkg.go.dev/v1` about Go modules, packages, versions, exported symbols, imported-by relationships, or vulnerabilities.

The source of truth is pkg.go.dev. This server is read-only and does not clone repositories, scrape HTML, or infer facts from training data.

Expand Down
2 changes: 2 additions & 0 deletions internal/mcpserver/skills/docs/pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ There are two pagination layers.

Upstream pkg.go.dev pagination uses `limit` and `token`. When a response includes `upstreamNextPageToken`, pass it as `token` to fetch the next upstream page.

Keep the rest of the upstream request unchanged when following a token; only add or replace `token`. A non-empty `upstreamNextPageToken` means another page exists even when the current page has no items.

Local display pagination uses `start_at` and `max_tokens`. When metadata includes `next_start_at`, repeat the same tool call with that `start_at` to see the next local batch from the current upstream response.
2 changes: 2 additions & 0 deletions internal/mcpserver/skills/docs/precision.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Package paths can be ambiguous across modules. If a package lookup returns candidates or an ambiguity message, repeat the call with `module_path`.

Error responses preserve the upstream `message`, `fixes`, and `candidates` fields. Prefer the suggested containing module from `candidates` or `fixes` instead of guessing which module owns an ambiguous package path.

Use version-pinned calls when answering compatibility questions. Empty `version` means latest and can change.

Do not treat absence of a field as proof unless the raw upstream response makes that absence clear.
2 changes: 2 additions & 0 deletions internal/mcpserver/tools/docs/pkgsite_imported_by.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
List packages that import a package. Defaults to a conservative upstream limit because results can be large.

`filter` must be a Go expression that returns a boolean. It supports `true`, `false`, and `nil`; `==` and `!=` on any value; `+`, `-`, `*`, `/`, and `%` on integers; `+` on strings; `<`, `<=`, `>`, and `>=` on strings and integers; and parenthesized expressions. The functions `contains(s, sub)`, `hasPrefix(s, pre)`, `hasSuffix(s, suf)`, and `matches(s, re)` are available. `matches` takes a regular expression; a bare regular expression is not a valid filter. The route's JSON fields are available as variables; for imported-by results, use `path` to filter paths. Pass the expression as plain text; the client percent-encodes query parameters.
2 changes: 2 additions & 0 deletions internal/mcpserver/tools/docs/pkgsite_packages.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
List packages contained in a module version from pkg.go.dev.

`filter` must be a Go expression that returns a boolean. It supports `true`, `false`, and `nil`; `==` and `!=` on any value; `+`, `-`, `*`, `/`, and `%` on integers; `+` on strings; `<`, `<=`, `>`, and `>=` on strings and integers; and parenthesized expressions. The functions `contains(s, sub)`, `hasPrefix(s, pre)`, `hasSuffix(s, suf)`, and `matches(s, re)` are available. `matches` takes a regular expression; a bare regular expression is not a valid filter. JSON fields from each package are available as variables. Example: `name == "main"`. Pass the expression as plain text; the client percent-encodes query parameters.
2 changes: 2 additions & 0 deletions internal/mcpserver/tools/docs/pkgsite_search.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
Search pkg.go.dev packages and optionally symbols. Use `token` for upstream pagination and `start_at` for local display pagination.

`filter` must be a Go expression that returns a boolean. It supports `true`, `false`, and `nil`; `==` and `!=` on any value; `+`, `-`, `*`, `/`, and `%` on integers; `+` on strings; `<`, `<=`, `>`, and `>=` on strings and integers; and parenthesized expressions. The functions `contains(s, sub)`, `hasPrefix(s, pre)`, `hasSuffix(s, suf)`, and `matches(s, re)` are available. `matches` takes a regular expression; a bare regular expression is not a valid filter. JSON fields from each search result are available as variables. Example: `hasPrefix(packagePath, "github.com/")`. Pass the expression as plain text; the client percent-encodes query parameters.
2 changes: 2 additions & 0 deletions internal/mcpserver/tools/docs/pkgsite_symbols.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
List exported symbols for a package from pkg.go.dev. This is the preferred tool for current public API facts.

`filter` must be a Go expression that returns a boolean. It supports `true`, `false`, and `nil`; `==` and `!=` on any value; `+`, `-`, `*`, `/`, and `%` on integers; `+` on strings; `<`, `<=`, `>`, and `>=` on strings and integers; and parenthesized expressions. The functions `contains(s, sub)`, `hasPrefix(s, pre)`, `hasSuffix(s, suf)`, and `matches(s, re)` are available. `matches` takes a regular expression; a bare regular expression is not a valid filter. JSON fields from each symbol are available as variables. Example: `kind == "Type"`. Pass the expression as plain text; the client percent-encodes query parameters.
2 changes: 2 additions & 0 deletions internal/mcpserver/tools/docs/pkgsite_versions.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
List versions for a module from pkg.go.dev with upstream pagination support.

`filter` must be a Go expression that returns a boolean. It supports `true`, `false`, and `nil`; `==` and `!=` on any value; `+`, `-`, `*`, `/`, and `%` on integers; `+` on strings; `<`, `<=`, `>`, and `>=` on strings and integers; and parenthesized expressions. The functions `contains(s, sub)`, `hasPrefix(s, pre)`, `hasSuffix(s, suf)`, and `matches(s, re)` are available. `matches` takes a regular expression; a bare regular expression is not a valid filter. JSON fields from each module version are available as variables. Example: `hasPrefix(version, "v2.")`. Pass the expression as plain text; the client percent-encodes query parameters.
2 changes: 2 additions & 0 deletions internal/mcpserver/tools/docs/pkgsite_vulns.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
List vulnerabilities for a module or package path from pkg.go.dev.

`filter` must be a Go expression that returns a boolean. It supports `true`, `false`, and `nil`; `==` and `!=` on any value; `+`, `-`, `*`, `/`, and `%` on integers; `+` on strings; `<`, `<=`, `>`, and `>=` on strings and integers; and parenthesized expressions. The functions `contains(s, sub)`, `hasPrefix(s, pre)`, `hasSuffix(s, suf)`, and `matches(s, re)` are available. `matches` takes a regular expression; a bare regular expression is not a valid filter. JSON fields from each vulnerability are available as variables. Pass the expression as plain text; the client percent-encodes query parameters.
23 changes: 23 additions & 0 deletions internal/mcpserver/tools/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ type explainSummary struct {
ModulePath string `json:"modulePath,omitempty"`
PackagePath string `json:"packagePath,omitempty"`
ResolvedVersion string `json:"resolvedVersion,omitempty"`
RepoURL string `json:"repoUrl,omitempty"`
Name string `json:"name,omitempty"`
Synopsis string `json:"synopsis,omitempty"`
Goos string `json:"goos,omitempty"`
Goarch string `json:"goarch,omitempty"`
ImportCount int `json:"importCount"`
IsLatest bool `json:"isLatest"`
HasGoMod bool `json:"hasGoMod"`
IsRedistributable bool `json:"isRedistributable"`
IsStandardLibrary bool `json:"isStandardLibrary"`
HasVulnerabilities bool `json:"hasVulnerabilities"`
Counts map[string]int `json:"counts"`
Expand Down Expand Up @@ -182,7 +190,10 @@ func applyModuleSummary(summary *explainSummary, data map[string]any) {
}
setString(&summary.ModulePath, data["path"])
setString(&summary.ResolvedVersion, data["version"])
setString(&summary.RepoURL, data["repoUrl"])
setBool(&summary.IsLatest, data["isLatest"])
setBool(&summary.HasGoMod, data["hasGoMod"])
setBool(&summary.IsRedistributable, data["isRedistributable"])
setBool(&summary.IsStandardLibrary, data["isStandardLibrary"])
}

Expand All @@ -193,7 +204,13 @@ func applyPackageSummary(summary *explainSummary, data map[string]any) {
setString(&summary.PackagePath, data["path"])
setString(&summary.ModulePath, data["modulePath"])
setString(&summary.ResolvedVersion, data["version"])
setString(&summary.Name, data["name"])
setString(&summary.Synopsis, data["synopsis"])
setString(&summary.Goos, data["goos"])
setString(&summary.Goarch, data["goarch"])
setInt(&summary.ImportCount, data["importCount"])
setBool(&summary.IsLatest, data["isLatest"])
setBool(&summary.IsRedistributable, data["isRedistributable"])
setBool(&summary.IsStandardLibrary, data["isStandardLibrary"])
}

Expand Down Expand Up @@ -262,3 +279,9 @@ func setBool(target *bool, value any) {
*target = b
}
}

func setInt(target *int, value any) {
if n, ok := value.(int); ok {
*target = n
}
}
20 changes: 20 additions & 0 deletions internal/mcpserver/tools/explain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@ func TestBuildExplainPayloadSummarizesSubResults(t *testing.T) {
}
}

func TestBuildExplainPayloadExposesUpstreamMetadata(t *testing.T) {
t.Parallel()

payload := buildExplainPayload(pkgsite.ExplainInput{Path: "example.com/module/pkg"}, explainParts{
Module: explainSubResultFromResult(pkgsite.Result{Summary: map[string]any{
"kind": "module", "path": "example.com/module", "version": "v1.2.3", "repoUrl": "https://example.com/module", "hasGoMod": true, "isRedistributable": true,
}}, nil),
Package: explainSubResultFromResult(pkgsite.Result{Summary: map[string]any{
"kind": "package", "path": "example.com/module/pkg", "modulePath": "example.com/module", "version": "v1.2.3", "name": "pkg", "synopsis": "Package synopsis.", "goos": "linux", "goarch": "amd64", "importCount": 3, "isLatest": true, "isRedistributable": true,
}}, nil),
})

if payload.Summary.RepoURL != "https://example.com/module" || !payload.Summary.HasGoMod || !payload.Summary.IsRedistributable {
t.Fatalf("module metadata missing from summary: %#v", payload.Summary)
}
if payload.Summary.Name != "pkg" || payload.Summary.Synopsis != "Package synopsis." || payload.Summary.Goos != "linux" || payload.Summary.Goarch != "amd64" || payload.Summary.ImportCount != 3 {
t.Fatalf("package metadata missing from summary: %#v", payload.Summary)
}
}

func TestExplainSubResultFromResultPreservesCallErrorsAndAPIResults(t *testing.T) {
t.Parallel()

Expand Down
1 change: 1 addition & 0 deletions internal/observability/attrs.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ func EndpointFromURL(u *url.URL) PkgsiteEndpoint {
return PkgsiteEndpointUnknown
}
path := strings.TrimPrefix(u.EscapedPath(), "/")
path = strings.TrimPrefix(path, "v1/")
path = strings.TrimPrefix(path, "v1beta/")
switch {
case path == "search":
Expand Down
2 changes: 2 additions & 0 deletions internal/observability/attrs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ func TestEndpointFromURL(t *testing.T) {
raw string
want PkgsiteEndpoint
}{
{raw: "https://pkg.go.dev/v1/search?q=uuid", want: PkgsiteEndpointSearch},
{raw: "https://pkg.go.dev/v1/module/golang.org%2Fx%2Foauth2", want: PkgsiteEndpointModule},
{raw: "https://pkg.go.dev/v1beta/search?q=uuid", want: PkgsiteEndpointSearch},
{raw: "https://pkg.go.dev/v1beta/module/golang.org%2Fx%2Foauth2", want: PkgsiteEndpointModule},
{raw: "https://pkg.go.dev/v1beta/package/golang.org%2Fx%2Foauth2", want: PkgsiteEndpointPackage},
Expand Down
67 changes: 59 additions & 8 deletions internal/pkgsite/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func New(cfg config.Pkgsite, store kv.Store, opts ...Option) (*Client, error) {
if timeout == 0 {
timeout = 10 * time.Second
}
doer := transport.NewCachedDoer(transport.NewHTTPClient(timeout), store, cfg.CacheDisabled)
doer := transport.NewCachedDoer(transport.NewHTTPClient(timeout, store), store, cfg.CacheDisabled)
api, err := pkgsiteapi.NewClientWithResponses(
baseURL,
pkgsiteapi.WithHTTPClient(doer),
Expand Down Expand Up @@ -128,18 +128,45 @@ func (c *Client) Package(ctx context.Context, input PackageInput) (Result, error
return resultError(resp.StatusCode(), resp.Status(), resp.Body, resp.HTTPResponse), nil
}
pkg := resp.JSON200
summary := packageSummary(pkg, input, resp.Body)
result := Result{
Summary: map[string]any{
"kind": "package", "path": input.PackagePath, "modulePath": stringVal(pkg.ModulePath, input.ModulePath),
"version": stringVal(pkg.Version, input.Version), "goos": stringVal(pkg.Goos, input.Goos), "goarch": stringVal(pkg.Goarch, input.Goarch),
"isLatest": boolVal(pkg.IsLatest), "isStandardLibrary": boolVal(pkg.IsStandardLibrary), "importCount": lenStringSlice(pkg.Imports),
},
Raw: pkg, UpstreamURL: requestURL(resp.HTTPResponse), FromCache: fromCache(resp.HTTPResponse),
Summary: summary,
Raw: pkg, UpstreamURL: requestURL(resp.HTTPResponse), FromCache: fromCache(resp.HTTPResponse),
}
c.warm(ctx, WarmJob{Kind: WarmSymbols, Symbols: SymbolsInput{PackagePath: stringValue(result.Summary["path"], input.PackagePath), ModulePath: stringValue(result.Summary["modulePath"], input.ModulePath), Version: stringValue(result.Summary["version"], input.Version)}, Drain: true})
return result, nil
}

func packageSummary(pkg *pkgsiteapi.Package, input PackageInput, body []byte) map[string]any {
summary := map[string]any{
"kind": "package", "path": input.PackagePath, "modulePath": stringVal(pkg.ModulePath, input.ModulePath),
"version": stringVal(pkg.Version, input.Version), "goos": stringVal(pkg.Goos, input.Goos), "goarch": stringVal(pkg.Goarch, input.Goarch),
"isLatest": boolVal(pkg.IsLatest), "isRedistributable": boolVal(pkg.IsRedistributable), "isStandardLibrary": boolVal(pkg.IsStandardLibrary),
"name": stringVal(pkg.Name, ""), "synopsis": stringVal(pkg.Synopsis, ""), "importCount": lenStringSlice(pkg.Imports),
}
var identity struct {
Path *string `json:"path"`
Name *string `json:"name"`
Synopsis *string `json:"synopsis"`
IsRedistributable *bool `json:"isRedistributable"`
}
if json.Unmarshal(body, &identity) == nil {
if identity.Path != nil {
summary["path"] = *identity.Path
}
if identity.Name != nil {
summary["name"] = *identity.Name
}
if identity.Synopsis != nil {
summary["synopsis"] = *identity.Synopsis
}
if identity.IsRedistributable != nil {
summary["isRedistributable"] = *identity.IsRedistributable
}
}
return summary
}

func (c *Client) Versions(ctx context.Context, input VersionsInput) (Result, error) {
resp, err := c.api.GetVersionsWithResponse(ctx, input.ModulePath, &pkgsiteapi.GetVersionsParams{
Limit: optionalInt(input.Limit), Token: optionalString(input.Token), Filter: optionalString(input.Filter),
Expand Down Expand Up @@ -246,10 +273,34 @@ func resultError(statusCode int, status string, body []byte, resp *http.Response
raw = append(raw, body...)
}
message := strings.TrimSpace(string(body))
var details struct {
Code *int `json:"code"`
Message string `json:"message"`
Fixes []string `json:"fixes"`
Candidates []Candidate `json:"candidates"`
}
if raw != nil && json.Unmarshal(raw, &details) == nil {
if strings.TrimSpace(details.Message) != "" {
message = strings.TrimSpace(details.Message)
}
}
if len(message) > 500 {
message = message[:500]
}
return Result{Error: &APIError{StatusCode: statusCode, Status: status, Message: message, Body: raw}, UpstreamURL: requestURL(resp), FromCache: fromCache(resp)}
retryAfter := ""
if resp != nil {
retryAfter = resp.Header.Get("Retry-After")
}
return Result{Error: &APIError{
StatusCode: statusCode,
Status: status,
Code: details.Code,
Message: message,
Fixes: details.Fixes,
Candidates: details.Candidates,
RetryAfter: retryAfter,
Body: raw,
}, UpstreamURL: requestURL(resp), FromCache: fromCache(resp)}
}

func paginatedItems(page any) []map[string]any {
Expand Down
Loading
Loading