Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04770492d5 | ||
|
|
bf357015bc | ||
|
|
383c889cab | ||
|
|
e5468ef654 | ||
|
|
e30c25b198 | ||
|
|
b8fde634b5 | ||
|
|
352dfcf2b6 | ||
|
|
df5c189743 |
@@ -1,2 +0,0 @@
|
||||
domogeek
|
||||
.git
|
||||
@@ -0,0 +1,2 @@
|
||||
/domogeek
|
||||
.git
|
||||
+1
-1
@@ -108,5 +108,5 @@ tags
|
||||
|
||||
|
||||
# Binary
|
||||
domogeek
|
||||
/domogeek
|
||||
build/
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
domogeek is a small Go HTTP service that exposes calendar/holiday information for home automation (domotic) use. It answers whether "today" is a working day, a French public holiday (`ferie`), and/or a personal holiday read from a CalDAV calendar. The same information is also exposed as MCP tools over a Streamable HTTP endpoint (`/mcp`), so LLM agents/MCP clients can query it directly.
|
||||
|
||||
## Commands
|
||||
|
||||
Build and test use Go's standard tooling with vendored dependencies (`-mod=vendor`). The module requires **Go 1.25+** (bumped from 1.18 to use the official MCP SDK). If the `go` binary on `PATH` resolves to an older toolchain (e.g. this box also has `golang-1.18` installed and it can shadow the newer one), invoke a newer toolchain explicitly, overriding `GOROOT` if it's pinned to the old one (e.g. by an IDE's `_INTELLIJ_FORCE_SET_GOROOT`):
|
||||
|
||||
```bash
|
||||
GOROOT=/usr/lib/go-1.27 /usr/lib/go-1.27/bin/go build -mod=vendor ./...
|
||||
```
|
||||
|
||||
Otherwise, plain invocations work the same as before:
|
||||
|
||||
```bash
|
||||
go build -mod=vendor ./... # build
|
||||
go vet -mod=vendor ./... # vet
|
||||
go test -mod=vendor ./... # run all tests
|
||||
go test -mod=vendor ./pkg/calendar/ -run TestCalendar_IsWorkingDay -v # run a single test
|
||||
```
|
||||
|
||||
Run locally (caldav flags are optional — omitting them disables CalDAV-based holiday checks):
|
||||
|
||||
```bash
|
||||
go run -mod=vendor ./cmd/domogeek \
|
||||
-port 8080 \
|
||||
-caldav-url https://example.com \
|
||||
-caldav-path /calendars/user/holidays/ \
|
||||
-caldav-username user \
|
||||
-caldav-password pass \
|
||||
-caldav-summary-pattern Holidays
|
||||
```
|
||||
|
||||
Running with no flags at all prints usage and exits (see the `len(os.Args) <= 1` check in `main`). Note: despite the "caldav flags are optional" framing, `main()` currently calls `calendar.NewCaldav` unconditionally, even with an empty `-caldav-url` — with no caldav server to validate against, this hits the 1000-retry exponential backoff described below and the process never gets to `http.ListenAndServe`. This is a pre-existing quirk, not something introduced by the MCP work; if you need a quick local instance (e.g. to exercise `/mcp` or `/calendar` without a real CalDAV server), it's easiest to call `newMCPHandler()`/`calendar.New(location)` directly from a small throwaway `main`/test rather than running the built binary.
|
||||
|
||||
Docker image (multi-stage, distroless), for local testing:
|
||||
|
||||
```bash
|
||||
docker build -t domogeek . # or: buildah bud -t domogeek .
|
||||
```
|
||||
|
||||
## Continuous builds
|
||||
|
||||
Pushing a Git tag matching `v[0-9]*` (existing convention: `vX.Y.Z`, e.g. `v0.4.0`) to
|
||||
the Gitea remote triggers the homelab Tekton pipeline `gitea-docker-build-multiarch`,
|
||||
which builds and pushes a multi-arch (amd64 + arm64) image plus a signed CycloneDX SBOM:
|
||||
|
||||
```
|
||||
git.cyrilix.bzh/cyrilix/domogeek:<tag>
|
||||
```
|
||||
|
||||
The pipeline uses its defaults — `Dockerfile` at the repo root, build context `.` — so
|
||||
the Dockerfile must stay at the root. It builds each target platform natively (no
|
||||
`GOOS`/`GOARCH` overrides in the `go build` step), so the Dockerfile must not hardcode a
|
||||
single-arch `GOOS`/`GOARCH` — that was a real bug fixed alongside this: the builder image
|
||||
is now pinned to `golang:1.27-alpine` (matching the `go 1.25` module requirement) and the
|
||||
`go build` step targets the whole package (`./cmd/domogeek`), not a single file.
|
||||
|
||||
There is no Tekton YAML in this repo: the pipeline and its Gitea webhook trigger are
|
||||
defined cluster-side. Watch a run: <https://tekton.banquise.cyrilix.bzh>
|
||||
|
||||
```sh
|
||||
git tag v0.4.0
|
||||
git push --tags
|
||||
```
|
||||
|
||||
Note: this replaces the previous `docker.io/cyrilix/domogeek:<git describe>` releases
|
||||
built locally via the now-removed `build-docker.sh`/`buildah` script.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `cmd/domogeek/domogeek.go` — the binary entrypoint. Wires up flags, zap logging, the `calendar.Calendar`, Prometheus metrics/instrumented handlers, and a `health-go` health check, then serves five HTTP routes:
|
||||
- `GET /calendar` — returns a `CalendarDay` JSON payload (`working_day`, `ferie`, `holiday`, `weekday`) for "now" in `Europe/Paris`.
|
||||
- `GET /datetime` — returns a `CurrentDateTime` JSON payload (`date_time`) for "now" in `Europe/Paris` (`DateTimeHandler`).
|
||||
- `POST /mcp` — MCP Streamable HTTP endpoint (see `cmd/domogeek/mcp.go` below).
|
||||
- `GET /metrics` — Prometheus metrics (request counter/summary/histogram wrapping the calendar handler, defined in `init()`).
|
||||
- `GET /status` — health-go handler with two checks: a static `calendar` check and a `caldav` check that calls `cal.IsHolidaysFromCaldav`.
|
||||
- The process blocks on `SIGTERM` (not `SIGINT`) via a signal channel; `http.ListenAndServe` runs in a goroutine and `zap.S().Fatal`s the process on error.
|
||||
|
||||
- `cmd/domogeek/mcp.go` — MCP server built with `github.com/modelcontextprotocol/go-sdk/mcp`, mounted at `/mcp` via `mcp.NewStreamableHTTPHandler`. Exposes four tools, all reading the same package-level `cal`/`location` globals as the `/calendar` and `/datetime` handlers:
|
||||
- `get_current_datetime` — same payload as `GET /datetime`: the current date and time.
|
||||
- `get_calendar_today` — same payload as `GET /calendar`, for now.
|
||||
- `get_calendar_for_date` — same payload for an arbitrary `date` (`YYYY-MM-DD`); an invalid date is returned as a tool error (`CallToolResult.IsError`), not a protocol-level error, so an MCP client/LLM can see and self-correct.
|
||||
- `get_holidays` — lists French public holidays (`Calendar.GetHolidays`) for a given `year`, defaulting to the current year if omitted.
|
||||
- `calendarDayFor` in this file is the single place that builds a `CalendarDay` from a `time.Time`; both the HTTP `/calendar` handler and the MCP tools call it, so there's one code path for the working-day/ferie/holiday/weekday logic.
|
||||
- `cmd/domogeek/mcp_test.go` unit-tests the handler functions directly; `cmd/domogeek/mcp_e2e_test.go` drives the real Streamable HTTP transport with an MCP client (`httptest.Server` + `mcp.NewClient(...).Connect`) to check the wiring end-to-end (tool listing, structured content, and the tool-error-vs-protocol-error distinction). `cmd/domogeek/datetime_test.go` covers the `/datetime` HTTP handler directly.
|
||||
|
||||
- `pkg/calendar/calendar.go` — all domain logic, built around the `Calendar` struct and functional options (`WithCaldav`, `WithCaldavPath`, `WithCaldavSummaryPattern`):
|
||||
- `GetEasterDay` implements the Gauss/Meeus algorithm to compute Easter Sunday for a given year (in `cal.Location`); `GetHolidays` derives the fixed-date French public holidays plus two computed from it: Easter Monday (Easter+1) and Ascension (Easter+39).
|
||||
- `IsHoliday` combines the static French holiday set with `IsHolidaysFromCaldav`.
|
||||
- `IsHolidaysFromCaldav` queries a CalDAV calendar for events on a given day and treats any event whose `Summary` contains `caldavSummaryPattern` (default `"Holidays"`) as a personal holiday. If no `Caldav` client is configured, it returns `false, nil` — CalDAV is optional.
|
||||
- The `Caldav` interface (`QueryEvents`) abstracts `github.com/dolanor/caldav-go`, letting tests substitute `MockCaldav` (see `calendar_test.go`) instead of hitting a real server.
|
||||
- `NewCaldav` validates the CalDAV connection at startup with `avast/retry-go`, retrying up to 1000 times with exponential backoff (max delay 24h) — this means a bad/unreachable CalDAV server does **not** fail fast; `main()` will effectively hang retrying rather than exiting quickly if the caldav server is down (it's only `Fatal`'d if `NewCaldav` itself returns a non-nil error, which currently only happens after all retries are exhausted).
|
||||
|
||||
- `pkg/metrics/metrics.go` — currently a single unused/unconfigured Prometheus counter (empty `Namespace`/`Subsystem`/`Name`/`Help`). Not wired into `cmd/domogeek`; treat as in-progress scaffolding rather than an established pattern to follow.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Dependencies are vendored under `vendor/` and checked into the repo — always pass `-mod=vendor` (or rely on `GOFLAGS=-mod=vendor` if set) for build/test/vet so Go doesn't try to hit the network or the module cache. After changing `go.mod`/`go.sum`, run `go mod vendor` to keep `vendor/` in sync (use the newer toolchain per the note above, and `-mod=mod` instead of `-mod=vendor` for the `go get`/`go mod tidy` step itself).
|
||||
|
||||
`github.com/modelcontextprotocol/go-sdk` (the official Go MCP SDK) is the dependency added for the `/mcp` endpoint; it pulled in `google/jsonschema-go`, `segmentio/encoding`, `yosida95/uritemplate`, and a few `golang.org/x/*` packages as transitive deps, and required bumping the `go` directive in `go.mod` from `1.18` to `1.25.0`.
|
||||
+2
-5
@@ -1,11 +1,8 @@
|
||||
FROM golang:alpine as builder
|
||||
FROM golang:1.27-alpine AS builder
|
||||
|
||||
WORKDIR /go/src
|
||||
ADD . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=vendor -tags netgo -o /go/bin/domogeek cmd/domogeek/domogeek.go
|
||||
|
||||
|
||||
|
||||
RUN CGO_ENABLED=0 go build -mod=vendor -tags netgo -o /go/bin/domogeek ./cmd/domogeek
|
||||
|
||||
FROM gcr.io/distroless/static
|
||||
|
||||
|
||||
@@ -6,3 +6,30 @@ Tools for domotic
|
||||
|
||||
Return calendar informations about today
|
||||
|
||||
## Date and time
|
||||
|
||||
`GET /datetime` returns the current date and time.
|
||||
|
||||
## MCP
|
||||
|
||||
An MCP server is exposed over Streamable HTTP at `/mcp`, providing the same calendar
|
||||
and date/time information as MCP tools (`get_current_datetime`, `get_calendar_today`,
|
||||
`get_calendar_for_date`, `get_holidays`) for use by LLM agents/MCP clients.
|
||||
|
||||
## Continuous builds
|
||||
|
||||
Pushing a Git tag matching `v[0-9]*` (e.g. `v0.4.0`) to the Gitea remote triggers the
|
||||
homelab Tekton pipeline `gitea-docker-build-multiarch`, which builds and pushes a
|
||||
multi-arch (amd64 + arm64) image plus a signed SBOM:
|
||||
|
||||
```
|
||||
git.cyrilix.bzh/cyrilix/domogeek:<tag>
|
||||
```
|
||||
|
||||
```sh
|
||||
git tag v0.4.0
|
||||
git push --tags
|
||||
```
|
||||
|
||||
Watch the run: <https://tekton.banquise.cyrilix.bzh>
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDateTimeHandler_ServeHTTP(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/datetime", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
(&DateTimeHandler{}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var got CurrentDateTime
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unable to decode response: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().In(location)
|
||||
if diff := now.Sub(got.DateTime); diff < -time.Second || diff > time.Second {
|
||||
t.Errorf("DateTime = %v, want close to %v", got.DateTime, now)
|
||||
}
|
||||
_, wantOffset := now.Zone()
|
||||
_, gotOffset := got.DateTime.Zone()
|
||||
if gotOffset != wantOffset {
|
||||
t.Errorf("DateTime UTC offset = %d, want %d", gotOffset, wantOffset)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -72,14 +73,7 @@ type CalendarDay struct {
|
||||
type CalendarHandler struct{}
|
||||
|
||||
func (c *CalendarHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
now := time.Now()
|
||||
cd := CalendarDay{
|
||||
Day: now,
|
||||
WorkingDay: cal.IsWorkingDay(now),
|
||||
Ferie: cal.IsHoliday(now),
|
||||
Holiday: cal.IsHoliday(now),
|
||||
Weekday: cal.IsWeekDay(now),
|
||||
}
|
||||
cd := calendarDayFor(time.Now())
|
||||
|
||||
content, err := json.Marshal(cd)
|
||||
if err != nil {
|
||||
@@ -94,9 +88,30 @@ func (c *CalendarHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
type CurrentDateTime struct {
|
||||
DateTime time.Time `json:"date_time"`
|
||||
}
|
||||
|
||||
type DateTimeHandler struct{}
|
||||
|
||||
func (d *DateTimeHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
content, err := json.Marshal(CurrentDateTime{DateTime: time.Now().In(location)})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
zap.S().Errorf("unable to marshall response %v, %v", content, err)
|
||||
} else {
|
||||
_, err = w.Write(content)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
zap.S().Errorf("unable to marshall response %v, :%v", content, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var port int
|
||||
var host string
|
||||
var user, pwd string
|
||||
var caldavUrl, caldavPath, caldavSummaryPattern string
|
||||
|
||||
flag.StringVar(&host, "host", "", "host to listen, default all addresses")
|
||||
@@ -104,6 +119,8 @@ func main() {
|
||||
flag.StringVar(&caldavUrl, "caldav-url", "", "caldav url to use to read holidays events")
|
||||
flag.StringVar(&caldavPath, "caldav-path", "", "caldav path to use to read holidays events")
|
||||
flag.StringVar(&caldavSummaryPattern, "caldav-summary-pattern", "Holidays", "Summary pattern that matches holidays event")
|
||||
flag.StringVar(&user, "caldav-username", "", "Username credential")
|
||||
flag.StringVar(&pwd, "caldav-password", "", "Password credential")
|
||||
flag.Parse()
|
||||
|
||||
logLevel := zap.LevelFlag("log", zap.InfoLevel, "log level")
|
||||
@@ -125,7 +142,13 @@ func main() {
|
||||
}()
|
||||
zap.ReplaceGlobals(lgr)
|
||||
|
||||
cdav, err := calendar.NewCaldav(caldavUrl, caldavPath)
|
||||
urlCaldav, err := url.Parse(caldavUrl)
|
||||
if err != nil {
|
||||
zap.S().Panicf("invalid caldav url '%v': %v", caldavUrl, err)
|
||||
}
|
||||
urlCaldav.User = url.UserPassword(user, pwd)
|
||||
|
||||
cdav, err := calendar.NewCaldav(urlCaldav.String(), caldavPath)
|
||||
if err != nil {
|
||||
zap.S().Fatal("unable to init caldav instance")
|
||||
}
|
||||
@@ -161,11 +184,16 @@ func main() {
|
||||
SkipOnErr: false,
|
||||
Check: func(ctx context.Context) error {
|
||||
_, err := cal.IsHolidaysFromCaldav(time.Now())
|
||||
if err != nil {
|
||||
zap.S().Warnf("unable to check caldav connection: %v", err)
|
||||
}
|
||||
return err
|
||||
},
|
||||
}),
|
||||
)
|
||||
http.Handle("/status", healthz.Handler())
|
||||
http.Handle("/mcp", newMCPHandler())
|
||||
http.Handle("/datetime", &DateTimeHandler{})
|
||||
|
||||
signChan := make(chan os.Signal, 1)
|
||||
go func() {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type mcpDateParams struct {
|
||||
Date string `json:"date" jsonschema:"Date to check, in YYYY-MM-DD format"`
|
||||
}
|
||||
|
||||
type mcpHolidaysParams struct {
|
||||
Year int `json:"year,omitempty" jsonschema:"Year to list French public holidays for (defaults to the current year)"`
|
||||
}
|
||||
|
||||
type mcpHolidaysResult struct {
|
||||
Year int `json:"year"`
|
||||
Holidays []time.Time `json:"holidays"`
|
||||
}
|
||||
|
||||
func calendarDayFor(day time.Time) CalendarDay {
|
||||
calDavHolidays, err := cal.IsHolidaysFromCaldav(day)
|
||||
if err != nil {
|
||||
zap.S().Warnf("unable to read holiday status from caldav: %v", err)
|
||||
calDavHolidays = false
|
||||
}
|
||||
return CalendarDay{
|
||||
Day: day,
|
||||
WorkingDay: cal.IsWorkingDay(day),
|
||||
Ferie: cal.IsHoliday(day),
|
||||
Holiday: calDavHolidays,
|
||||
Weekday: cal.IsWeekDay(day),
|
||||
}
|
||||
}
|
||||
|
||||
func handleCurrentDateTime(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, CurrentDateTime, error) {
|
||||
return nil, CurrentDateTime{DateTime: time.Now().In(location)}, nil
|
||||
}
|
||||
|
||||
func handleCalendarToday(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, CalendarDay, error) {
|
||||
return nil, calendarDayFor(time.Now().In(location)), nil
|
||||
}
|
||||
|
||||
func handleCalendarForDate(_ context.Context, _ *mcp.CallToolRequest, params mcpDateParams) (*mcp.CallToolResult, CalendarDay, error) {
|
||||
day, err := time.ParseInLocation("2006-01-02", params.Date, location)
|
||||
if err != nil {
|
||||
return nil, CalendarDay{}, fmt.Errorf("invalid date %q, expected format YYYY-MM-DD: %w", params.Date, err)
|
||||
}
|
||||
return nil, calendarDayFor(day), nil
|
||||
}
|
||||
|
||||
func handleGetHolidays(_ context.Context, _ *mcp.CallToolRequest, params mcpHolidaysParams) (*mcp.CallToolResult, mcpHolidaysResult, error) {
|
||||
year := params.Year
|
||||
if year == 0 {
|
||||
year = time.Now().In(location).Year()
|
||||
}
|
||||
return nil, mcpHolidaysResult{Year: year, Holidays: *cal.GetHolidays(year)}, nil
|
||||
}
|
||||
|
||||
func newMCPHandler() http.Handler {
|
||||
server := mcp.NewServer(&mcp.Implementation{
|
||||
Name: "domogeek",
|
||||
Version: "1.0.0",
|
||||
}, nil)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_current_datetime",
|
||||
Description: "Get the current date and time",
|
||||
}, handleCurrentDateTime)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_calendar_today",
|
||||
Description: "Get calendar information for today: working day, French public holiday (ferie), personal holiday (from CalDAV) and weekday",
|
||||
}, handleCalendarToday)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_calendar_for_date",
|
||||
Description: "Get calendar information for a given date: working day, French public holiday (ferie), personal holiday (from CalDAV) and weekday",
|
||||
}, handleCalendarForDate)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_holidays",
|
||||
Description: "List French public holidays for a given year (defaults to the current year)",
|
||||
}, handleGetHolidays)
|
||||
|
||||
return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
|
||||
return server
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func TestMCPEndToEnd(t *testing.T) {
|
||||
withTestCalendar(t)
|
||||
|
||||
srv := httptest.NewServer(newMCPHandler())
|
||||
defer srv.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
|
||||
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: srv.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("connect failed: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
tools, err := session.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("list tools failed: %v", err)
|
||||
}
|
||||
if len(tools.Tools) != 4 {
|
||||
t.Fatalf("got %d tools, want 4", len(tools.Tools))
|
||||
}
|
||||
for _, tool := range tools.Tools {
|
||||
t.Logf("tool: %s - %s", tool.Name, tool.Description)
|
||||
}
|
||||
|
||||
now, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "get_current_datetime",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call get_current_datetime failed: %v", err)
|
||||
}
|
||||
if now.IsError {
|
||||
t.Fatalf("get_current_datetime returned an error result: %v", now.Content)
|
||||
}
|
||||
t.Logf("current datetime structured content: %+v", now.StructuredContent)
|
||||
|
||||
result, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "get_calendar_for_date",
|
||||
Arguments: map[string]any{"date": "2020-01-01"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call tool failed: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("tool call returned an error result: %v", result.Content)
|
||||
}
|
||||
t.Logf("structured content: %+v", result.StructuredContent)
|
||||
|
||||
holidays, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "get_holidays",
|
||||
Arguments: map[string]any{"year": 2020},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call get_holidays failed: %v", err)
|
||||
}
|
||||
t.Logf("holidays structured content: %+v", holidays.StructuredContent)
|
||||
|
||||
badDate, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "get_calendar_for_date",
|
||||
Arguments: map[string]any{"date": "nope"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call with bad date should be a tool error, not a protocol error: %v", err)
|
||||
}
|
||||
if !badDate.IsError {
|
||||
t.Fatalf("expected IsError=true for an invalid date")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"domogeek/pkg/calendar"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func withTestCalendar(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := cal
|
||||
cal = calendar.New(location)
|
||||
t.Cleanup(func() { cal = previous })
|
||||
}
|
||||
|
||||
func TestCalendarDayFor(t *testing.T) {
|
||||
withTestCalendar(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
day time.Time
|
||||
wantWorkingDay bool
|
||||
wantFerie bool
|
||||
wantWeekday bool
|
||||
}{
|
||||
{
|
||||
name: "public holiday",
|
||||
day: time.Date(2020, time.January, 1, 0, 0, 0, 0, location),
|
||||
wantWorkingDay: false,
|
||||
wantFerie: true,
|
||||
wantWeekday: true,
|
||||
},
|
||||
{
|
||||
name: "regular working day",
|
||||
day: time.Date(2020, time.January, 2, 0, 0, 0, 0, location),
|
||||
wantWorkingDay: true,
|
||||
wantFerie: false,
|
||||
wantWeekday: true,
|
||||
},
|
||||
{
|
||||
name: "week-end",
|
||||
day: time.Date(2020, time.January, 4, 0, 0, 0, 0, location),
|
||||
wantWorkingDay: false,
|
||||
wantFerie: false,
|
||||
wantWeekday: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cd := calendarDayFor(tt.day)
|
||||
if cd.WorkingDay != tt.wantWorkingDay {
|
||||
t.Errorf("WorkingDay = %v, want %v", cd.WorkingDay, tt.wantWorkingDay)
|
||||
}
|
||||
if cd.Ferie != tt.wantFerie {
|
||||
t.Errorf("Ferie = %v, want %v", cd.Ferie, tt.wantFerie)
|
||||
}
|
||||
if cd.Weekday != tt.wantWeekday {
|
||||
t.Errorf("Weekday = %v, want %v", cd.Weekday, tt.wantWeekday)
|
||||
}
|
||||
if !cd.Day.Equal(tt.day) {
|
||||
t.Errorf("Day = %v, want %v", cd.Day, tt.day)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCurrentDateTime(t *testing.T) {
|
||||
_, dt, err := handleCurrentDateTime(context.Background(), nil, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().In(location)
|
||||
if diff := now.Sub(dt.DateTime); diff < -time.Second || diff > time.Second {
|
||||
t.Errorf("DateTime = %v, want close to %v", dt.DateTime, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCalendarToday(t *testing.T) {
|
||||
withTestCalendar(t)
|
||||
|
||||
_, cd, err := handleCalendarToday(context.Background(), nil, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().In(location)
|
||||
wantDay := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, location)
|
||||
gotDay := time.Date(cd.Day.Year(), cd.Day.Month(), cd.Day.Day(), cd.Day.Hour(), cd.Day.Minute(), 0, 0, location)
|
||||
if !gotDay.Equal(wantDay) {
|
||||
t.Errorf("Day = %v, want close to %v", cd.Day, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCalendarForDate(t *testing.T) {
|
||||
withTestCalendar(t)
|
||||
|
||||
t.Run("valid date", func(t *testing.T) {
|
||||
_, cd, err := handleCalendarForDate(context.Background(), nil, mcpDateParams{Date: "2020-01-01"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !cd.Ferie {
|
||||
t.Errorf("expected 2020-01-01 to be a ferie day")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid date", func(t *testing.T) {
|
||||
_, _, err := handleCalendarForDate(context.Background(), nil, mcpDateParams{Date: "not-a-date"})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an invalid date, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleGetHolidays(t *testing.T) {
|
||||
withTestCalendar(t)
|
||||
|
||||
t.Run("explicit year", func(t *testing.T) {
|
||||
_, result, err := handleGetHolidays(context.Background(), nil, mcpHolidaysParams{Year: 2020})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Year != 2020 {
|
||||
t.Errorf("Year = %d, want 2020", result.Year)
|
||||
}
|
||||
if len(result.Holidays) != 10 {
|
||||
t.Errorf("got %d holidays, want 10", len(result.Holidays))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults to current year", func(t *testing.T) {
|
||||
_, result, err := handleGetHolidays(context.Background(), nil, mcpHolidaysParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Year != time.Now().In(location).Year() {
|
||||
t.Errorf("Year = %d, want current year %d", result.Year, time.Now().In(location).Year())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
module domogeek
|
||||
|
||||
go 1.18
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/avast/retry-go v2.7.0+incompatible
|
||||
github.com/dolanor/caldav-go v0.2.1
|
||||
github.com/hellofresh/health-go/v4 v4.5.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||
github.com/prometheus/client_golang v1.12.1
|
||||
go.uber.org/zap v1.21.0
|
||||
)
|
||||
@@ -13,14 +15,21 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.32.1 // indirect
|
||||
github.com/prometheus/procfs v0.7.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
|
||||
go.opentelemetry.io/otel v1.0.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.0.0 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.26.0 // indirect
|
||||
)
|
||||
|
||||
@@ -39,6 +39,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/avast/retry-go v2.7.0+incompatible h1:XaGnzl7gESAideSjr+I8Hki/JBi+Yb9baHlMRPeSC84=
|
||||
github.com/avast/retry-go v2.7.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
@@ -112,6 +114,8 @@ github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/V
|
||||
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
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/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@@ -153,9 +157,12 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
@@ -250,6 +257,8 @@ github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
@@ -303,6 +312,10 @@ github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
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/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
@@ -327,6 +340,8 @@ github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhV
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
|
||||
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
|
||||
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/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -450,6 +465,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -462,6 +479,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -513,8 +532,9 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -529,6 +549,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -580,12 +602,13 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
|
||||
@@ -2,6 +2,7 @@ package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/avast/retry-go"
|
||||
"github.com/dolanor/caldav-go/caldav"
|
||||
"github.com/dolanor/caldav-go/caldav/entities"
|
||||
"github.com/dolanor/caldav-go/icalendar/components"
|
||||
@@ -28,10 +29,26 @@ func NewCaldav(caldavUrl, caldavPath string) (Caldav, error) {
|
||||
server, _ := caldav.NewServer(caldavUrl)
|
||||
// create a CalDAV client to speak to the server
|
||||
var client = caldav.NewClient(server, http.DefaultClient)
|
||||
// start executing requests!
|
||||
err := client.ValidateServer(caldavPath)
|
||||
err := retry.Do(
|
||||
func() error {
|
||||
// start executing requests!
|
||||
err := client.ValidateServer(caldavPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad caldav configuration, unable to validate connexion: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
retry.OnRetry(
|
||||
func(n uint, err error) {
|
||||
zap.S().Errorf("unable to validate caldav connection on retry %d: %v", n, err)
|
||||
},
|
||||
),
|
||||
retry.Attempts(1000),
|
||||
retry.DelayType(retry.BackOffDelay),
|
||||
retry.MaxDelay(24*time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad caldav configuration, unable to validate connexion: %w", err)
|
||||
return nil, fmt.Errorf("unable to validate caldav connection: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, build with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
|
||||
.glide/
|
||||
|
||||
# dep
|
||||
vendor/
|
||||
Gopkg.lock
|
||||
|
||||
# cover
|
||||
coverage.txt
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# {{ .Name }}
|
||||
|
||||
[](https://github.com/avast/retry-go/releases/latest)
|
||||
[](LICENSE.md)
|
||||
[](https://travis-ci.org/avast/retry-go)
|
||||
[](https://ci.appveyor.com/project/JaSei/retry-go)
|
||||
[](https://goreportcard.com/report/github.com/avast/retry-go)
|
||||
[](http://godoc.org/github.com/avast/retry-go)
|
||||
[](https://codecov.io/github/avast/retry-go?branch=master)
|
||||
[](https://sourcegraph.com/github.com/avast/retry-go?badge)
|
||||
|
||||
{{ .EmitSynopsis }}
|
||||
|
||||
{{ .EmitUsage }}
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very much welcome.
|
||||
|
||||
### Makefile
|
||||
|
||||
Makefile provides several handy rules, like README.md `generator` , `setup` for prepare build/dev environment, `test`, `cover`, etc...
|
||||
|
||||
Try `make help` for more information.
|
||||
|
||||
### Before pull request
|
||||
|
||||
please try:
|
||||
* run tests (`make test`)
|
||||
* run linter (`make lint`)
|
||||
* if your IDE don't automaticaly do `go fmt`, run `go fmt` (`make fmt`)
|
||||
|
||||
### README
|
||||
|
||||
README.md are generate from template [.godocdown.tmpl](.godocdown.tmpl) and code documentation via [godocdown](https://github.com/robertkrimen/godocdown).
|
||||
|
||||
Never edit README.md direct, because your change will be lost.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
language: go
|
||||
|
||||
go:
|
||||
- 1.7
|
||||
- 1.8
|
||||
- 1.9
|
||||
- "1.10"
|
||||
- 1.11
|
||||
- 1.12
|
||||
- 1.13
|
||||
- 1.14
|
||||
- 1.15
|
||||
|
||||
install:
|
||||
- make setup
|
||||
|
||||
script:
|
||||
- make ci
|
||||
|
||||
after_success:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
[[constraint]]
|
||||
name = "github.com/stretchr/testify"
|
||||
version = "1.1.4"
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 Avast
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
SOURCE_FILES?=$$(go list ./... | grep -v /vendor/)
|
||||
TEST_PATTERN?=.
|
||||
TEST_OPTIONS?=
|
||||
DEP?=$$(which dep)
|
||||
VERSION?=$$(cat VERSION)
|
||||
LINTER?=$$(which golangci-lint)
|
||||
LINTER_VERSION=1.15.0
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
DEP_VERS=dep-windows-amd64
|
||||
LINTER_FILE=golangci-lint-$(LINTER_VERSION)-windows-amd64.zip
|
||||
LINTER_UNPACK= >| app.zip; unzip -j app.zip -d $$GOPATH/bin; rm app.zip
|
||||
else ifeq ($(OS), Darwin)
|
||||
LINTER_FILE=golangci-lint-$(LINTER_VERSION)-darwin-amd64.tar.gz
|
||||
LINTER_UNPACK= | tar xzf - -C $$GOPATH/bin --wildcards --strip 1 "**/golangci-lint"
|
||||
else
|
||||
DEP_VERS=dep-linux-amd64
|
||||
LINTER_FILE=golangci-lint-$(LINTER_VERSION)-linux-amd64.tar.gz
|
||||
LINTER_UNPACK= | tar xzf - -C $$GOPATH/bin --wildcards --strip 1 "**/golangci-lint"
|
||||
endif
|
||||
|
||||
setup:
|
||||
go get -u github.com/pierrre/gotestcover
|
||||
go get -u golang.org/x/tools/cmd/cover
|
||||
go get -u github.com/robertkrimen/godocdown/godocdown
|
||||
@if [ "$(LINTER)" = "" ]; then\
|
||||
curl -L https://github.com/golangci/golangci-lint/releases/download/v$(LINTER_VERSION)/$(LINTER_FILE) $(LINTER_UNPACK) ;\
|
||||
chmod +x $$GOPATH/bin/golangci-lint;\
|
||||
fi
|
||||
@if [ "$(DEP)" = "" ]; then\
|
||||
curl -L https://github.com/golang/dep/releases/download/v0.3.1/$(DEP_VERS) >| $$GOPATH/bin/dep;\
|
||||
chmod +x $$GOPATH/bin/dep;\
|
||||
fi
|
||||
dep ensure
|
||||
|
||||
generate: ## Generate README.md
|
||||
godocdown >| README.md
|
||||
|
||||
test: generate test_and_cover_report lint
|
||||
|
||||
test_and_cover_report:
|
||||
gotestcover $(TEST_OPTIONS) -covermode=atomic -coverprofile=coverage.txt $(SOURCE_FILES) -run $(TEST_PATTERN) -timeout=2m
|
||||
|
||||
cover: test ## Run all the tests and opens the coverage report
|
||||
go tool cover -html=coverage.txt
|
||||
|
||||
fmt: ## gofmt and goimports all go files
|
||||
find . -name '*.go' -not -wholename './vendor/*' | while read -r file; do gofmt -w -s "$$file"; goimports -w "$$file"; done
|
||||
|
||||
lint: ## Run all the linters
|
||||
golangci-lint run
|
||||
|
||||
ci: test_and_cover_report ## Run all the tests but no linters - use https://golangci.com integration instead
|
||||
|
||||
build:
|
||||
go build
|
||||
|
||||
release: ## Release new version
|
||||
git tag | grep -q $(VERSION) && echo This version was released! Increase VERSION! || git tag $(VERSION) && git push origin $(VERSION) && git tag v$(VERSION) && git push origin v$(VERSION)
|
||||
|
||||
# Absolutely awesome: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.DEFAULT_GOAL := build
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
# retry
|
||||
|
||||
[](https://github.com/avast/retry-go/releases/latest)
|
||||
[](LICENSE.md)
|
||||
[](https://travis-ci.org/avast/retry-go)
|
||||
[](https://ci.appveyor.com/project/JaSei/retry-go)
|
||||
[](https://goreportcard.com/report/github.com/avast/retry-go)
|
||||
[](http://godoc.org/github.com/avast/retry-go)
|
||||
[](https://codecov.io/github/avast/retry-go?branch=master)
|
||||
[](https://sourcegraph.com/github.com/avast/retry-go?badge)
|
||||
|
||||
Simple library for retry mechanism
|
||||
|
||||
slightly inspired by
|
||||
[Try::Tiny::Retry](https://metacpan.org/pod/Try::Tiny::Retry)
|
||||
|
||||
|
||||
### SYNOPSIS
|
||||
|
||||
http get with retry:
|
||||
|
||||
url := "http://example.com"
|
||||
var body []byte
|
||||
|
||||
err := retry.Do(
|
||||
func() error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
fmt.Println(body)
|
||||
|
||||
[next examples](https://github.com/avast/retry-go/tree/master/examples)
|
||||
|
||||
|
||||
### SEE ALSO
|
||||
|
||||
* [giantswarm/retry-go](https://github.com/giantswarm/retry-go) - slightly
|
||||
complicated interface.
|
||||
|
||||
* [sethgrid/pester](https://github.com/sethgrid/pester) - only http retry for
|
||||
http calls with retries and backoff
|
||||
|
||||
* [cenkalti/backoff](https://github.com/cenkalti/backoff) - Go port of the
|
||||
exponential backoff algorithm from Google's HTTP Client Library for Java. Really
|
||||
complicated interface.
|
||||
|
||||
* [rafaeljesus/retry-go](https://github.com/rafaeljesus/retry-go) - looks good,
|
||||
slightly similar as this package, don't have 'simple' `Retry` method
|
||||
|
||||
* [matryer/try](https://github.com/matryer/try) - very popular package,
|
||||
nonintuitive interface (for me)
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
1.0.2 -> 2.0.0
|
||||
|
||||
* argument of `retry.Delay` is final delay (no multiplication by `retry.Units`
|
||||
anymore)
|
||||
|
||||
* function `retry.Units` are removed
|
||||
|
||||
* [more about this breaking change](https://github.com/avast/retry-go/issues/7)
|
||||
|
||||
0.3.0 -> 1.0.0
|
||||
|
||||
* `retry.Retry` function are changed to `retry.Do` function
|
||||
|
||||
* `retry.RetryCustom` (OnRetry) and `retry.RetryCustomWithOpts` functions are
|
||||
now implement via functions produces Options (aka `retry.OnRetry`)
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
var (
|
||||
DefaultAttempts = uint(10)
|
||||
DefaultDelay = 100 * time.Millisecond
|
||||
DefaultMaxJitter = 100 * time.Millisecond
|
||||
DefaultOnRetry = func(n uint, err error) {}
|
||||
DefaultRetryIf = IsRecoverable
|
||||
DefaultDelayType = CombineDelay(BackOffDelay, RandomDelay)
|
||||
DefaultLastErrorOnly = false
|
||||
DefaultContext = context.Background()
|
||||
)
|
||||
```
|
||||
|
||||
#### func BackOffDelay
|
||||
|
||||
```go
|
||||
func BackOffDelay(n uint, config *Config) time.Duration
|
||||
```
|
||||
BackOffDelay is a DelayType which increases delay between consecutive retries
|
||||
|
||||
#### func Do
|
||||
|
||||
```go
|
||||
func Do(retryableFunc RetryableFunc, opts ...Option) error
|
||||
```
|
||||
|
||||
#### func FixedDelay
|
||||
|
||||
```go
|
||||
func FixedDelay(_ uint, config *Config) time.Duration
|
||||
```
|
||||
FixedDelay is a DelayType which keeps delay the same through all iterations
|
||||
|
||||
#### func IsRecoverable
|
||||
|
||||
```go
|
||||
func IsRecoverable(err error) bool
|
||||
```
|
||||
IsRecoverable checks if error is an instance of `unrecoverableError`
|
||||
|
||||
#### func RandomDelay
|
||||
|
||||
```go
|
||||
func RandomDelay(_ uint, config *Config) time.Duration
|
||||
```
|
||||
RandomDelay is a DelayType which picks a random delay up to config.maxJitter
|
||||
|
||||
#### func Unrecoverable
|
||||
|
||||
```go
|
||||
func Unrecoverable(err error) error
|
||||
```
|
||||
Unrecoverable wraps an error in `unrecoverableError` struct
|
||||
|
||||
#### type Config
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
#### type DelayTypeFunc
|
||||
|
||||
```go
|
||||
type DelayTypeFunc func(n uint, config *Config) time.Duration
|
||||
```
|
||||
|
||||
|
||||
#### func CombineDelay
|
||||
|
||||
```go
|
||||
func CombineDelay(delays ...DelayTypeFunc) DelayTypeFunc
|
||||
```
|
||||
CombineDelay is a DelayType the combines all of the specified delays into a new
|
||||
DelayTypeFunc
|
||||
|
||||
#### type Error
|
||||
|
||||
```go
|
||||
type Error []error
|
||||
```
|
||||
|
||||
Error type represents list of errors in retry
|
||||
|
||||
#### func (Error) Error
|
||||
|
||||
```go
|
||||
func (e Error) Error() string
|
||||
```
|
||||
Error method return string representation of Error It is an implementation of
|
||||
error interface
|
||||
|
||||
#### func (Error) WrappedErrors
|
||||
|
||||
```go
|
||||
func (e Error) WrappedErrors() []error
|
||||
```
|
||||
WrappedErrors returns the list of errors that this Error is wrapping. It is an
|
||||
implementation of the `errwrap.Wrapper` interface in package
|
||||
[errwrap](https://github.com/hashicorp/errwrap) so that `retry.Error` can be
|
||||
used with that library.
|
||||
|
||||
#### type OnRetryFunc
|
||||
|
||||
```go
|
||||
type OnRetryFunc func(n uint, err error)
|
||||
```
|
||||
|
||||
Function signature of OnRetry function n = count of attempts
|
||||
|
||||
#### type Option
|
||||
|
||||
```go
|
||||
type Option func(*Config)
|
||||
```
|
||||
|
||||
Option represents an option for retry.
|
||||
|
||||
#### func Attempts
|
||||
|
||||
```go
|
||||
func Attempts(attempts uint) Option
|
||||
```
|
||||
Attempts set count of retry default is 10
|
||||
|
||||
#### func Context
|
||||
|
||||
```go
|
||||
func Context(ctx context.Context) Option
|
||||
```
|
||||
Context allow to set context of retry default are Background context
|
||||
|
||||
example of immediately cancellation (maybe it isn't the best example, but it describes behavior enough; I hope)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
retry.Do(
|
||||
func() error {
|
||||
...
|
||||
},
|
||||
retry.Context(ctx),
|
||||
)
|
||||
|
||||
#### func Delay
|
||||
|
||||
```go
|
||||
func Delay(delay time.Duration) Option
|
||||
```
|
||||
Delay set delay between retry default is 100ms
|
||||
|
||||
#### func DelayType
|
||||
|
||||
```go
|
||||
func DelayType(delayType DelayTypeFunc) Option
|
||||
```
|
||||
DelayType set type of the delay between retries default is BackOff
|
||||
|
||||
#### func LastErrorOnly
|
||||
|
||||
```go
|
||||
func LastErrorOnly(lastErrorOnly bool) Option
|
||||
```
|
||||
return the direct last error that came from the retried function default is
|
||||
false (return wrapped errors with everything)
|
||||
|
||||
#### func MaxDelay
|
||||
|
||||
```go
|
||||
func MaxDelay(maxDelay time.Duration) Option
|
||||
```
|
||||
MaxDelay set maximum delay between retry does not apply by default
|
||||
|
||||
#### func MaxJitter
|
||||
|
||||
```go
|
||||
func MaxJitter(maxJitter time.Duration) Option
|
||||
```
|
||||
MaxJitter sets the maximum random Jitter between retries for RandomDelay
|
||||
|
||||
#### func OnRetry
|
||||
|
||||
```go
|
||||
func OnRetry(onRetry OnRetryFunc) Option
|
||||
```
|
||||
OnRetry function callback are called each retry
|
||||
|
||||
log each retry example:
|
||||
|
||||
retry.Do(
|
||||
func() error {
|
||||
return errors.New("some error")
|
||||
},
|
||||
retry.OnRetry(func(n uint, err error) {
|
||||
log.Printf("#%d: %s\n", n, err)
|
||||
}),
|
||||
)
|
||||
|
||||
#### func RetryIf
|
||||
|
||||
```go
|
||||
func RetryIf(retryIf RetryIfFunc) Option
|
||||
```
|
||||
RetryIf controls whether a retry should be attempted after an error (assuming
|
||||
there are any retry attempts remaining)
|
||||
|
||||
skip retry if special error example:
|
||||
|
||||
retry.Do(
|
||||
func() error {
|
||||
return errors.New("special error")
|
||||
},
|
||||
retry.RetryIf(func(err error) bool {
|
||||
if err.Error() == "special error" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
By default RetryIf stops execution if the error is wrapped using
|
||||
`retry.Unrecoverable`, so above example may also be shortened to:
|
||||
|
||||
retry.Do(
|
||||
func() error {
|
||||
return retry.Unrecoverable(errors.New("special error"))
|
||||
}
|
||||
)
|
||||
|
||||
#### type RetryIfFunc
|
||||
|
||||
```go
|
||||
type RetryIfFunc func(error) bool
|
||||
```
|
||||
|
||||
Function signature of retry if function
|
||||
|
||||
#### type RetryableFunc
|
||||
|
||||
```go
|
||||
type RetryableFunc func() error
|
||||
```
|
||||
|
||||
Function signature of retryable function
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are very much welcome.
|
||||
|
||||
### Makefile
|
||||
|
||||
Makefile provides several handy rules, like README.md `generator` , `setup` for prepare build/dev environment, `test`, `cover`, etc...
|
||||
|
||||
Try `make help` for more information.
|
||||
|
||||
### Before pull request
|
||||
|
||||
please try:
|
||||
* run tests (`make test`)
|
||||
* run linter (`make lint`)
|
||||
* if your IDE don't automaticaly do `go fmt`, run `go fmt` (`make fmt`)
|
||||
|
||||
### README
|
||||
|
||||
README.md are generate from template [.godocdown.tmpl](.godocdown.tmpl) and code documentation via [godocdown](https://github.com/robertkrimen/godocdown).
|
||||
|
||||
Never edit README.md direct, because your change will be lost.
|
||||
+1
@@ -0,0 +1 @@
|
||||
2.7.0
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
version: "{build}"
|
||||
|
||||
clone_folder: c:\Users\appveyor\go\src\github.com\avast\retry-go
|
||||
|
||||
#os: Windows Server 2012 R2
|
||||
platform: x64
|
||||
|
||||
install:
|
||||
- copy c:\MinGW\bin\mingw32-make.exe c:\MinGW\bin\make.exe
|
||||
- set GOPATH=C:\Users\appveyor\go
|
||||
- set PATH=%PATH%;c:\MinGW\bin
|
||||
- set PATH=%PATH%;%GOPATH%\bin;c:\go\bin
|
||||
- set GOBIN=%GOPATH%\bin
|
||||
- go version
|
||||
- go env
|
||||
- make setup
|
||||
|
||||
build_script:
|
||||
- make ci
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
package retry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Function signature of retry if function
|
||||
type RetryIfFunc func(error) bool
|
||||
|
||||
// Function signature of OnRetry function
|
||||
// n = count of attempts
|
||||
type OnRetryFunc func(n uint, err error)
|
||||
|
||||
type DelayTypeFunc func(n uint, config *Config) time.Duration
|
||||
|
||||
type Config struct {
|
||||
attempts uint
|
||||
delay time.Duration
|
||||
maxDelay time.Duration
|
||||
maxJitter time.Duration
|
||||
onRetry OnRetryFunc
|
||||
retryIf RetryIfFunc
|
||||
delayType DelayTypeFunc
|
||||
lastErrorOnly bool
|
||||
context context.Context
|
||||
|
||||
maxBackOffN uint
|
||||
}
|
||||
|
||||
// Option represents an option for retry.
|
||||
type Option func(*Config)
|
||||
|
||||
// return the direct last error that came from the retried function
|
||||
// default is false (return wrapped errors with everything)
|
||||
func LastErrorOnly(lastErrorOnly bool) Option {
|
||||
return func(c *Config) {
|
||||
c.lastErrorOnly = lastErrorOnly
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts set count of retry
|
||||
// default is 10
|
||||
func Attempts(attempts uint) Option {
|
||||
return func(c *Config) {
|
||||
c.attempts = attempts
|
||||
}
|
||||
}
|
||||
|
||||
// Delay set delay between retry
|
||||
// default is 100ms
|
||||
func Delay(delay time.Duration) Option {
|
||||
return func(c *Config) {
|
||||
c.delay = delay
|
||||
}
|
||||
}
|
||||
|
||||
// MaxDelay set maximum delay between retry
|
||||
// does not apply by default
|
||||
func MaxDelay(maxDelay time.Duration) Option {
|
||||
return func(c *Config) {
|
||||
c.maxDelay = maxDelay
|
||||
}
|
||||
}
|
||||
|
||||
// MaxJitter sets the maximum random Jitter between retries for RandomDelay
|
||||
func MaxJitter(maxJitter time.Duration) Option {
|
||||
return func(c *Config) {
|
||||
c.maxJitter = maxJitter
|
||||
}
|
||||
}
|
||||
|
||||
// DelayType set type of the delay between retries
|
||||
// default is BackOff
|
||||
func DelayType(delayType DelayTypeFunc) Option {
|
||||
return func(c *Config) {
|
||||
c.delayType = delayType
|
||||
}
|
||||
}
|
||||
|
||||
// BackOffDelay is a DelayType which increases delay between consecutive retries
|
||||
func BackOffDelay(n uint, config *Config) time.Duration {
|
||||
// 1 << 63 would overflow signed int64 (time.Duration), thus 62.
|
||||
const max uint = 62
|
||||
|
||||
if config.maxBackOffN == 0 {
|
||||
if config.delay <= 0 {
|
||||
config.delay = 1
|
||||
}
|
||||
config.maxBackOffN = max - uint(math.Floor(math.Log2(float64(config.delay))))
|
||||
}
|
||||
|
||||
if n > config.maxBackOffN {
|
||||
n = config.maxBackOffN
|
||||
}
|
||||
return config.delay << n
|
||||
}
|
||||
|
||||
// FixedDelay is a DelayType which keeps delay the same through all iterations
|
||||
func FixedDelay(_ uint, config *Config) time.Duration {
|
||||
return config.delay
|
||||
}
|
||||
|
||||
// RandomDelay is a DelayType which picks a random delay up to config.maxJitter
|
||||
func RandomDelay(_ uint, config *Config) time.Duration {
|
||||
return time.Duration(rand.Int63n(int64(config.maxJitter)))
|
||||
}
|
||||
|
||||
// CombineDelay is a DelayType the combines all of the specified delays into a new DelayTypeFunc
|
||||
func CombineDelay(delays ...DelayTypeFunc) DelayTypeFunc {
|
||||
const maxInt64 = uint64(math.MaxInt64)
|
||||
|
||||
return func(n uint, config *Config) time.Duration {
|
||||
var total uint64
|
||||
for _, delay := range delays {
|
||||
total += uint64(delay(n, config))
|
||||
if total > maxInt64 {
|
||||
total = maxInt64
|
||||
}
|
||||
}
|
||||
return time.Duration(total)
|
||||
}
|
||||
}
|
||||
|
||||
// OnRetry function callback are called each retry
|
||||
//
|
||||
// log each retry example:
|
||||
//
|
||||
// retry.Do(
|
||||
// func() error {
|
||||
// return errors.New("some error")
|
||||
// },
|
||||
// retry.OnRetry(func(n uint, err error) {
|
||||
// log.Printf("#%d: %s\n", n, err)
|
||||
// }),
|
||||
// )
|
||||
func OnRetry(onRetry OnRetryFunc) Option {
|
||||
return func(c *Config) {
|
||||
c.onRetry = onRetry
|
||||
}
|
||||
}
|
||||
|
||||
// RetryIf controls whether a retry should be attempted after an error
|
||||
// (assuming there are any retry attempts remaining)
|
||||
//
|
||||
// skip retry if special error example:
|
||||
//
|
||||
// retry.Do(
|
||||
// func() error {
|
||||
// return errors.New("special error")
|
||||
// },
|
||||
// retry.RetryIf(func(err error) bool {
|
||||
// if err.Error() == "special error" {
|
||||
// return false
|
||||
// }
|
||||
// return true
|
||||
// })
|
||||
// )
|
||||
//
|
||||
// By default RetryIf stops execution if the error is wrapped using `retry.Unrecoverable`,
|
||||
// so above example may also be shortened to:
|
||||
//
|
||||
// retry.Do(
|
||||
// func() error {
|
||||
// return retry.Unrecoverable(errors.New("special error"))
|
||||
// }
|
||||
// )
|
||||
func RetryIf(retryIf RetryIfFunc) Option {
|
||||
return func(c *Config) {
|
||||
c.retryIf = retryIf
|
||||
}
|
||||
}
|
||||
|
||||
// Context allow to set context of retry
|
||||
// default are Background context
|
||||
//
|
||||
// example of immediately cancellation (maybe it isn't the best example, but it describes behavior enough; I hope)
|
||||
// ctx, cancel := context.WithCancel(context.Background())
|
||||
// cancel()
|
||||
//
|
||||
// retry.Do(
|
||||
// func() error {
|
||||
// ...
|
||||
// },
|
||||
// retry.Context(ctx),
|
||||
// )
|
||||
func Context(ctx context.Context) Option {
|
||||
return func(c *Config) {
|
||||
c.context = ctx
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
Simple library for retry mechanism
|
||||
|
||||
slightly inspired by [Try::Tiny::Retry](https://metacpan.org/pod/Try::Tiny::Retry)
|
||||
|
||||
SYNOPSIS
|
||||
|
||||
http get with retry:
|
||||
|
||||
url := "http://example.com"
|
||||
var body []byte
|
||||
|
||||
err := retry.Do(
|
||||
func() error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
fmt.Println(body)
|
||||
|
||||
[next examples](https://github.com/avast/retry-go/tree/master/examples)
|
||||
|
||||
|
||||
SEE ALSO
|
||||
|
||||
* [giantswarm/retry-go](https://github.com/giantswarm/retry-go) - slightly complicated interface.
|
||||
|
||||
* [sethgrid/pester](https://github.com/sethgrid/pester) - only http retry for http calls with retries and backoff
|
||||
|
||||
* [cenkalti/backoff](https://github.com/cenkalti/backoff) - Go port of the exponential backoff algorithm from Google's HTTP Client Library for Java. Really complicated interface.
|
||||
|
||||
* [rafaeljesus/retry-go](https://github.com/rafaeljesus/retry-go) - looks good, slightly similar as this package, don't have 'simple' `Retry` method
|
||||
|
||||
* [matryer/try](https://github.com/matryer/try) - very popular package, nonintuitive interface (for me)
|
||||
|
||||
BREAKING CHANGES
|
||||
|
||||
1.0.2 -> 2.0.0
|
||||
|
||||
* argument of `retry.Delay` is final delay (no multiplication by `retry.Units` anymore)
|
||||
|
||||
* function `retry.Units` are removed
|
||||
|
||||
* [more about this breaking change](https://github.com/avast/retry-go/issues/7)
|
||||
|
||||
|
||||
0.3.0 -> 1.0.0
|
||||
|
||||
* `retry.Retry` function are changed to `retry.Do` function
|
||||
|
||||
* `retry.RetryCustom` (OnRetry) and `retry.RetryCustomWithOpts` functions are now implement via functions produces Options (aka `retry.OnRetry`)
|
||||
|
||||
|
||||
*/
|
||||
package retry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Function signature of retryable function
|
||||
type RetryableFunc func() error
|
||||
|
||||
var (
|
||||
DefaultAttempts = uint(10)
|
||||
DefaultDelay = 100 * time.Millisecond
|
||||
DefaultMaxJitter = 100 * time.Millisecond
|
||||
DefaultOnRetry = func(n uint, err error) {}
|
||||
DefaultRetryIf = IsRecoverable
|
||||
DefaultDelayType = CombineDelay(BackOffDelay, RandomDelay)
|
||||
DefaultLastErrorOnly = false
|
||||
DefaultContext = context.Background()
|
||||
)
|
||||
|
||||
func Do(retryableFunc RetryableFunc, opts ...Option) error {
|
||||
var n uint
|
||||
|
||||
//default
|
||||
config := &Config{
|
||||
attempts: DefaultAttempts,
|
||||
delay: DefaultDelay,
|
||||
maxJitter: DefaultMaxJitter,
|
||||
onRetry: DefaultOnRetry,
|
||||
retryIf: DefaultRetryIf,
|
||||
delayType: DefaultDelayType,
|
||||
lastErrorOnly: DefaultLastErrorOnly,
|
||||
context: DefaultContext,
|
||||
}
|
||||
|
||||
//apply opts
|
||||
for _, opt := range opts {
|
||||
opt(config)
|
||||
}
|
||||
|
||||
if err := config.context.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errorLog Error
|
||||
if !config.lastErrorOnly {
|
||||
errorLog = make(Error, config.attempts)
|
||||
} else {
|
||||
errorLog = make(Error, 1)
|
||||
}
|
||||
|
||||
lastErrIndex := n
|
||||
for n < config.attempts {
|
||||
err := retryableFunc()
|
||||
|
||||
if err != nil {
|
||||
errorLog[lastErrIndex] = unpackUnrecoverable(err)
|
||||
|
||||
if !config.retryIf(err) {
|
||||
break
|
||||
}
|
||||
|
||||
config.onRetry(n, err)
|
||||
|
||||
// if this is last attempt - don't wait
|
||||
if n == config.attempts-1 {
|
||||
break
|
||||
}
|
||||
|
||||
delayTime := config.delayType(n, config)
|
||||
if config.maxDelay > 0 && delayTime > config.maxDelay {
|
||||
delayTime = config.maxDelay
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(delayTime):
|
||||
case <-config.context.Done():
|
||||
return config.context.Err()
|
||||
}
|
||||
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
n++
|
||||
if !config.lastErrorOnly {
|
||||
lastErrIndex = n
|
||||
}
|
||||
}
|
||||
|
||||
if config.lastErrorOnly {
|
||||
return errorLog[lastErrIndex]
|
||||
}
|
||||
return errorLog
|
||||
}
|
||||
|
||||
// Error type represents list of errors in retry
|
||||
type Error []error
|
||||
|
||||
// Error method return string representation of Error
|
||||
// It is an implementation of error interface
|
||||
func (e Error) Error() string {
|
||||
logWithNumber := make([]string, lenWithoutNil(e))
|
||||
for i, l := range e {
|
||||
if l != nil {
|
||||
logWithNumber[i] = fmt.Sprintf("#%d: %s", i+1, l.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("All attempts fail:\n%s", strings.Join(logWithNumber, "\n"))
|
||||
}
|
||||
|
||||
func lenWithoutNil(e Error) (count int) {
|
||||
for _, v := range e {
|
||||
if v != nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// WrappedErrors returns the list of errors that this Error is wrapping.
|
||||
// It is an implementation of the `errwrap.Wrapper` interface
|
||||
// in package [errwrap](https://github.com/hashicorp/errwrap) so that
|
||||
// `retry.Error` can be used with that library.
|
||||
func (e Error) WrappedErrors() []error {
|
||||
return e
|
||||
}
|
||||
|
||||
type unrecoverableError struct {
|
||||
error
|
||||
}
|
||||
|
||||
// Unrecoverable wraps an error in `unrecoverableError` struct
|
||||
func Unrecoverable(err error) error {
|
||||
return unrecoverableError{err}
|
||||
}
|
||||
|
||||
// IsRecoverable checks if error is an instance of `unrecoverableError`
|
||||
func IsRecoverable(err error) bool {
|
||||
_, isUnrecoverable := err.(unrecoverableError)
|
||||
return !isUnrecoverable
|
||||
}
|
||||
|
||||
func unpackUnrecoverable(err error) error {
|
||||
if unrecoverable, isUnrecoverable := err.(unrecoverableError); isUnrecoverable {
|
||||
return unrecoverable.error
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 JSON Schema Go Project Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import "maps"
|
||||
|
||||
// An annotations tracks certain properties computed by keywords that are used by validation.
|
||||
// ("Annotation" is the spec's term.)
|
||||
// In particular, the unevaluatedItems and unevaluatedProperties keywords need to know which
|
||||
// items and properties were evaluated (validated successfully).
|
||||
type annotations struct {
|
||||
allItems bool // all items were evaluated
|
||||
endIndex int // 1+largest index evaluated by prefixItems
|
||||
evaluatedIndexes map[int]bool // set of indexes evaluated by contains
|
||||
allProperties bool // all properties were evaluated
|
||||
evaluatedProperties map[string]bool // set of properties evaluated by various keywords
|
||||
}
|
||||
|
||||
// noteIndex marks i as evaluated.
|
||||
func (a *annotations) noteIndex(i int) {
|
||||
if a.evaluatedIndexes == nil {
|
||||
a.evaluatedIndexes = map[int]bool{}
|
||||
}
|
||||
a.evaluatedIndexes[i] = true
|
||||
}
|
||||
|
||||
// noteEndIndex marks items with index less than end as evaluated.
|
||||
func (a *annotations) noteEndIndex(end int) {
|
||||
if end > a.endIndex {
|
||||
a.endIndex = end
|
||||
}
|
||||
}
|
||||
|
||||
// noteProperty marks prop as evaluated.
|
||||
func (a *annotations) noteProperty(prop string) {
|
||||
if a.evaluatedProperties == nil {
|
||||
a.evaluatedProperties = map[string]bool{}
|
||||
}
|
||||
a.evaluatedProperties[prop] = true
|
||||
}
|
||||
|
||||
// noteProperties marks all the properties in props as evaluated.
|
||||
func (a *annotations) noteProperties(props map[string]bool) {
|
||||
a.evaluatedProperties = merge(a.evaluatedProperties, props)
|
||||
}
|
||||
|
||||
// merge adds b's annotations to a.
|
||||
// a must not be nil.
|
||||
func (a *annotations) merge(b *annotations) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
if b.allItems {
|
||||
a.allItems = true
|
||||
}
|
||||
if b.endIndex > a.endIndex {
|
||||
a.endIndex = b.endIndex
|
||||
}
|
||||
a.evaluatedIndexes = merge(a.evaluatedIndexes, b.evaluatedIndexes)
|
||||
if b.allProperties {
|
||||
a.allProperties = true
|
||||
}
|
||||
a.evaluatedProperties = merge(a.evaluatedProperties, b.evaluatedProperties)
|
||||
}
|
||||
|
||||
// merge adds t's keys to s and returns s.
|
||||
// If s is nil, it returns a copy of t.
|
||||
func merge[K comparable](s, t map[K]bool) map[K]bool {
|
||||
if s == nil {
|
||||
return maps.Clone(t)
|
||||
}
|
||||
maps.Copy(s, t)
|
||||
return s
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
Package jsonschema is an implementation of the [JSON Schema specification],
|
||||
a JSON-based format for describing the structure of JSON data.
|
||||
The package can be used to read schemas for code generation, and to validate
|
||||
data using the draft 2020-12 and draft-07 specifications. Validation with
|
||||
other drafts or custom meta-schemas is not supported.
|
||||
|
||||
Construct a [Schema] as you would any Go struct (for example, by writing
|
||||
a struct literal), or unmarshal a JSON schema into a [Schema] in the usual
|
||||
way (with [encoding/json], for instance). It can then be used for code
|
||||
generation or other purposes without further processing.
|
||||
You can also infer a schema from a Go struct.
|
||||
|
||||
# Resolution
|
||||
|
||||
A Schema can refer to other schemas, both inside and outside itself. These
|
||||
references must be resolved before a schema can be used for validation.
|
||||
Call [Schema.Resolve] to obtain a resolved schema (called a [Resolved]).
|
||||
If the schema has external references, pass a [ResolveOptions] with a [Loader]
|
||||
to load them. To validate default values in a schema, set
|
||||
[ResolveOptions.ValidateDefaults] to true.
|
||||
|
||||
# Validation
|
||||
|
||||
Call [Resolved.Validate] to validate a JSON value. The value must be a
|
||||
Go value that looks like the result of unmarshaling a JSON value into an
|
||||
[any] or a struct. For example, the JSON value
|
||||
|
||||
{"name": "Al", "scores": [90, 80, 100]}
|
||||
|
||||
could be represented as the Go value
|
||||
|
||||
map[string]any{
|
||||
"name": "Al",
|
||||
"scores": []any{90, 80, 100},
|
||||
}
|
||||
|
||||
or as a value of this type:
|
||||
|
||||
type Player struct {
|
||||
Name string `json:"name"`
|
||||
Scores []int `json:"scores"`
|
||||
}
|
||||
|
||||
# Inference
|
||||
|
||||
The [For] function returns a [Schema] describing the given Go type.
|
||||
Each field in the struct becomes a property of the schema.
|
||||
The values of "json" tags are respected: the field's property name is taken
|
||||
from the tag, and fields omitted from the JSON are omitted from the schema as
|
||||
well.
|
||||
For example, `jsonschema.For[Player]()` returns this schema:
|
||||
|
||||
{
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"scores": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"}
|
||||
}
|
||||
"required": ["name", "scores"],
|
||||
"additionalProperties": {"not": {}}
|
||||
}
|
||||
}
|
||||
|
||||
Use the "jsonschema" struct tag to provide a description for the property:
|
||||
|
||||
type Player struct {
|
||||
Name string `json:"name" jsonschema:"player name"`
|
||||
Scores []int `json:"scores" jsonschema:"scores of player's games"`
|
||||
}
|
||||
|
||||
# Deviations from the specification
|
||||
|
||||
Regular expressions are processed with Go's regexp package, which differs
|
||||
from ECMA 262, most significantly in not supporting back-references.
|
||||
See [this table of differences] for more.
|
||||
|
||||
The "format" keyword described in [section 7 of the validation spec] is recorded
|
||||
in the Schema, but is ignored during validation.
|
||||
It does not even produce [annotations].
|
||||
Use the "pattern" keyword instead: it will work more reliably across JSON Schema
|
||||
implementations. See [learnjsonschema.com] for more recommendations about "format".
|
||||
|
||||
The content keywords described in [section 8 of the validation spec]
|
||||
are recorded in the schema, but ignored during validation.
|
||||
|
||||
# Controlling behavior changes
|
||||
|
||||
Minor and patch releases of this package may introduce behavior changes as part
|
||||
of bug fixes or correctness improvements. To help manage the impact of such
|
||||
changes, the package allows you to access previous behaviors using the
|
||||
`JSONSCHEMAGODEBUG` environment variable. The available settings are listed
|
||||
below; additional options may be introduced in future releases.
|
||||
|
||||
- **typeschemasnull**: When set to `"1"`, the inferred schema for slices will
|
||||
*not* include the `null` type alongside the array type. It will also avoid
|
||||
adding `null` to non-native pointer types (such as `time.Time`). This restores
|
||||
the behavior from versions prior to v0.3.0. The default behavior is to include
|
||||
`null` in these cases.
|
||||
|
||||
[JSON Schema specification]: https://json-schema.org
|
||||
[section 7 of the validation spec]: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7
|
||||
[section 8 of the validation spec]: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.8
|
||||
[learnjsonschema.com]: https://www.learnjsonschema.com/2020-12/format-annotation/format/
|
||||
[this table of differences]: https://github.com/dlclark/regexp2?tab=readme-ov-file#compare-regexp-and-regexp2
|
||||
[annotations]: https://json-schema.org/draft/2020-12/json-schema-core#name-annotations
|
||||
*/
|
||||
package jsonschema
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file contains functions that infer a schema from a Go type.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
"math/big"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
const debugEnv = "JSONSCHEMAGODEBUG"
|
||||
|
||||
// ForOptions are options for the [For] and [ForType] functions.
|
||||
type ForOptions struct {
|
||||
// If IgnoreInvalidTypes is true, fields that can't be represented as a JSON
|
||||
// Schema are ignored instead of causing an error.
|
||||
// This allows callers to adjust the resulting schema using custom knowledge.
|
||||
// For example, an interface type where all the possible implementations are
|
||||
// known can be described with "oneof".
|
||||
IgnoreInvalidTypes bool
|
||||
|
||||
// TypeSchemas maps types to their schemas.
|
||||
// If [For] encounters a type that is a key in this map, the
|
||||
// corresponding value is used as the resulting schema (after cloning to
|
||||
// ensure uniqueness).
|
||||
// Types in this map override the default translations, as described
|
||||
// in [For]'s documentation.
|
||||
// PropertyOrder defined in these schemas will not be used in [For] or [ForType].
|
||||
TypeSchemas map[reflect.Type]*Schema
|
||||
}
|
||||
|
||||
// For constructs a JSON schema object for the given type argument.
|
||||
// If non-nil, the provided options configure certain aspects of this contruction,
|
||||
// described below.
|
||||
|
||||
// It translates Go types into compatible JSON schema types, as follows.
|
||||
// These defaults can be overridden by [ForOptions.TypeSchemas].
|
||||
//
|
||||
// - Strings have schema type "string".
|
||||
// - Bools have schema type "boolean".
|
||||
// - Signed and unsigned integer types have schema type "integer".
|
||||
// - Floating point types have schema type "number".
|
||||
// - Slices and arrays have schema type "array", and a corresponding schema
|
||||
// for items.
|
||||
// - Maps with string key have schema type "object", and corresponding
|
||||
// schema for additionalProperties.
|
||||
// - Structs have schema type "object", and disallow additionalProperties.
|
||||
// Their properties are derived from exported struct fields, using the
|
||||
// struct field JSON name. Fields that are marked "omitempty" or "omitzero" are
|
||||
// considered optional; all other fields become required properties.
|
||||
// For structs, the PropertyOrder will be set to the field order.
|
||||
// - Some types in the standard library that implement json.Marshaler
|
||||
// translate to schemas that match the values to which they marshal.
|
||||
// For example, [time.Time] translates to the schema for strings.
|
||||
//
|
||||
// For will return an error if there is a cycle in the types.
|
||||
//
|
||||
// By default, For returns an error if t contains (possibly recursively) any of the
|
||||
// following Go types, as they are incompatible with the JSON schema spec.
|
||||
// If [ForOptions.IgnoreInvalidTypes] is true, then these types are ignored instead.
|
||||
// - maps with key other than 'string'
|
||||
// - function types
|
||||
// - channel types
|
||||
// - complex numbers
|
||||
// - unsafe pointers
|
||||
//
|
||||
// This function recognizes struct field tags named "jsonschema".
|
||||
// A jsonschema tag on a field is used as the description for the corresponding property.
|
||||
// For future compatibility, descriptions must not start with "WORD=", where WORD is a
|
||||
// sequence of non-whitespace characters.
|
||||
func For[T any](opts *ForOptions) (*Schema, error) {
|
||||
if opts == nil {
|
||||
opts = &ForOptions{}
|
||||
}
|
||||
schemas := maps.Clone(initialSchemaMap)
|
||||
// Add types from the options. They override the default ones.
|
||||
maps.Copy(schemas, opts.TypeSchemas)
|
||||
s, err := forType(reflect.TypeFor[T](), map[reflect.Type]bool{}, opts.IgnoreInvalidTypes, schemas)
|
||||
if err != nil {
|
||||
var z T
|
||||
return nil, fmt.Errorf("For[%T](): %w", z, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ForType is like [For], but takes a [reflect.Type]
|
||||
func ForType(t reflect.Type, opts *ForOptions) (*Schema, error) {
|
||||
if opts == nil {
|
||||
opts = &ForOptions{}
|
||||
}
|
||||
schemas := maps.Clone(initialSchemaMap)
|
||||
// Add types from the options. They override the default ones.
|
||||
maps.Copy(schemas, opts.TypeSchemas)
|
||||
s, err := forType(t, map[reflect.Type]bool{}, opts.IgnoreInvalidTypes, schemas)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ForType(%s): %w", t, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Helper to create a *float64 pointer from a value
|
||||
func f64Ptr(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas map[reflect.Type]*Schema) (*Schema, error) {
|
||||
// Follow pointers: the schema for *T is almost the same as for T, except that
|
||||
// an explicit JSON "null" is allowed for the pointer.
|
||||
allowNull := false
|
||||
for t.Kind() == reflect.Pointer {
|
||||
allowNull = true
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
// Check for cycles
|
||||
// User defined types have a name, so we can skip those that are natively defined
|
||||
if t.Name() != "" {
|
||||
if seen[t] {
|
||||
return nil, fmt.Errorf("cycle detected for type %v", t)
|
||||
}
|
||||
seen[t] = true
|
||||
defer delete(seen, t)
|
||||
}
|
||||
|
||||
if s := schemas[t]; s != nil {
|
||||
cloned := s.CloneSchemas()
|
||||
if os.Getenv(debugEnv) != "typeschemasnull=1" && allowNull {
|
||||
if cloned.Type != "" {
|
||||
cloned.Types = []string{"null", cloned.Type}
|
||||
cloned.Type = ""
|
||||
} else if !slices.Contains(cloned.Types, "null") {
|
||||
cloned.Types = append([]string{"null"}, cloned.Types...)
|
||||
}
|
||||
}
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
var (
|
||||
s = new(Schema)
|
||||
err error
|
||||
)
|
||||
|
||||
switch t.Kind() {
|
||||
case reflect.Bool:
|
||||
s.Type = "boolean"
|
||||
|
||||
case reflect.Int, reflect.Int64:
|
||||
s.Type = "integer"
|
||||
|
||||
case reflect.Uint, reflect.Uint64, reflect.Uintptr:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(0)
|
||||
|
||||
case reflect.Int8:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(math.MinInt8)
|
||||
s.Maximum = f64Ptr(math.MaxInt8)
|
||||
|
||||
case reflect.Uint8:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(0)
|
||||
s.Maximum = f64Ptr(math.MaxUint8)
|
||||
|
||||
case reflect.Int16:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(math.MinInt16)
|
||||
s.Maximum = f64Ptr(math.MaxInt16)
|
||||
|
||||
case reflect.Uint16:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(0)
|
||||
s.Maximum = f64Ptr(math.MaxUint16)
|
||||
|
||||
case reflect.Int32:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(math.MinInt32)
|
||||
s.Maximum = f64Ptr(math.MaxInt32)
|
||||
|
||||
case reflect.Uint32:
|
||||
s.Type = "integer"
|
||||
s.Minimum = f64Ptr(0)
|
||||
s.Maximum = f64Ptr(math.MaxUint32)
|
||||
|
||||
case reflect.Float32, reflect.Float64:
|
||||
s.Type = "number"
|
||||
|
||||
case reflect.Interface:
|
||||
// Unrestricted
|
||||
|
||||
case reflect.Map:
|
||||
if t.Key().Kind() != reflect.String && !t.Key().Implements(reflect.TypeFor[encoding.TextMarshaler]()) {
|
||||
if ignore {
|
||||
return nil, nil // ignore
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported map key type %v", t.Key().Kind())
|
||||
}
|
||||
s.Type = "object"
|
||||
s.AdditionalProperties, err = forType(t.Elem(), seen, ignore, schemas)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing map value schema: %v", err)
|
||||
}
|
||||
if ignore && s.AdditionalProperties == nil {
|
||||
// Ignore if the element type is invalid.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
case reflect.Slice, reflect.Array:
|
||||
if os.Getenv(debugEnv) != "typeschemasnull=1" && t.Kind() == reflect.Slice {
|
||||
s.Types = []string{"null", "array"}
|
||||
} else {
|
||||
s.Type = "array"
|
||||
}
|
||||
itemsSchema, err := forType(t.Elem(), seen, ignore, schemas)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing element schema: %v", err)
|
||||
}
|
||||
if itemsSchema == nil {
|
||||
return nil, nil
|
||||
}
|
||||
s.Items = itemsSchema
|
||||
if ignore && s.Items == nil {
|
||||
// Ignore if the element type is invalid.
|
||||
return nil, nil
|
||||
}
|
||||
if t.Kind() == reflect.Array {
|
||||
s.MinItems = Ptr(t.Len())
|
||||
s.MaxItems = Ptr(t.Len())
|
||||
}
|
||||
|
||||
case reflect.String:
|
||||
s.Type = "string"
|
||||
|
||||
case reflect.Struct:
|
||||
s.Type = "object"
|
||||
// no additional properties are allowed
|
||||
s.AdditionalProperties = falseSchema()
|
||||
|
||||
// If skipPath is non-nil, it is path to an anonymous field whose
|
||||
// schema has been replaced by a known schema.
|
||||
var skipPath []int
|
||||
for _, field := range reflect.VisibleFields(t) {
|
||||
if s.Properties == nil {
|
||||
s.Properties = make(map[string]*Schema)
|
||||
}
|
||||
if field.Anonymous {
|
||||
override := schemas[field.Type]
|
||||
if override != nil {
|
||||
// Type must be object, and only properties can be set.
|
||||
if override.Type != "object" {
|
||||
return nil, fmt.Errorf(`custom schema for embedded struct must have type "object", got %q`,
|
||||
override.Type)
|
||||
}
|
||||
// Check that all keywords relevant for objects are absent, except properties.
|
||||
ov := reflect.ValueOf(override).Elem()
|
||||
for _, sfi := range schemaFieldInfos {
|
||||
if sfi.sf.Name == "Type" || sfi.sf.Name == "Properties" {
|
||||
continue
|
||||
}
|
||||
fv := ov.FieldByIndex(sfi.sf.Index)
|
||||
if !fv.IsZero() {
|
||||
return nil, fmt.Errorf(`overrides for embedded fields can have only "Type" and "Properties"; this has %q`, sfi.sf.Name)
|
||||
}
|
||||
}
|
||||
|
||||
skipPath = field.Index
|
||||
keys := make([]string, 0, len(override.Properties))
|
||||
for k := range override.Properties {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for _, name := range keys {
|
||||
if _, ok := s.Properties[name]; !ok {
|
||||
s.Properties[name] = override.Properties[name].CloneSchemas()
|
||||
s.PropertyOrder = append(s.PropertyOrder, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check to see if this field has been promoted from a replaced anonymous
|
||||
// type.
|
||||
if skipPath != nil {
|
||||
skip := false
|
||||
if len(field.Index) >= len(skipPath) {
|
||||
skip = true
|
||||
for i, index := range skipPath {
|
||||
if field.Index[i] != index {
|
||||
// If we're no longer in a subfield.
|
||||
skip = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
} else {
|
||||
// Anonymous fields are followed immediately by their promoted fields.
|
||||
// Once we encounter a field that *isn't* promoted, we can stop
|
||||
// checking.
|
||||
skipPath = nil
|
||||
}
|
||||
}
|
||||
|
||||
info := fieldJSONInfo(field)
|
||||
if info.omit {
|
||||
continue
|
||||
}
|
||||
fs, err := forType(field.Type, seen, ignore, schemas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ignore && fs == nil {
|
||||
// Skip fields of invalid type.
|
||||
continue
|
||||
}
|
||||
if tag, ok := field.Tag.Lookup("jsonschema"); ok {
|
||||
if tag == "" {
|
||||
return nil, fmt.Errorf("empty jsonschema tag on struct field %s.%s", t, field.Name)
|
||||
}
|
||||
if disallowedPrefixRegexp.MatchString(tag) {
|
||||
return nil, fmt.Errorf("tag must not begin with 'WORD=': %q", tag)
|
||||
}
|
||||
fs.Description = tag
|
||||
}
|
||||
s.Properties[info.name] = fs
|
||||
|
||||
s.PropertyOrder = append(s.PropertyOrder, info.name)
|
||||
|
||||
if !info.settings["omitempty"] && !info.settings["omitzero"] {
|
||||
s.Required = append(s.Required, info.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove PropertyOrder duplicates, keeping the last occurrence
|
||||
if len(s.PropertyOrder) > 1 {
|
||||
seen := make(map[string]bool)
|
||||
// Create a slice to hold the cleaned order (capacity = current length)
|
||||
cleaned := make([]string, 0, len(s.PropertyOrder))
|
||||
|
||||
// Iterate backwards
|
||||
for i := len(s.PropertyOrder) - 1; i >= 0; i-- {
|
||||
name := s.PropertyOrder[i]
|
||||
if !seen[name] {
|
||||
cleaned = append(cleaned, name)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Since we collected them backwards, we need to reverse the result
|
||||
// to restore the correct order.
|
||||
slices.Reverse(cleaned)
|
||||
s.PropertyOrder = cleaned
|
||||
}
|
||||
|
||||
default:
|
||||
if ignore {
|
||||
// Ignore.
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("type %v is unsupported by jsonschema", t)
|
||||
}
|
||||
if allowNull && s.Type != "" {
|
||||
s.Types = []string{"null", s.Type}
|
||||
s.Type = ""
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// initialSchemaMap holds types from the standard library that have MarshalJSON methods.
|
||||
var initialSchemaMap = make(map[reflect.Type]*Schema)
|
||||
|
||||
func init() {
|
||||
ss := &Schema{Type: "string"}
|
||||
initialSchemaMap[reflect.TypeFor[time.Time]()] = ss
|
||||
initialSchemaMap[reflect.TypeFor[slog.Level]()] = ss
|
||||
if os.Getenv(debugEnv) == "typeschemasnull=1" {
|
||||
initialSchemaMap[reflect.TypeFor[big.Int]()] = &Schema{Types: []string{"null", "string"}}
|
||||
} else {
|
||||
initialSchemaMap[reflect.TypeFor[big.Int]()] = ss
|
||||
}
|
||||
initialSchemaMap[reflect.TypeFor[big.Rat]()] = ss
|
||||
initialSchemaMap[reflect.TypeFor[big.Float]()] = ss
|
||||
}
|
||||
|
||||
// Disallow jsonschema tag values beginning "WORD=", for future expansion.
|
||||
var disallowedPrefixRegexp = regexp.MustCompile("^[^ \t\n]*=")
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file implements JSON Pointers.
|
||||
// A JSON Pointer is a path that refers to one JSON value within another.
|
||||
// If the path is empty, it refers to the root value.
|
||||
// Otherwise, it is a sequence of slash-prefixed strings, like "/points/1/x",
|
||||
// selecting successive properties (for JSON objects) or items (for JSON arrays).
|
||||
// For example, when applied to this JSON value:
|
||||
// {
|
||||
// "points": [
|
||||
// {"x": 1, "y": 2},
|
||||
// {"x": 3, "y": 4}
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// the JSON Pointer "/points/1/x" refers to the number 3.
|
||||
// See the spec at https://datatracker.ietf.org/doc/html/rfc6901.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
jsonPointerEscaper = strings.NewReplacer("~", "~0", "/", "~1")
|
||||
jsonPointerUnescaper = strings.NewReplacer("~0", "~", "~1", "/")
|
||||
)
|
||||
|
||||
func escapeJSONPointerSegment(s string) string {
|
||||
return jsonPointerEscaper.Replace(s)
|
||||
}
|
||||
|
||||
func unescapeJSONPointerSegment(s string) string {
|
||||
return jsonPointerUnescaper.Replace(s)
|
||||
}
|
||||
|
||||
// parseJSONPointer splits a JSON Pointer into a sequence of segments. It doesn't
|
||||
// convert strings to numbers, because that depends on the traversal: a segment
|
||||
// is treated as a number when applied to an array, but a string when applied to
|
||||
// an object. See section 4 of the spec.
|
||||
func parseJSONPointer(ptr string) (segments []string, err error) {
|
||||
if ptr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if ptr[0] != '/' {
|
||||
return nil, fmt.Errorf("JSON Pointer %q does not begin with '/'", ptr)
|
||||
}
|
||||
// Unlike file paths, consecutive slashes are not coalesced.
|
||||
// Split is nicer than Cut here, because it gets a final "/" right.
|
||||
segments = strings.Split(ptr[1:], "/")
|
||||
if strings.Contains(ptr, "~") {
|
||||
// Undo the simple escaping rules that allow one to include a slash in a segment.
|
||||
for i := range segments {
|
||||
segments[i] = unescapeJSONPointerSegment(segments[i])
|
||||
}
|
||||
}
|
||||
return segments, nil
|
||||
}
|
||||
|
||||
// dereferenceJSONPointer returns the Schema that sptr points to within s,
|
||||
// or an error if none.
|
||||
// This implementation suffices for JSON Schema: pointers are applied only to Schemas,
|
||||
// and refer only to Schemas.
|
||||
func dereferenceJSONPointer(s *Schema, sptr string) (_ *Schema, err error) {
|
||||
defer wrapf(&err, "JSON Pointer %q", sptr)
|
||||
|
||||
segments, err := parseJSONPointer(sptr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := reflect.ValueOf(s)
|
||||
for _, seg := range segments {
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer:
|
||||
v = v.Elem()
|
||||
if !v.IsValid() {
|
||||
return nil, errors.New("navigated to nil reference")
|
||||
}
|
||||
fallthrough // if valid, can only be a pointer to a Schema
|
||||
|
||||
case reflect.Struct:
|
||||
// The segment must refer to a field in a Schema.
|
||||
if v.Type() != reflect.TypeFor[Schema]() {
|
||||
return nil, fmt.Errorf("navigated to non-Schema %s", v.Type())
|
||||
}
|
||||
v = lookupSchemaField(v, seg)
|
||||
if !v.IsValid() {
|
||||
return nil, fmt.Errorf("no schema field %q", seg)
|
||||
}
|
||||
case reflect.Slice, reflect.Array:
|
||||
// The segment must be an integer without leading zeroes that refers to an item in the
|
||||
// slice or array.
|
||||
if seg == "-" {
|
||||
return nil, errors.New("the JSON Pointer array segment '-' is not supported")
|
||||
}
|
||||
if len(seg) > 1 && seg[0] == '0' {
|
||||
return nil, fmt.Errorf("segment %q has leading zeroes", seg)
|
||||
}
|
||||
n, err := strconv.Atoi(seg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid int: %q", seg)
|
||||
}
|
||||
if n < 0 || n >= v.Len() {
|
||||
return nil, fmt.Errorf("index %d is out of bounds for array of length %d", n, v.Len())
|
||||
}
|
||||
v = v.Index(n)
|
||||
// Cannot be invalid.
|
||||
case reflect.Map:
|
||||
// The segment must be a key in the map.
|
||||
v = v.MapIndex(reflect.ValueOf(seg))
|
||||
if !v.IsValid() {
|
||||
return nil, fmt.Errorf("no key %q in map", seg)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("value %s (%s) is not a schema, slice or map", v, v.Type())
|
||||
}
|
||||
}
|
||||
if s, ok := v.Interface().(*Schema); ok {
|
||||
return s, nil
|
||||
}
|
||||
return nil, fmt.Errorf("does not refer to a schema, but to a %s", v.Type())
|
||||
}
|
||||
|
||||
// lookupSchemaField returns the value of the field with the given name in v,
|
||||
// or the zero value if there is no such field or it is not of type Schema or *Schema.
|
||||
func lookupSchemaField(v reflect.Value, name string) reflect.Value {
|
||||
if name == "type" {
|
||||
// The "type" keyword may refer to Type or Types.
|
||||
// At most one will be non-zero.
|
||||
if t := v.FieldByName("Type"); !t.IsZero() {
|
||||
return t
|
||||
}
|
||||
return v.FieldByName("Types")
|
||||
}
|
||||
if name == "items" {
|
||||
// The "items" keyword refers to the "union type" that is either a schema or a schema array.
|
||||
// Implemented using the Items representing the schema and ItemsArray for the schema array.
|
||||
if items := v.FieldByName("Items"); items.IsValid() && !items.IsNil() {
|
||||
return items
|
||||
}
|
||||
return v.FieldByName("ItemsArray")
|
||||
}
|
||||
if name == "dependencies" {
|
||||
// The "dependencies" keyword refers to both DependencyStrings and DependencySchemas maps.
|
||||
// The value on schemaFieldMap is not garanteed to be DependencySchemas which we want
|
||||
// for pointer dereference. So we use FieldByName to get the DependencySchemas map.
|
||||
return v.FieldByName("DependencySchemas")
|
||||
}
|
||||
if sf, ok := schemaFieldMap[name]; ok {
|
||||
return v.FieldByIndex(sf.Index)
|
||||
}
|
||||
return reflect.Value{}
|
||||
}
|
||||
+589
@@ -0,0 +1,589 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file deals with preparing a schema for validation, including various checks,
|
||||
// optimizations, and the resolution of cross-schema references.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A Resolved consists of a [Schema] along with associated information needed to
|
||||
// validate documents against it.
|
||||
// A Resolved has been validated against its meta-schema, and all its references
|
||||
// (the $ref and $dynamicRef keywords) have been resolved to their referenced Schemas.
|
||||
// Call [Schema.Resolve] to obtain a Resolved from a Schema.
|
||||
type Resolved struct {
|
||||
root *Schema
|
||||
draft draft
|
||||
// map from $ids to their schemas
|
||||
resolvedURIs map[string]*Schema
|
||||
// map from schemas to additional info computed during resolution
|
||||
resolvedInfos map[*Schema]*resolvedInfo
|
||||
}
|
||||
|
||||
type draft int
|
||||
|
||||
const (
|
||||
draft7 = iota
|
||||
draft2020
|
||||
)
|
||||
|
||||
func newResolved(s *Schema) *Resolved {
|
||||
return &Resolved{
|
||||
root: s,
|
||||
draft: detectDraft(s),
|
||||
resolvedURIs: map[string]*Schema{},
|
||||
resolvedInfos: map[*Schema]*resolvedInfo{},
|
||||
}
|
||||
}
|
||||
|
||||
// detectDraft inspects the raw JSON to determine the schema version.
|
||||
func detectDraft(s *Schema) draft {
|
||||
// Check explicit $schema declaration
|
||||
switch s.Schema {
|
||||
case draft7SchemaVersion, draft7SecSchemaVersion:
|
||||
return draft7
|
||||
case draft202012SchemaVersion:
|
||||
return draft2020
|
||||
default:
|
||||
// If nothing matches default to the latest supported version.
|
||||
return draft2020
|
||||
}
|
||||
}
|
||||
|
||||
// resolvedInfo holds information specific to a schema that is computed by [Schema.Resolve].
|
||||
type resolvedInfo struct {
|
||||
s *Schema
|
||||
// The JSON Pointer path from the root schema to here.
|
||||
// Used in errors.
|
||||
path string
|
||||
// The schema's base schema.
|
||||
// If the schema is the root or has an ID, its base is itself.
|
||||
// Otherwise, its base is the innermost enclosing schema whose base
|
||||
// is itself.
|
||||
// Intuitively, a base schema is one that can be referred to with a
|
||||
// fragmentless URI.
|
||||
base *Schema
|
||||
// The URI for the schema, if it is the root or has an ID.
|
||||
// Otherwise nil.
|
||||
// Invariants:
|
||||
// s.base.uri != nil.
|
||||
// s.base == s <=> s.uri != nil
|
||||
uri *url.URL
|
||||
// The schema to which Ref refers.
|
||||
resolvedRef *Schema
|
||||
|
||||
// If the schema has a dynamic ref, exactly one of the next two fields
|
||||
// will be non-zero after successful resolution.
|
||||
// The schema to which the dynamic ref refers when it acts lexically.
|
||||
resolvedDynamicRef *Schema
|
||||
// The anchor to look up on the stack when the dynamic ref acts dynamically.
|
||||
dynamicRefAnchor string
|
||||
|
||||
// The following fields are independent of arguments to Schema.Resolved,
|
||||
// so they could live on the Schema. We put them here for simplicity.
|
||||
|
||||
// The set of required properties.
|
||||
isRequired map[string]bool
|
||||
|
||||
// Compiled regexps.
|
||||
pattern *regexp.Regexp
|
||||
patternProperties map[*regexp.Regexp]*Schema
|
||||
|
||||
// Map from anchors to subschemas.
|
||||
anchors map[string]anchorInfo
|
||||
}
|
||||
|
||||
// Schema returns the schema that was resolved.
|
||||
// It must not be modified.
|
||||
func (r *Resolved) Schema() *Schema { return r.root }
|
||||
|
||||
// schemaString returns a short string describing the schema.
|
||||
func (r *Resolved) schemaString(s *Schema) string {
|
||||
if s.ID != "" {
|
||||
return s.ID
|
||||
}
|
||||
info := r.resolvedInfos[s]
|
||||
if info.path != "" {
|
||||
return info.path
|
||||
}
|
||||
return "<anonymous schema>"
|
||||
}
|
||||
|
||||
// A Loader reads and unmarshals the schema at uri, if any.
|
||||
type Loader func(uri *url.URL) (*Schema, error)
|
||||
|
||||
// ResolveOptions are options for [Schema.Resolve].
|
||||
type ResolveOptions struct {
|
||||
// BaseURI is the URI relative to which the root schema should be resolved.
|
||||
// If non-empty, must be an absolute URI (one that starts with a scheme).
|
||||
// It is resolved (in the URI sense; see [url.ResolveReference]) with root's
|
||||
// $id property.
|
||||
// If the resulting URI is not absolute, then the schema cannot contain
|
||||
// relative URI references.
|
||||
BaseURI string
|
||||
// Loader loads schemas that are referred to by a $ref but are not under the
|
||||
// root schema (remote references).
|
||||
// If nil, resolving a remote reference will return an error.
|
||||
Loader Loader
|
||||
// ValidateDefaults determines whether to validate values of "default" keywords
|
||||
// against their schemas.
|
||||
// The [JSON Schema specification] does not require this, but it is recommended
|
||||
// if defaults will be used.
|
||||
//
|
||||
// [JSON Schema specification]: https://json-schema.org/understanding-json-schema/reference/annotations
|
||||
ValidateDefaults bool
|
||||
}
|
||||
|
||||
// Resolve resolves all references within the schema and performs other tasks that
|
||||
// prepare the schema for validation.
|
||||
// If opts is nil, the default values are used.
|
||||
// The schema must not be changed after Resolve is called.
|
||||
// The same schema may be resolved multiple times.
|
||||
func (root *Schema) Resolve(opts *ResolveOptions) (*Resolved, error) {
|
||||
// There are up to five steps required to prepare a schema to validate.
|
||||
// 1. Load: read the schema from somewhere and unmarshal it.
|
||||
// This schema (root) may have been loaded or created in memory, but other schemas that
|
||||
// come into the picture in step 4 will be loaded by the given loader.
|
||||
// 2. Check: validate the schema against a meta-schema, and perform other well-formedness checks.
|
||||
// Precompute some values along the way.
|
||||
// 3. Resolve URIs: determine the base URI of the root and all its subschemas, and
|
||||
// resolve (in the URI sense) all identifiers and anchors with their bases. This step results
|
||||
// in a map from URIs to schemas within root.
|
||||
// 4. Resolve references: all refs in the schemas are replaced with the schema they refer to.
|
||||
// 5. (Optional.) If opts.ValidateDefaults is true, validate the defaults.
|
||||
r := &resolver{loaded: map[string]*Resolved{}}
|
||||
if opts != nil {
|
||||
r.opts = *opts
|
||||
}
|
||||
var base *url.URL
|
||||
if r.opts.BaseURI == "" {
|
||||
base = &url.URL{} // so we can call ResolveReference on it
|
||||
} else {
|
||||
var err error
|
||||
base, err = url.Parse(r.opts.BaseURI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing base URI: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.opts.Loader == nil {
|
||||
r.opts.Loader = func(uri *url.URL) (*Schema, error) {
|
||||
return nil, errors.New("cannot resolve remote schemas: no loader passed to Schema.Resolve")
|
||||
}
|
||||
}
|
||||
|
||||
resolved, err := r.resolve(root, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.opts.ValidateDefaults {
|
||||
if err := resolved.validateDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// TODO: before we return, throw away anything we don't need for validation.
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// A resolver holds the state for resolution.
|
||||
type resolver struct {
|
||||
opts ResolveOptions
|
||||
// A cache of loaded and partly resolved schemas. (They may not have had their
|
||||
// refs resolved.) The cache ensures that the loader will never be called more
|
||||
// than once with the same URI, and that reference cycles are handled properly.
|
||||
loaded map[string]*Resolved
|
||||
}
|
||||
|
||||
func (r *resolver) resolve(s *Schema, baseURI *url.URL) (*Resolved, error) {
|
||||
if baseURI.Fragment != "" {
|
||||
return nil, fmt.Errorf("base URI %s must not have a fragment", baseURI)
|
||||
}
|
||||
rs := newResolved(s)
|
||||
|
||||
if err := s.check(rs.resolvedInfos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := resolveURIs(rs, baseURI); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remember the schema by both the URI we loaded it from and its canonical name,
|
||||
// which may differ if the schema has an $id.
|
||||
// We must set the map before calling resolveRefs, or ref cycles will cause unbounded recursion.
|
||||
r.loaded[baseURI.String()] = rs
|
||||
r.loaded[rs.resolvedInfos[s].uri.String()] = rs
|
||||
|
||||
if err := r.resolveRefs(rs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func (root *Schema) check(infos map[*Schema]*resolvedInfo) error {
|
||||
// Check for structural validity. Do this first and fail fast:
|
||||
// bad structure will cause other code to panic.
|
||||
if err := root.checkStructure(infos); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
report := func(err error) { errs = append(errs, err) }
|
||||
|
||||
for ss := range root.all() {
|
||||
ss.checkLocal(report, infos)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// checkStructure verifies that root and its subschemas form a tree.
|
||||
// It also assigns each schema a unique path, to improve error messages.
|
||||
func (root *Schema) checkStructure(infos map[*Schema]*resolvedInfo) error {
|
||||
assert(len(infos) == 0, "non-empty infos")
|
||||
|
||||
var check func(reflect.Value, []byte) error
|
||||
check = func(v reflect.Value, path []byte) error {
|
||||
// For the purpose of error messages, the root schema has path "root"
|
||||
// and other schemas' paths are their JSON Pointer from the root.
|
||||
p := "root"
|
||||
if len(path) > 0 {
|
||||
p = string(path)
|
||||
}
|
||||
s := v.Interface().(*Schema)
|
||||
if s == nil {
|
||||
return fmt.Errorf("jsonschema: schema at %s is nil", p)
|
||||
}
|
||||
if info, ok := infos[s]; ok {
|
||||
// We've seen s before.
|
||||
// The schema graph at root is not a tree, but it needs to
|
||||
// be because a schema's base must be unique.
|
||||
// A cycle would also put Schema.all into an infinite recursion.
|
||||
return fmt.Errorf("jsonschema: schemas at %s do not form a tree; %s appears more than once (also at %s)",
|
||||
root, info.path, p)
|
||||
}
|
||||
infos[s] = &resolvedInfo{s: s, path: p}
|
||||
|
||||
for _, info := range schemaFieldInfos {
|
||||
fv := v.Elem().FieldByIndex(info.sf.Index)
|
||||
switch info.sf.Type {
|
||||
case schemaType:
|
||||
// A field that contains an individual schema.
|
||||
// A nil is valid: it just means the field isn't present.
|
||||
if !fv.IsNil() {
|
||||
if err := check(fv, fmt.Appendf(path, "/%s", info.jsonName)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case schemaSliceType:
|
||||
for i := range fv.Len() {
|
||||
if err := check(fv.Index(i), fmt.Appendf(path, "/%s/%d", info.jsonName, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case schemaMapType:
|
||||
iter := fv.MapRange()
|
||||
for iter.Next() {
|
||||
key := escapeJSONPointerSegment(iter.Key().String())
|
||||
if err := check(iter.Value(), fmt.Appendf(path, "/%s/%s", info.jsonName, key)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return check(reflect.ValueOf(root), make([]byte, 0, 256))
|
||||
}
|
||||
|
||||
// checkLocal checks s for validity, independently of other schemas it may refer to.
|
||||
// Since checking a regexp involves compiling it, checkLocal saves those compiled regexps
|
||||
// in the schema for later use.
|
||||
// It appends the errors it finds to errs.
|
||||
func (s *Schema) checkLocal(report func(error), infos map[*Schema]*resolvedInfo) {
|
||||
addf := func(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
report(fmt.Errorf("jsonschema.Schema: %s: %s", s, msg))
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
addf("nil subschema")
|
||||
return
|
||||
}
|
||||
if err := s.basicChecks(); err != nil {
|
||||
report(err)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: validate the schema's properties,
|
||||
// ideally by jsonschema-validating it against the meta-schema.
|
||||
|
||||
// Some properties are present so that Schemas can round-trip, but we do not
|
||||
// validate them.
|
||||
// Currently, it's just the $vocabulary property.
|
||||
// As a special case, we can validate the 2020-12 meta-schema.
|
||||
if s.Vocabulary != nil && s.Schema != draft202012SchemaVersion {
|
||||
addf("cannot validate a schema with $vocabulary")
|
||||
}
|
||||
|
||||
info := infos[s]
|
||||
|
||||
// Check and compile regexps.
|
||||
if s.Pattern != "" {
|
||||
re, err := regexp.Compile(s.Pattern)
|
||||
if err != nil {
|
||||
addf("pattern: %v", err)
|
||||
} else {
|
||||
info.pattern = re
|
||||
}
|
||||
}
|
||||
if len(s.PatternProperties) > 0 {
|
||||
info.patternProperties = map[*regexp.Regexp]*Schema{}
|
||||
for reString, subschema := range s.PatternProperties {
|
||||
re, err := regexp.Compile(reString)
|
||||
if err != nil {
|
||||
addf("patternProperties[%q]: %v", reString, err)
|
||||
continue
|
||||
}
|
||||
info.patternProperties[re] = subschema
|
||||
}
|
||||
}
|
||||
|
||||
// Build a set of required properties, to avoid quadratic behavior when validating
|
||||
// a struct.
|
||||
if len(s.Required) > 0 {
|
||||
info.isRequired = map[string]bool{}
|
||||
for _, r := range s.Required {
|
||||
info.isRequired[r] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveURIs resolves the ids and anchors in all the schemas of root, relative
|
||||
// to baseURI.
|
||||
// See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2, section
|
||||
// 8.2.1.
|
||||
//
|
||||
// Every schema has a base URI and a parent base URI.
|
||||
//
|
||||
// The parent base URI is the base URI of the lexically enclosing schema, or for
|
||||
// a root schema, the URI it was loaded from or the one supplied to [Schema.Resolve].
|
||||
//
|
||||
// If the schema has no $id property, the base URI of a schema is that of its parent.
|
||||
// If the schema does have an $id, it must be a URI, possibly relative. The schema's
|
||||
// base URI is the $id resolved (in the sense of [url.URL.ResolveReference]) against
|
||||
// the parent base.
|
||||
//
|
||||
// As an example, consider this schema loaded from http://a.com/root.json (quotes omitted):
|
||||
//
|
||||
// {
|
||||
// allOf: [
|
||||
// {$id: "sub1.json", minLength: 5},
|
||||
// {$id: "http://b.com", minimum: 10},
|
||||
// {not: {maximum: 20}}
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// The base URIs are as follows. Schema locations are expressed in the JSON Pointer notation.
|
||||
//
|
||||
// schema base URI
|
||||
// root http://a.com/root.json
|
||||
// allOf/0 http://a.com/sub1.json
|
||||
// allOf/1 http://b.com (absolute $id; doesn't matter that it's not under the loaded URI)
|
||||
// allOf/2 http://a.com/root.json (inherited from parent)
|
||||
// allOf/2/not http://a.com/root.json (inherited from parent)
|
||||
func resolveURIs(rs *Resolved, baseURI *url.URL) error {
|
||||
// Anchors and dynamic anchors are URI fragments that are scoped to their base.
|
||||
// We treat them as keys in a map stored within the schema.
|
||||
setAnchor := func(s *Schema, baseInfo *resolvedInfo, anchor string, dynamic bool) error {
|
||||
if anchor != "" {
|
||||
if _, ok := baseInfo.anchors[anchor]; ok {
|
||||
return fmt.Errorf("duplicate anchor %q in %s", anchor, baseInfo.uri)
|
||||
}
|
||||
if baseInfo.anchors == nil {
|
||||
baseInfo.anchors = map[string]anchorInfo{}
|
||||
}
|
||||
baseInfo.anchors[anchor] = anchorInfo{s, dynamic}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var resolve func(s, base *Schema) error
|
||||
resolve = func(s, base *Schema) error {
|
||||
info := rs.resolvedInfos[s]
|
||||
baseInfo := rs.resolvedInfos[base]
|
||||
|
||||
// ids are scoped to the root.
|
||||
if s.ID != "" {
|
||||
// draft-7 specific
|
||||
// https://json-schema.org/draft-07/draft-handrews-json-schema-01#rfc.section.8.3
|
||||
// "All other properties in a "$ref" object MUST be ignored."
|
||||
ignore := rs.draft == draft7 && s.Ref != ""
|
||||
if !ignore {
|
||||
// A non-empty ID establishes a new base.
|
||||
idURI, err := url.Parse(s.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rs.draft == draft2020 && idURI.Fragment != "" {
|
||||
return fmt.Errorf("$id %s must not have a fragment", s.ID)
|
||||
}
|
||||
if rs.draft == draft7 && idURI.Fragment != "" {
|
||||
// anchor did not exist in draft 7, id was used for base uri and document navigation
|
||||
// https://json-schema.org/draft-07/draft-handrews-json-schema-01#id-keyword
|
||||
anchorName := strings.TrimPrefix(s.ID, "#")
|
||||
setAnchor(s, baseInfo, anchorName, false)
|
||||
} else {
|
||||
// The base URI for this schema is its $id resolved against the parent base.
|
||||
info.uri = baseInfo.uri.ResolveReference(idURI)
|
||||
if !info.uri.IsAbs() {
|
||||
return fmt.Errorf("$id %s does not resolve to an absolute URI (base is %q)", s.ID, baseInfo.uri)
|
||||
}
|
||||
rs.resolvedURIs[info.uri.String()] = s
|
||||
base = s // needed for anchors
|
||||
baseInfo = rs.resolvedInfos[base]
|
||||
}
|
||||
}
|
||||
}
|
||||
info.base = base
|
||||
if rs.draft == draft2020 {
|
||||
setAnchor(s, baseInfo, s.Anchor, false)
|
||||
setAnchor(s, baseInfo, s.DynamicAnchor, true)
|
||||
}
|
||||
|
||||
for c := range s.children() {
|
||||
if err := resolve(c, base); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set the root URI to the base for now. If the root has an $id, this will change.
|
||||
rs.resolvedInfos[rs.root].uri = baseURI
|
||||
// The original base, even if changed, is still a valid way to refer to the root.
|
||||
rs.resolvedURIs[baseURI.String()] = rs.root
|
||||
|
||||
return resolve(rs.root, rs.root)
|
||||
}
|
||||
|
||||
// resolveRefs replaces every ref in the schemas with the schema it refers to.
|
||||
// A reference that doesn't resolve within the schema may refer to some other schema
|
||||
// that needs to be loaded.
|
||||
func (r *resolver) resolveRefs(rs *Resolved) error {
|
||||
for s := range rs.root.all() {
|
||||
info := rs.resolvedInfos[s]
|
||||
if s.Ref != "" {
|
||||
refSchema, _, err := r.resolveRef(rs, s, s.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Whether or not the anchor referred to by $ref fragment is dynamic,
|
||||
// the ref still treats it lexically.
|
||||
info.resolvedRef = refSchema
|
||||
}
|
||||
if s.DynamicRef != "" {
|
||||
refSchema, frag, err := r.resolveRef(rs, s, s.DynamicRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if frag != "" {
|
||||
// The dynamic ref's fragment points to a dynamic anchor.
|
||||
// We must resolve the fragment at validation time.
|
||||
info.dynamicRefAnchor = frag
|
||||
} else {
|
||||
// There is no dynamic anchor in the lexically referenced schema,
|
||||
// so the dynamic ref behaves like a lexical ref.
|
||||
info.resolvedDynamicRef = refSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRef resolves the reference ref, which is either s.Ref or s.DynamicRef.
|
||||
func (r *resolver) resolveRef(rs *Resolved, s *Schema, ref string) (_ *Schema, dynamicFragment string, err error) {
|
||||
refURI, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// URI-resolve the ref against the current base URI to get a complete URI.
|
||||
base := rs.resolvedInfos[s].base
|
||||
refURI = rs.resolvedInfos[base].uri.ResolveReference(refURI)
|
||||
// The non-fragment part of a ref URI refers to the base URI of some schema.
|
||||
// This part is the same for dynamic refs too: their non-fragment part resolves
|
||||
// lexically.
|
||||
u := *refURI
|
||||
u.Fragment = ""
|
||||
fraglessRefURI := &u
|
||||
// Look it up locally.
|
||||
referencedSchema := rs.resolvedURIs[fraglessRefURI.String()]
|
||||
if referencedSchema == nil {
|
||||
// The schema is remote. Maybe we've already loaded it.
|
||||
// We assume that the non-fragment part of refURI refers to a top-level schema
|
||||
// document. That is, we don't support the case exemplified by
|
||||
// http://foo.com/bar.json/baz, where the document is in bar.json and
|
||||
// the reference points to a subschema within it.
|
||||
// TODO: support that case.
|
||||
if lrs := r.loaded[fraglessRefURI.String()]; lrs != nil {
|
||||
referencedSchema = lrs.root
|
||||
} else {
|
||||
// Try to load the schema.
|
||||
ls, err := r.opts.Loader(fraglessRefURI)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("loading %s: %w", fraglessRefURI, err)
|
||||
}
|
||||
// Check if referenced schema has $schema defined. If not it should inherit the resolved
|
||||
if ls.Schema == "" {
|
||||
ls.Schema = s.Schema
|
||||
}
|
||||
lrs, err := r.resolve(ls, fraglessRefURI)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
referencedSchema = lrs.root
|
||||
assert(referencedSchema != nil, "nil referenced schema")
|
||||
// Copy the resolvedInfos from lrs into rs, without overwriting
|
||||
// (hence we can't use maps.Insert).
|
||||
for s, i := range lrs.resolvedInfos {
|
||||
if rs.resolvedInfos[s] == nil {
|
||||
rs.resolvedInfos[s] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frag := refURI.Fragment
|
||||
// Look up frag in refSchema.
|
||||
// frag is either a JSON Pointer or the name of an anchor.
|
||||
// A JSON Pointer is either the empty string or begins with a '/',
|
||||
// whereas anchors are always non-empty strings that don't contain slashes.
|
||||
if frag != "" && !strings.HasPrefix(frag, "/") {
|
||||
resInfo := rs.resolvedInfos[referencedSchema]
|
||||
info, found := resInfo.anchors[frag]
|
||||
|
||||
if !found {
|
||||
return nil, "", fmt.Errorf("no anchor %q in %s", frag, s)
|
||||
}
|
||||
if info.dynamic {
|
||||
dynamicFragment = frag
|
||||
}
|
||||
return info.schema, dynamicFragment, nil
|
||||
}
|
||||
// frag is a JSON Pointer.
|
||||
s, err = dereferenceJSONPointer(referencedSchema, frag)
|
||||
return s, "", err
|
||||
}
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// A Schema is a JSON schema object.
|
||||
// It supports both draft-07 and the 2020-12 draft specifications:
|
||||
// - Draft-07: https://json-schema.org/draft-07/draft-handrews-json-schema-01
|
||||
// and https://json-schema.org/draft-07/draft-handrews-json-schema-validation-01
|
||||
// - Draft 2020-12: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01
|
||||
// and https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01
|
||||
//
|
||||
// A Schema value may have non-zero values for more than one field:
|
||||
// all relevant non-zero fields are used for validation.
|
||||
// There is one exception to provide more Go type-safety: the Type and Types fields
|
||||
// are mutually exclusive.
|
||||
//
|
||||
// Since this struct is a Go representation of a JSON value, it inherits JSON's
|
||||
// distinction between nil and empty. Nil slices and maps are considered absent,
|
||||
// but empty ones are present and affect validation. For example,
|
||||
//
|
||||
// Schema{Enum: nil}
|
||||
//
|
||||
// is equivalent to an empty schema, so it validates every instance. But
|
||||
//
|
||||
// Schema{Enum: []any{}}
|
||||
//
|
||||
// requires equality to some slice element, so it vacuously rejects every instance.
|
||||
type Schema struct {
|
||||
// core
|
||||
ID string `json:"$id,omitempty"`
|
||||
Schema string `json:"$schema,omitempty"`
|
||||
Ref string `json:"$ref,omitempty"`
|
||||
Comment string `json:"$comment,omitempty"`
|
||||
Defs map[string]*Schema `json:"$defs,omitempty"`
|
||||
Definitions map[string]*Schema `json:"definitions,omitempty"`
|
||||
|
||||
// split draft 7 Dependencies into DependencySchemas and DependencyStrings
|
||||
DependencySchemas map[string]*Schema `json:"-"`
|
||||
DependencyStrings map[string][]string `json:"-"`
|
||||
|
||||
Anchor string `json:"$anchor,omitempty"`
|
||||
DynamicAnchor string `json:"$dynamicAnchor,omitempty"`
|
||||
DynamicRef string `json:"$dynamicRef,omitempty"`
|
||||
Vocabulary map[string]bool `json:"$vocabulary,omitempty"`
|
||||
|
||||
// metadata
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Default json.RawMessage `json:"default,omitempty"`
|
||||
Deprecated bool `json:"deprecated,omitempty"`
|
||||
ReadOnly bool `json:"readOnly,omitempty"`
|
||||
WriteOnly bool `json:"writeOnly,omitempty"`
|
||||
Examples []any `json:"examples,omitempty"`
|
||||
|
||||
// validation
|
||||
// Use Type for a single type, or Types for multiple types; never both.
|
||||
Type string `json:"-"`
|
||||
Types []string `json:"-"`
|
||||
Enum []any `json:"enum,omitempty"`
|
||||
// Const is *any because a JSON null (Go nil) is a valid value.
|
||||
Const *any `json:"const,omitempty"`
|
||||
MultipleOf *float64 `json:"multipleOf,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
ExclusiveMinimum *float64 `json:"exclusiveMinimum,omitempty"`
|
||||
ExclusiveMaximum *float64 `json:"exclusiveMaximum,omitempty"`
|
||||
MinLength *int `json:"minLength,omitempty"`
|
||||
MaxLength *int `json:"maxLength,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
|
||||
// arrays
|
||||
PrefixItems []*Schema `json:"prefixItems,omitempty"`
|
||||
Items *Schema `json:"-"`
|
||||
ItemsArray []*Schema `json:"-"`
|
||||
MinItems *int `json:"minItems,omitempty"`
|
||||
MaxItems *int `json:"maxItems,omitempty"`
|
||||
AdditionalItems *Schema `json:"additionalItems,omitempty"`
|
||||
UniqueItems bool `json:"uniqueItems,omitempty"`
|
||||
Contains *Schema `json:"contains,omitempty"`
|
||||
MinContains *int `json:"minContains,omitempty"` // *int, not int: default is 1, not 0
|
||||
MaxContains *int `json:"maxContains,omitempty"`
|
||||
UnevaluatedItems *Schema `json:"unevaluatedItems,omitempty"`
|
||||
|
||||
// objects
|
||||
MinProperties *int `json:"minProperties,omitempty"`
|
||||
MaxProperties *int `json:"maxProperties,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
DependentRequired map[string][]string `json:"dependentRequired,omitempty"`
|
||||
Properties map[string]*Schema `json:"properties,omitempty"`
|
||||
PatternProperties map[string]*Schema `json:"patternProperties,omitempty"`
|
||||
AdditionalProperties *Schema `json:"additionalProperties,omitempty"`
|
||||
PropertyNames *Schema `json:"propertyNames,omitempty"`
|
||||
UnevaluatedProperties *Schema `json:"unevaluatedProperties,omitempty"`
|
||||
|
||||
// logic
|
||||
AllOf []*Schema `json:"allOf,omitempty"`
|
||||
AnyOf []*Schema `json:"anyOf,omitempty"`
|
||||
OneOf []*Schema `json:"oneOf,omitempty"`
|
||||
Not *Schema `json:"not,omitempty"`
|
||||
|
||||
// conditional
|
||||
If *Schema `json:"if,omitempty"`
|
||||
Then *Schema `json:"then,omitempty"`
|
||||
Else *Schema `json:"else,omitempty"`
|
||||
DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty"`
|
||||
|
||||
// other
|
||||
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.8
|
||||
ContentEncoding string `json:"contentEncoding,omitempty"`
|
||||
ContentMediaType string `json:"contentMediaType,omitempty"`
|
||||
ContentSchema *Schema `json:"contentSchema,omitempty"`
|
||||
|
||||
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7
|
||||
Format string `json:"format,omitempty"`
|
||||
|
||||
// Extra allows for additional keywords beyond those specified.
|
||||
Extra map[string]any `json:"-"`
|
||||
|
||||
// PropertyOrder records the ordering of properties for JSON rendering.
|
||||
//
|
||||
// During [For], PropertyOrder is set to the field order,
|
||||
// if the type used for inference is a struct.
|
||||
//
|
||||
// If PropertyOrder is set, it controls the relative ordering of properties in [Schema.MarshalJSON].
|
||||
// The rendered JSON first lists any properties that appear in the PropertyOrder slice in the order
|
||||
// they appear, followed by all other properties that do not appear in the PropertyOrder slice in an
|
||||
// undefined but deterministic order.
|
||||
PropertyOrder []string `json:"-"`
|
||||
}
|
||||
|
||||
// falseSchema returns a new Schema tree that fails to validate any value.
|
||||
func falseSchema() *Schema {
|
||||
return &Schema{Not: &Schema{}}
|
||||
}
|
||||
|
||||
// anchorInfo records the subschema to which an anchor refers, and whether
|
||||
// the anchor keyword is $anchor or $dynamicAnchor.
|
||||
type anchorInfo struct {
|
||||
schema *Schema
|
||||
dynamic bool
|
||||
}
|
||||
|
||||
// String returns a short description of the schema.
|
||||
func (s *Schema) String() string {
|
||||
if s.ID != "" {
|
||||
return s.ID
|
||||
}
|
||||
if a := cmp.Or(s.Anchor, s.DynamicAnchor); a != "" {
|
||||
return fmt.Sprintf("anchor %s", a)
|
||||
}
|
||||
return "<anonymous schema>"
|
||||
}
|
||||
|
||||
// CloneSchemas returns a copy of s.
|
||||
// The copy is shallow except for sub-schemas, which are themelves copied with CloneSchemas.
|
||||
// This allows both s and s.CloneSchemas() to appear as sub-schemas of the same parent.
|
||||
func (s *Schema) CloneSchemas() *Schema {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s2 := *s
|
||||
v := reflect.ValueOf(&s2)
|
||||
for _, info := range schemaFieldInfos {
|
||||
fv := v.Elem().FieldByIndex(info.sf.Index)
|
||||
switch info.sf.Type {
|
||||
case schemaType:
|
||||
sscss := fv.Interface().(*Schema)
|
||||
fv.Set(reflect.ValueOf(sscss.CloneSchemas()))
|
||||
|
||||
case schemaSliceType:
|
||||
slice := fv.Interface().([]*Schema)
|
||||
slice = slices.Clone(slice)
|
||||
for i, ss := range slice {
|
||||
slice[i] = ss.CloneSchemas()
|
||||
}
|
||||
fv.Set(reflect.ValueOf(slice))
|
||||
|
||||
case schemaMapType:
|
||||
m := fv.Interface().(map[string]*Schema)
|
||||
m = maps.Clone(m)
|
||||
for k, ss := range m {
|
||||
m[k] = ss.CloneSchemas()
|
||||
}
|
||||
fv.Set(reflect.ValueOf(m))
|
||||
|
||||
}
|
||||
}
|
||||
return &s2
|
||||
}
|
||||
|
||||
func (s *Schema) basicChecks() error {
|
||||
if s.Type != "" && s.Types != nil {
|
||||
return errors.New("both Type and Types are set; at most one should be")
|
||||
}
|
||||
if s.Defs != nil && s.Definitions != nil {
|
||||
return errors.New("both Defs and Definitions are set; at most one should be")
|
||||
}
|
||||
if s.Items != nil && s.ItemsArray != nil {
|
||||
return errors.New("both Items and ItemsArray are set; at most one should be")
|
||||
}
|
||||
propertyOrderSeen := make(map[string]bool)
|
||||
for _, val := range s.PropertyOrder {
|
||||
if _, ok := propertyOrderSeen[val]; ok {
|
||||
// Duplicate found
|
||||
return fmt.Errorf("property order slice cannot contain duplicate entries, found duplicate %q", val)
|
||||
}
|
||||
propertyOrderSeen[val] = true
|
||||
}
|
||||
|
||||
for key := range s.DependencySchemas {
|
||||
// Check if the key exists in the dependency strings map
|
||||
if _, exists := s.DependencyStrings[key]; exists {
|
||||
return fmt.Errorf("dependency key %q cannot be defined as both a schema and a string array", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type schemaWithoutMethods Schema // doesn't implement json.{Unm,M}arshaler
|
||||
|
||||
func (s Schema) MarshalJSON() ([]byte, error) {
|
||||
// NOTE: Use a value receiver here to avoid the encoding/json bugs
|
||||
// described in golang/go#22967, golang/go#33993, and golang/go#55890.
|
||||
// With a pointer receiver, MarshalJSON is only called for Schema in
|
||||
// some cases (for example when the field value is addressable, or not
|
||||
// stored as a map value), which leads to inconsistent JSON encoding.
|
||||
// A value receiver makes Schema itself implement json.Marshaler and
|
||||
// ensures that encoding/json always calls this method.
|
||||
if err := s.basicChecks(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Marshal either Type or Types as "type".
|
||||
var typ any
|
||||
switch {
|
||||
case s.Type != "":
|
||||
typ = s.Type
|
||||
case s.Types != nil:
|
||||
typ = s.Types
|
||||
}
|
||||
|
||||
var items any
|
||||
switch {
|
||||
case s.Items != nil:
|
||||
items = s.Items
|
||||
case s.ItemsArray != nil:
|
||||
items = s.ItemsArray
|
||||
}
|
||||
|
||||
var dep map[string]any
|
||||
size := len(s.DependencySchemas) + len(s.DependencyStrings)
|
||||
if size > 0 {
|
||||
dep = make(map[string]any, size)
|
||||
for k, v := range s.DependencySchemas {
|
||||
dep[k] = v
|
||||
}
|
||||
for k, v := range s.DependencyStrings {
|
||||
dep[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
ms := struct {
|
||||
Type any `json:"type,omitempty"`
|
||||
Properties json.Marshaler `json:"properties,omitempty"`
|
||||
Dependencies map[string]any `json:"dependencies,omitempty"`
|
||||
Items any `json:"items,omitempty"`
|
||||
*schemaWithoutMethods
|
||||
}{
|
||||
Type: typ,
|
||||
Dependencies: dep,
|
||||
Items: items,
|
||||
schemaWithoutMethods: (*schemaWithoutMethods)(&s),
|
||||
}
|
||||
// Marshal properties, even if the empty map (but not nil).
|
||||
if s.Properties != nil {
|
||||
ms.Properties = orderedProperties{
|
||||
props: s.Properties,
|
||||
order: s.PropertyOrder,
|
||||
}
|
||||
}
|
||||
|
||||
bs, err := marshalStructWithMap(&ms, "Extra")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Marshal {} as true and {"not": {}} as false.
|
||||
// It is wasteful to do this here instead of earlier, but much easier.
|
||||
switch {
|
||||
case bytes.Equal(bs, []byte(`{}`)):
|
||||
bs = []byte("true")
|
||||
case bytes.Equal(bs, []byte(`{"not":true}`)):
|
||||
bs = []byte("false")
|
||||
}
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// orderedProperties is a helper to marshal the properties map in a specific order.
|
||||
type orderedProperties struct {
|
||||
props map[string]*Schema
|
||||
order []string
|
||||
}
|
||||
|
||||
func (op orderedProperties) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte('{')
|
||||
|
||||
first := true
|
||||
processed := make(map[string]bool, len(op.props))
|
||||
|
||||
// Helper closure to write "key": value
|
||||
writeEntry := func(key string, val *Schema) error {
|
||||
if !first {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
first = false
|
||||
|
||||
// Marshal the Key
|
||||
keyBytes, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf.Write(keyBytes)
|
||||
|
||||
buf.WriteByte(':')
|
||||
|
||||
// Marshal the Value
|
||||
valBytes, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf.Write(valBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write keys explicitly listed in PropertyOrder
|
||||
for _, name := range op.order {
|
||||
if prop, ok := op.props[name]; ok {
|
||||
if err := writeEntry(name, prop); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processed[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Write any remaining keys
|
||||
var remaining []string
|
||||
for name := range op.props {
|
||||
if !processed[name] {
|
||||
remaining = append(remaining, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the slice alphabetically
|
||||
slices.Sort(remaining)
|
||||
|
||||
for _, name := range remaining {
|
||||
if err := writeEntry(name, op.props[name]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteByte('}')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *Schema) UnmarshalJSON(data []byte) error {
|
||||
// A JSON boolean is a valid schema.
|
||||
var b bool
|
||||
if err := json.Unmarshal(data, &b); err == nil {
|
||||
if b {
|
||||
// true is the empty schema, which validates everything.
|
||||
*s = Schema{}
|
||||
} else {
|
||||
// false is the schema that validates nothing.
|
||||
*s = *falseSchema()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ms := struct {
|
||||
Type json.RawMessage `json:"type,omitempty"`
|
||||
Dependencies map[string]json.RawMessage `json:"dependencies,omitempty"`
|
||||
Items json.RawMessage `json:"items,omitempty"`
|
||||
Const json.RawMessage `json:"const,omitempty"`
|
||||
MinLength *integer `json:"minLength,omitempty"`
|
||||
MaxLength *integer `json:"maxLength,omitempty"`
|
||||
MinItems *integer `json:"minItems,omitempty"`
|
||||
MaxItems *integer `json:"maxItems,omitempty"`
|
||||
MinProperties *integer `json:"minProperties,omitempty"`
|
||||
MaxProperties *integer `json:"maxProperties,omitempty"`
|
||||
MinContains *integer `json:"minContains,omitempty"`
|
||||
MaxContains *integer `json:"maxContains,omitempty"`
|
||||
|
||||
*schemaWithoutMethods
|
||||
}{
|
||||
schemaWithoutMethods: (*schemaWithoutMethods)(s),
|
||||
}
|
||||
if err := unmarshalStructWithMap(data, &ms, "Extra"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Unmarshal "type" as either Type or Types.
|
||||
var err error
|
||||
if len(ms.Type) > 0 {
|
||||
switch ms.Type[0] {
|
||||
case '"':
|
||||
err = json.Unmarshal(ms.Type, &s.Type)
|
||||
case '[':
|
||||
err = json.Unmarshal(ms.Type, &s.Types)
|
||||
default:
|
||||
err = fmt.Errorf(`invalid value for "type": %q`, ms.Type)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unmarshal "items" as either Items or ItemsArray.
|
||||
if len(ms.Items) > 0 {
|
||||
switch ms.Items[0] {
|
||||
case '[':
|
||||
var schemas []*Schema
|
||||
err = json.Unmarshal(ms.Items, &schemas)
|
||||
s.ItemsArray = schemas
|
||||
default:
|
||||
var schema Schema
|
||||
err = json.Unmarshal(ms.Items, &schema)
|
||||
s.Items = &schema
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unmarshal "Dependencies" values as either string arrays or schemas
|
||||
// and assign them to specific map DependencySchemas or DependencyStrings.
|
||||
for k, v := range ms.Dependencies {
|
||||
if len(v) > 0 {
|
||||
switch v[0] {
|
||||
case '[':
|
||||
var dstrings []string
|
||||
err = json.Unmarshal(v, &dstrings)
|
||||
if s.DependencyStrings == nil {
|
||||
s.DependencyStrings = make(map[string][]string)
|
||||
}
|
||||
s.DependencyStrings[k] = dstrings
|
||||
default:
|
||||
var dschema Schema
|
||||
err = json.Unmarshal(v, &dschema)
|
||||
if s.DependencySchemas == nil {
|
||||
s.DependencySchemas = make(map[string]*Schema)
|
||||
}
|
||||
s.DependencySchemas[k] = &dschema
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
unmarshalAnyPtr := func(p **any, raw json.RawMessage) error {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if bytes.Equal(raw, []byte("null")) {
|
||||
*p = new(any)
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, p)
|
||||
}
|
||||
|
||||
// Setting Const to a pointer to null will marshal properly, but won't
|
||||
// unmarshal: the *any is set to nil, not a pointer to nil.
|
||||
if err := unmarshalAnyPtr(&s.Const, ms.Const); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
set := func(dst **int, src *integer) {
|
||||
if src != nil {
|
||||
*dst = Ptr(int(*src))
|
||||
}
|
||||
}
|
||||
|
||||
set(&s.MinLength, ms.MinLength)
|
||||
set(&s.MaxLength, ms.MaxLength)
|
||||
set(&s.MinItems, ms.MinItems)
|
||||
set(&s.MaxItems, ms.MaxItems)
|
||||
set(&s.MinProperties, ms.MinProperties)
|
||||
set(&s.MaxProperties, ms.MaxProperties)
|
||||
set(&s.MinContains, ms.MinContains)
|
||||
set(&s.MaxContains, ms.MaxContains)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type integer int32 // for the integer-valued fields of Schema
|
||||
|
||||
func (ip *integer) UnmarshalJSON(data []byte) error {
|
||||
if len(data) == 0 {
|
||||
// nothing to do
|
||||
return nil
|
||||
}
|
||||
// If there is a decimal point, src is a floating-point number.
|
||||
var i int64
|
||||
if bytes.ContainsRune(data, '.') {
|
||||
var f float64
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return errors.New("not a number")
|
||||
}
|
||||
i = int64(f)
|
||||
if float64(i) != f {
|
||||
return errors.New("not an integer value")
|
||||
}
|
||||
} else {
|
||||
if err := json.Unmarshal(data, &i); err != nil {
|
||||
return errors.New("cannot be unmarshaled into an int")
|
||||
}
|
||||
}
|
||||
// Ensure behavior is the same on both 32-bit and 64-bit systems.
|
||||
if i < math.MinInt32 || i > math.MaxInt32 {
|
||||
return errors.New("integer is out of range")
|
||||
}
|
||||
*ip = integer(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ptr returns a pointer to a new variable whose value is x.
|
||||
func Ptr[T any](x T) *T { return &x }
|
||||
|
||||
// every applies f preorder to every schema under s including s.
|
||||
// The second argument to f is the path to the schema appended to the argument path.
|
||||
// It stops when f returns false.
|
||||
func (s *Schema) every(f func(*Schema) bool) bool {
|
||||
return f(s) && s.everyChild(func(s *Schema) bool { return s.every(f) })
|
||||
}
|
||||
|
||||
// everyChild reports whether f is true for every immediate child schema of s.
|
||||
func (s *Schema) everyChild(f func(*Schema) bool) bool {
|
||||
v := reflect.ValueOf(s)
|
||||
for _, info := range schemaFieldInfos {
|
||||
fv := v.Elem().FieldByIndex(info.sf.Index)
|
||||
switch info.sf.Type {
|
||||
case schemaType:
|
||||
// A field that contains an individual schema. A nil is valid: it just means the field isn't present.
|
||||
c := fv.Interface().(*Schema)
|
||||
if c != nil && !f(c) {
|
||||
return false
|
||||
}
|
||||
|
||||
case schemaSliceType:
|
||||
slice := fv.Interface().([]*Schema)
|
||||
for _, c := range slice {
|
||||
if !f(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
case schemaMapType:
|
||||
// Sort keys for determinism.
|
||||
m := fv.Interface().(map[string]*Schema)
|
||||
for _, k := range slices.Sorted(maps.Keys(m)) {
|
||||
if !f(m[k]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// all wraps every in an iterator.
|
||||
func (s *Schema) all() iter.Seq[*Schema] {
|
||||
return func(yield func(*Schema) bool) { s.every(yield) }
|
||||
}
|
||||
|
||||
// children wraps everyChild in an iterator.
|
||||
func (s *Schema) children() iter.Seq[*Schema] {
|
||||
return func(yield func(*Schema) bool) { s.everyChild(yield) }
|
||||
}
|
||||
|
||||
var (
|
||||
schemaType = reflect.TypeFor[*Schema]()
|
||||
schemaSliceType = reflect.TypeFor[[]*Schema]()
|
||||
schemaMapType = reflect.TypeFor[map[string]*Schema]()
|
||||
)
|
||||
|
||||
type structFieldInfo struct {
|
||||
sf reflect.StructField
|
||||
jsonName string
|
||||
}
|
||||
|
||||
var (
|
||||
// the visible fields of Schema that have a JSON name, sorted by that name
|
||||
schemaFieldInfos []structFieldInfo
|
||||
// map from JSON name to field
|
||||
schemaFieldMap = map[string]reflect.StructField{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
t := reflect.VisibleFields(reflect.TypeFor[Schema]())
|
||||
for _, sf := range t {
|
||||
info := fieldJSONInfo(sf)
|
||||
if !info.omit {
|
||||
schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, info.name})
|
||||
} else {
|
||||
// jsoninfo.name is used to build the info paths. The items and dependencies are ommited,
|
||||
// since the original fields are separated to handle the union types supported in json and
|
||||
// these fields have custom marshalling and unmarshalling logic.
|
||||
// we still need these fields in schemaFieldInfos for creating schema trees and calculating paths and refs.
|
||||
// so we manually create them and assign the jsonName to the original field json name.
|
||||
switch sf.Name {
|
||||
case "Items", "ItemsArray":
|
||||
schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, "items"})
|
||||
case "DependencySchemas", "DependencyStrings":
|
||||
schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, "dependencies"})
|
||||
}
|
||||
}
|
||||
}
|
||||
// The value of "dependencies" this sort of schemaFieldInfos.
|
||||
// This sort is unstable and is comparing the json.names of DependencyStrings and DependencySchemas which are both "dependencies".
|
||||
// Since the sort is unstable it cannot be guarantied that "dependencies" has the DependencySchemas value.
|
||||
slices.SortFunc(schemaFieldInfos, func(i1, i2 structFieldInfo) int {
|
||||
return cmp.Compare(i1.jsonName, i2.jsonName)
|
||||
})
|
||||
for _, info := range schemaFieldInfos {
|
||||
schemaFieldMap[info.jsonName] = info.sf
|
||||
}
|
||||
}
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/maphash"
|
||||
"math"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Equal reports whether two Go values representing JSON values are equal according
|
||||
// to the JSON Schema spec.
|
||||
// The values must not contain cycles.
|
||||
// See https://json-schema.org/draft/2020-12/json-schema-core#section-4.2.2.
|
||||
// It behaves like reflect.DeepEqual, except that numbers are compared according
|
||||
// to mathematical equality.
|
||||
func Equal(x, y any) bool {
|
||||
return equalValue(reflect.ValueOf(x), reflect.ValueOf(y))
|
||||
}
|
||||
|
||||
func equalValue(x, y reflect.Value) bool {
|
||||
// Copied from src/reflect/deepequal.go, omitting the visited check (because JSON
|
||||
// values are trees).
|
||||
if !x.IsValid() || !y.IsValid() {
|
||||
return x.IsValid() == y.IsValid()
|
||||
}
|
||||
|
||||
// Treat numbers specially.
|
||||
rx, ok1 := jsonNumber(x)
|
||||
ry, ok2 := jsonNumber(y)
|
||||
if ok1 && ok2 {
|
||||
return rx.Cmp(ry) == 0
|
||||
}
|
||||
if x.Kind() != y.Kind() {
|
||||
return false
|
||||
}
|
||||
switch x.Kind() {
|
||||
case reflect.Array:
|
||||
if x.Len() != y.Len() {
|
||||
return false
|
||||
}
|
||||
for i := range x.Len() {
|
||||
if !equalValue(x.Index(i), y.Index(i)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case reflect.Slice:
|
||||
if x.IsNil() != y.IsNil() {
|
||||
return false
|
||||
}
|
||||
if x.Len() != y.Len() {
|
||||
return false
|
||||
}
|
||||
if x.UnsafePointer() == y.UnsafePointer() {
|
||||
return true
|
||||
}
|
||||
// Special case for []byte, which is common.
|
||||
if x.Type().Elem().Kind() == reflect.Uint8 && x.Type() == y.Type() {
|
||||
return bytes.Equal(x.Bytes(), y.Bytes())
|
||||
}
|
||||
for i := range x.Len() {
|
||||
if !equalValue(x.Index(i), y.Index(i)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case reflect.Interface:
|
||||
if x.IsNil() || y.IsNil() {
|
||||
return x.IsNil() == y.IsNil()
|
||||
}
|
||||
return equalValue(x.Elem(), y.Elem())
|
||||
case reflect.Pointer:
|
||||
if x.UnsafePointer() == y.UnsafePointer() {
|
||||
return true
|
||||
}
|
||||
return equalValue(x.Elem(), y.Elem())
|
||||
case reflect.Struct:
|
||||
t := x.Type()
|
||||
if t != y.Type() {
|
||||
return false
|
||||
}
|
||||
for i := range t.NumField() {
|
||||
sf := t.Field(i)
|
||||
if !sf.IsExported() {
|
||||
continue
|
||||
}
|
||||
if !equalValue(x.FieldByIndex(sf.Index), y.FieldByIndex(sf.Index)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case reflect.Map:
|
||||
if x.IsNil() != y.IsNil() {
|
||||
return false
|
||||
}
|
||||
if x.Len() != y.Len() {
|
||||
return false
|
||||
}
|
||||
if x.UnsafePointer() == y.UnsafePointer() {
|
||||
return true
|
||||
}
|
||||
iter := x.MapRange()
|
||||
for iter.Next() {
|
||||
vx := iter.Value()
|
||||
vy := y.MapIndex(iter.Key())
|
||||
if !vy.IsValid() || !equalValue(vx, vy) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case reflect.Func:
|
||||
if x.Type() != y.Type() {
|
||||
return false
|
||||
}
|
||||
if x.IsNil() && y.IsNil() {
|
||||
return true
|
||||
}
|
||||
panic("cannot compare functions")
|
||||
case reflect.String:
|
||||
return x.String() == y.String()
|
||||
case reflect.Bool:
|
||||
return x.Bool() == y.Bool()
|
||||
// Ints, uints and floats handled in jsonNumber, at top of function.
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported kind: %s", x.Kind()))
|
||||
}
|
||||
}
|
||||
|
||||
// hashValue adds v to the data hashed by h. v must not have cycles.
|
||||
// hashValue panics if the value contains functions or channels, or maps whose
|
||||
// key type is not string.
|
||||
// It ignores unexported fields of structs.
|
||||
// Calls to hashValue with the equal values (in the sense
|
||||
// of [Equal]) result in the same sequence of values written to the hash.
|
||||
func hashValue(h *maphash.Hash, v reflect.Value) {
|
||||
// TODO: replace writes of basic types with WriteComparable in 1.24.
|
||||
|
||||
writeUint := func(u uint64) {
|
||||
var buf [8]byte
|
||||
binary.BigEndian.PutUint64(buf[:], u)
|
||||
h.Write(buf[:])
|
||||
}
|
||||
|
||||
var write func(reflect.Value)
|
||||
write = func(v reflect.Value) {
|
||||
if r, ok := jsonNumber(v); ok {
|
||||
// We want 1.0 and 1 to hash the same.
|
||||
// big.Rats are always normalized, so they will be.
|
||||
// We could do this more efficiently by handling the int and float cases
|
||||
// separately, but that's premature.
|
||||
writeUint(uint64(r.Sign() + 1))
|
||||
h.Write(r.Num().Bytes())
|
||||
h.Write(r.Denom().Bytes())
|
||||
return
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.Invalid:
|
||||
h.WriteByte(0)
|
||||
case reflect.String:
|
||||
h.WriteString(v.String())
|
||||
case reflect.Bool:
|
||||
if v.Bool() {
|
||||
h.WriteByte(1)
|
||||
} else {
|
||||
h.WriteByte(0)
|
||||
}
|
||||
case reflect.Complex64, reflect.Complex128:
|
||||
c := v.Complex()
|
||||
writeUint(math.Float64bits(real(c)))
|
||||
writeUint(math.Float64bits(imag(c)))
|
||||
case reflect.Array, reflect.Slice:
|
||||
// Although we could treat []byte more efficiently,
|
||||
// JSON values are unlikely to contain them.
|
||||
writeUint(uint64(v.Len()))
|
||||
for i := range v.Len() {
|
||||
write(v.Index(i))
|
||||
}
|
||||
case reflect.Interface, reflect.Pointer:
|
||||
write(v.Elem())
|
||||
case reflect.Struct:
|
||||
t := v.Type()
|
||||
for i := range t.NumField() {
|
||||
if sf := t.Field(i); sf.IsExported() {
|
||||
write(v.FieldByIndex(sf.Index))
|
||||
}
|
||||
}
|
||||
case reflect.Map:
|
||||
if v.Type().Key().Kind() != reflect.String {
|
||||
panic("map with non-string key")
|
||||
}
|
||||
// Sort the keys so the hash is deterministic.
|
||||
keys := v.MapKeys()
|
||||
// Write the length. That distinguishes between, say, two consecutive
|
||||
// maps with disjoint keys from one map that has the items of both.
|
||||
writeUint(uint64(len(keys)))
|
||||
slices.SortFunc(keys, func(x, y reflect.Value) int { return cmp.Compare(x.String(), y.String()) })
|
||||
for _, k := range keys {
|
||||
write(k)
|
||||
write(v.MapIndex(k))
|
||||
}
|
||||
// Ints, uints and floats handled in jsonNumber, at top of function.
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported kind: %s", v.Kind()))
|
||||
}
|
||||
}
|
||||
|
||||
write(v)
|
||||
}
|
||||
|
||||
// jsonNumber converts a numeric value or a json.Number to a [big.Rat].
|
||||
// If v is not a number, it returns nil, false.
|
||||
func jsonNumber(v reflect.Value) (*big.Rat, bool) {
|
||||
r := new(big.Rat)
|
||||
switch {
|
||||
case !v.IsValid():
|
||||
return nil, false
|
||||
case v.CanInt():
|
||||
r.SetInt64(v.Int())
|
||||
case v.CanUint():
|
||||
r.SetUint64(v.Uint())
|
||||
case v.CanFloat():
|
||||
r.SetFloat64(v.Float())
|
||||
default:
|
||||
jn, ok := v.Interface().(json.Number)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if _, ok := r.SetString(jn.String()); !ok {
|
||||
// This can fail in rare cases; for example, "1e9999999".
|
||||
// That is a valid JSON number, since the spec puts no limit on the size
|
||||
// of the exponent.
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// jsonType returns a string describing the type of the JSON value,
|
||||
// as described in the JSON Schema specification:
|
||||
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.1.
|
||||
// It returns "", false if the value is not valid JSON.
|
||||
func jsonType(v reflect.Value) (string, bool) {
|
||||
if !v.IsValid() {
|
||||
// Not v.IsNil(): a nil []any is still a JSON array.
|
||||
return "null", true
|
||||
}
|
||||
if v.CanInt() || v.CanUint() {
|
||||
return "integer", true
|
||||
}
|
||||
if v.CanFloat() {
|
||||
if _, f := math.Modf(v.Float()); f == 0 {
|
||||
return "integer", true
|
||||
}
|
||||
return "number", true
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.Bool:
|
||||
return "boolean", true
|
||||
case reflect.String:
|
||||
return "string", true
|
||||
case reflect.Slice, reflect.Array:
|
||||
return "array", true
|
||||
case reflect.Map, reflect.Struct:
|
||||
return "object", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func assert(cond bool, msg string) {
|
||||
if !cond {
|
||||
panic("assertion failed: " + msg)
|
||||
}
|
||||
}
|
||||
|
||||
// marshalStructWithMap marshals its first argument to JSON, treating the field named
|
||||
// mapField as an embedded map. The first argument must be a pointer to
|
||||
// a struct. The underlying type of mapField must be a map[string]any, and it must have
|
||||
// a "-" json tag, meaning it will not be marshaled.
|
||||
//
|
||||
// For example, given this struct:
|
||||
//
|
||||
// type S struct {
|
||||
// A int
|
||||
// Extra map[string] any `json:"-"`
|
||||
// }
|
||||
//
|
||||
// and this value:
|
||||
//
|
||||
// s := S{A: 1, Extra: map[string]any{"B": 2}}
|
||||
//
|
||||
// the call marshalJSONWithMap(s, "Extra") would return
|
||||
//
|
||||
// {"A": 1, "B": 2}
|
||||
//
|
||||
// It is an error if the map contains the same key as another struct field's
|
||||
// JSON name.
|
||||
//
|
||||
// marshalStructWithMap calls json.Marshal on a value of type T, so T must not
|
||||
// have a MarshalJSON method that calls this function, on pain of infinite regress.
|
||||
//
|
||||
// Note that there is a similar function in mcp/util.go, but they are not the same.
|
||||
// Here the function requires `-` json tag, does not clear the mapField map,
|
||||
// and handles embedded struct due to the implementation of jsonNames in this package.
|
||||
//
|
||||
// TODO: avoid this restriction on T by forcing it to marshal in a default way.
|
||||
// See https://go.dev/play/p/EgXKJHxEx_R.
|
||||
func marshalStructWithMap[T any](s *T, mapField string) ([]byte, error) {
|
||||
// Marshal the struct and the map separately, and concatenate the bytes.
|
||||
// This strategy is dramatically less complicated than
|
||||
// constructing a synthetic struct or map with the combined keys.
|
||||
if s == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
s2 := *s
|
||||
vMapField := reflect.ValueOf(&s2).Elem().FieldByName(mapField)
|
||||
mapVal := vMapField.Interface().(map[string]any)
|
||||
|
||||
// Check for duplicates.
|
||||
names := jsonNames(reflect.TypeFor[T]())
|
||||
for key := range mapVal {
|
||||
if names[key] {
|
||||
return nil, fmt.Errorf("map key %q duplicates struct field", key)
|
||||
}
|
||||
}
|
||||
|
||||
structBytes, err := json.Marshal(s2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshalStructWithMap(%+v): %w", s, err)
|
||||
}
|
||||
if len(mapVal) == 0 {
|
||||
return structBytes, nil
|
||||
}
|
||||
mapBytes, err := json.Marshal(mapVal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(structBytes) == 2 { // must be "{}"
|
||||
return mapBytes, nil
|
||||
}
|
||||
// "{X}" + "{Y}" => "{X,Y}"
|
||||
res := append(structBytes[:len(structBytes)-1], ',')
|
||||
res = append(res, mapBytes[1:]...)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// unmarshalStructWithMap is the inverse of marshalStructWithMap.
|
||||
// T has the same restrictions as in that function.
|
||||
//
|
||||
// Note that there is a similar function in mcp/util.go, but they are not the same.
|
||||
// Here jsonNames also returns fields from embedded structs, hence this function
|
||||
// handles embedded structs as well.
|
||||
func unmarshalStructWithMap[T any](data []byte, v *T, mapField string) error {
|
||||
// Unmarshal into the struct, ignoring unknown fields.
|
||||
if err := json.Unmarshal(data, v); err != nil {
|
||||
return err
|
||||
}
|
||||
// Unmarshal into the map.
|
||||
m := map[string]any{}
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete from the map the fields of the struct.
|
||||
for n := range jsonNames(reflect.TypeFor[T]()) {
|
||||
delete(m, n)
|
||||
}
|
||||
if len(m) != 0 {
|
||||
reflect.ValueOf(v).Elem().FieldByName(mapField).Set(reflect.ValueOf(m))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var jsonNamesMap sync.Map // from reflect.Type to map[string]bool
|
||||
|
||||
// jsonNames returns the set of JSON object keys that t will marshal into,
|
||||
// including fields from embedded structs in t.
|
||||
// t must be a struct type.
|
||||
//
|
||||
// Note that there is a similar function in mcp/util.go, but they are not the same
|
||||
// Here the function recurses over embedded structs and includes fields from them.
|
||||
func jsonNames(t reflect.Type) map[string]bool {
|
||||
// Lock not necessary: at worst we'll duplicate work.
|
||||
if val, ok := jsonNamesMap.Load(t); ok {
|
||||
return val.(map[string]bool)
|
||||
}
|
||||
m := map[string]bool{}
|
||||
for i := range t.NumField() {
|
||||
field := t.Field(i)
|
||||
// handle embedded structs
|
||||
if field.Anonymous {
|
||||
fieldType := field.Type
|
||||
if fieldType.Kind() == reflect.Ptr {
|
||||
fieldType = fieldType.Elem()
|
||||
}
|
||||
for n := range jsonNames(fieldType) {
|
||||
m[n] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
info := fieldJSONInfo(field)
|
||||
if !info.omit {
|
||||
m[info.name] = true
|
||||
}
|
||||
}
|
||||
jsonNamesMap.Store(t, m)
|
||||
return m
|
||||
}
|
||||
|
||||
type jsonInfo struct {
|
||||
omit bool // unexported or first tag element is "-"
|
||||
name string // Go field name or first tag element. Empty if omit is true.
|
||||
settings map[string]bool // "omitempty", "omitzero", etc.
|
||||
}
|
||||
|
||||
// fieldJSONInfo reports information about how encoding/json
|
||||
// handles the given struct field.
|
||||
// If the field is unexported, jsonInfo.omit is true and no other jsonInfo field
|
||||
// is populated.
|
||||
// If the field is exported and has no tag, then name is the field's name and all
|
||||
// other fields are false.
|
||||
// Otherwise, the information is obtained from the tag.
|
||||
func fieldJSONInfo(f reflect.StructField) jsonInfo {
|
||||
if !f.IsExported() {
|
||||
return jsonInfo{omit: true}
|
||||
}
|
||||
info := jsonInfo{name: f.Name}
|
||||
if tag, ok := f.Tag.Lookup("json"); ok {
|
||||
name, rest, found := strings.Cut(tag, ",")
|
||||
// "-" means omit, but "-," means the name is "-"
|
||||
if name == "-" && !found {
|
||||
return jsonInfo{omit: true}
|
||||
}
|
||||
if name != "" {
|
||||
info.name = name
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
info.settings = map[string]bool{}
|
||||
for _, s := range strings.Split(rest, ",") {
|
||||
info.settings[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// wrapf wraps *errp with the given formatted message if *errp is not nil.
|
||||
func wrapf(errp *error, format string, args ...any) {
|
||||
if *errp != nil {
|
||||
*errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp)
|
||||
}
|
||||
}
|
||||
+906
@@ -0,0 +1,906 @@
|
||||
// Copyright 2025 The JSON Schema Go Project Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonschema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/maphash"
|
||||
"iter"
|
||||
"math"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// The values of the "$schema" keyword for the versions that we can validate.
|
||||
const (
|
||||
draft7SchemaVersion = "http://json-schema.org/draft-07/schema#"
|
||||
draft7SecSchemaVersion = "https://json-schema.org/draft-07/schema#"
|
||||
draft202012SchemaVersion = "https://json-schema.org/draft/2020-12/schema"
|
||||
)
|
||||
|
||||
// isValidSchemaVersion checks if the given schema version is supported
|
||||
func isValidSchemaVersion(version string) bool {
|
||||
return version == "" || version == draft7SchemaVersion || version == draft7SecSchemaVersion || version == draft202012SchemaVersion
|
||||
}
|
||||
|
||||
// Validate validates the instance, which must be a JSON value, against the schema.
|
||||
// It returns nil if validation is successful or an error if it is not.
|
||||
// If the schema type is "object", instance should be a map[string]any.
|
||||
func (rs *Resolved) Validate(instance any) error {
|
||||
if s := rs.root.Schema; !isValidSchemaVersion(s) {
|
||||
return fmt.Errorf("cannot validate version %s, supported versions: draft-07 and draft 2020-12", s)
|
||||
}
|
||||
st := &state{rs: rs}
|
||||
return st.validate(reflect.ValueOf(instance), st.rs.root, nil)
|
||||
}
|
||||
|
||||
// validateDefaults walks the schema tree. If it finds a default, it validates it
|
||||
// against the schema containing it.
|
||||
//
|
||||
// TODO(jba): account for dynamic refs. This algorithm simple-mindedly
|
||||
// treats each schema with a default as its own root.
|
||||
func (rs *Resolved) validateDefaults() error {
|
||||
if s := rs.root.Schema; !isValidSchemaVersion(s) {
|
||||
return fmt.Errorf("cannot validate version %s, supported versions: draft-07 and draft 2020-12", s)
|
||||
}
|
||||
st := &state{rs: rs}
|
||||
for s := range rs.root.all() {
|
||||
// We checked for nil schemas in [Schema.Resolve].
|
||||
assert(s != nil, "nil schema")
|
||||
if s.DynamicRef != "" {
|
||||
return fmt.Errorf("jsonschema: %s: validateDefaults does not support dynamic refs", rs.schemaString(s))
|
||||
}
|
||||
if s.Default != nil {
|
||||
var d any
|
||||
if err := json.Unmarshal(s.Default, &d); err != nil {
|
||||
return fmt.Errorf("unmarshaling default value of schema %s: %w", rs.schemaString(s), err)
|
||||
}
|
||||
if err := st.validate(reflect.ValueOf(d), s, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// state is the state of single call to ResolvedSchema.Validate.
|
||||
type state struct {
|
||||
rs *Resolved
|
||||
// stack holds the schemas from recursive calls to validate.
|
||||
// These are the "dynamic scopes" used to resolve dynamic references.
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#scopes
|
||||
stack []*Schema
|
||||
}
|
||||
|
||||
// validate validates the reflected value of the instance.
|
||||
func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *annotations) (err error) {
|
||||
defer wrapf(&err, "validating %s", st.rs.schemaString(schema))
|
||||
|
||||
// Maintain a stack for dynamic schema resolution.
|
||||
st.stack = append(st.stack, schema) // push
|
||||
defer func() {
|
||||
st.stack = st.stack[:len(st.stack)-1] // pop
|
||||
}()
|
||||
|
||||
// We checked for nil schemas in [Schema.Resolve].
|
||||
assert(schema != nil, "nil schema")
|
||||
|
||||
// Step through interfaces and pointers.
|
||||
for instance.Kind() == reflect.Pointer || instance.Kind() == reflect.Interface {
|
||||
instance = instance.Elem()
|
||||
}
|
||||
|
||||
schemaInfo := st.rs.resolvedInfos[schema]
|
||||
|
||||
var anns annotations // all the annotations for this call and child calls
|
||||
// $ref: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.1
|
||||
if schema.Ref != "" {
|
||||
if err := st.validate(instance, schemaInfo.resolvedRef, &anns); err != nil {
|
||||
return err
|
||||
}
|
||||
// https://json-schema.org/draft-07/draft-handrews-json-schema-01#rfc.section.8.3
|
||||
// "All other properties in a "$ref" object MUST be ignored."
|
||||
if st.rs.draft == draft7 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// type: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.1
|
||||
if schema.Type != "" || schema.Types != nil {
|
||||
gotType, ok := jsonType(instance)
|
||||
if !ok {
|
||||
return fmt.Errorf("type: %v of type %[1]T is not a valid JSON value", instance)
|
||||
}
|
||||
if schema.Type != "" {
|
||||
// "number" subsumes integers
|
||||
if !(gotType == schema.Type ||
|
||||
gotType == "integer" && schema.Type == "number") {
|
||||
return fmt.Errorf("type: %v has type %q, want %q", instance, gotType, schema.Type)
|
||||
}
|
||||
} else {
|
||||
if !(slices.Contains(schema.Types, gotType) || (gotType == "integer" && slices.Contains(schema.Types, "number"))) {
|
||||
return fmt.Errorf("type: %v has type %q, want one of %q",
|
||||
instance, gotType, strings.Join(schema.Types, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
// enum: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.2
|
||||
if schema.Enum != nil {
|
||||
ok := false
|
||||
for _, e := range schema.Enum {
|
||||
if equalValue(reflect.ValueOf(e), instance) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("enum: %v does not equal any of: %v", instance, schema.Enum)
|
||||
}
|
||||
}
|
||||
|
||||
// const: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.3
|
||||
if schema.Const != nil {
|
||||
if !equalValue(reflect.ValueOf(*schema.Const), instance) {
|
||||
return fmt.Errorf("const: %v does not equal %v", instance, *schema.Const)
|
||||
}
|
||||
}
|
||||
|
||||
// numbers: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.2
|
||||
if schema.MultipleOf != nil || schema.Minimum != nil || schema.Maximum != nil || schema.ExclusiveMinimum != nil || schema.ExclusiveMaximum != nil {
|
||||
n, ok := jsonNumber(instance)
|
||||
if ok { // these keywords don't apply to non-numbers
|
||||
if schema.MultipleOf != nil {
|
||||
// TODO: validate MultipleOf as non-zero.
|
||||
// The test suite assumes floats.
|
||||
nf, _ := n.Float64() // don't care if it's exact or not
|
||||
if _, f := math.Modf(nf / *schema.MultipleOf); f != 0 {
|
||||
return fmt.Errorf("multipleOf: %s is not a multiple of %f", n, *schema.MultipleOf)
|
||||
}
|
||||
}
|
||||
|
||||
m := new(big.Rat) // reuse for all of the following
|
||||
cmp := func(f float64) int { return n.Cmp(m.SetFloat64(f)) }
|
||||
|
||||
if schema.Minimum != nil && cmp(*schema.Minimum) < 0 {
|
||||
return fmt.Errorf("minimum: %s is less than %f", n, *schema.Minimum)
|
||||
}
|
||||
if schema.Maximum != nil && cmp(*schema.Maximum) > 0 {
|
||||
return fmt.Errorf("maximum: %s is greater than %f", n, *schema.Maximum)
|
||||
}
|
||||
if schema.ExclusiveMinimum != nil && cmp(*schema.ExclusiveMinimum) <= 0 {
|
||||
return fmt.Errorf("exclusiveMinimum: %s is less than or equal to %f", n, *schema.ExclusiveMinimum)
|
||||
}
|
||||
if schema.ExclusiveMaximum != nil && cmp(*schema.ExclusiveMaximum) >= 0 {
|
||||
return fmt.Errorf("exclusiveMaximum: %s is greater than or equal to %f", n, *schema.ExclusiveMaximum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// strings: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.3
|
||||
if instance.Kind() == reflect.String && (schema.MinLength != nil || schema.MaxLength != nil || schema.Pattern != "") {
|
||||
str := instance.String()
|
||||
n := utf8.RuneCountInString(str)
|
||||
if schema.MinLength != nil {
|
||||
if m := *schema.MinLength; n < m {
|
||||
return fmt.Errorf("minLength: %q contains %d Unicode code points, fewer than %d", str, n, m)
|
||||
}
|
||||
}
|
||||
if schema.MaxLength != nil {
|
||||
if m := *schema.MaxLength; n > m {
|
||||
return fmt.Errorf("maxLength: %q contains %d Unicode code points, more than %d", str, n, m)
|
||||
}
|
||||
}
|
||||
|
||||
if schema.Pattern != "" && !schemaInfo.pattern.MatchString(str) {
|
||||
return fmt.Errorf("pattern: %q does not match regular expression %q", str, schema.Pattern)
|
||||
}
|
||||
}
|
||||
|
||||
// $dynamicRef: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.2
|
||||
if schema.DynamicRef != "" {
|
||||
// The ref behaves lexically or dynamically, but not both.
|
||||
assert((schemaInfo.resolvedDynamicRef == nil) != (schemaInfo.dynamicRefAnchor == ""),
|
||||
"DynamicRef not resolved properly")
|
||||
if schemaInfo.resolvedDynamicRef != nil {
|
||||
// Same as $ref.
|
||||
if err := st.validate(instance, schemaInfo.resolvedDynamicRef, &anns); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Dynamic behavior.
|
||||
// Look for the base of the outermost schema on the stack with this dynamic
|
||||
// anchor. (Yes, outermost: the one farthest from here. This the opposite
|
||||
// of how ordinary dynamic variables behave.)
|
||||
// Why the base of the schema being validated and not the schema itself?
|
||||
// Because the base is the scope for anchors. In fact it's possible to
|
||||
// refer to a schema that is not on the stack, but a child of some base
|
||||
// on the stack.
|
||||
// For an example, search for "detached" in testdata/draft2020-12/dynamicRef.json.
|
||||
var dynamicSchema *Schema
|
||||
for _, s := range st.stack {
|
||||
base := st.rs.resolvedInfos[s].base
|
||||
info, ok := st.rs.resolvedInfos[base].anchors[schemaInfo.dynamicRefAnchor]
|
||||
if ok && info.dynamic {
|
||||
dynamicSchema = info.schema
|
||||
break
|
||||
}
|
||||
}
|
||||
if dynamicSchema == nil {
|
||||
return fmt.Errorf("missing dynamic anchor %q", schemaInfo.dynamicRefAnchor)
|
||||
}
|
||||
if err := st.validate(instance, dynamicSchema, &anns); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logic
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.2
|
||||
// These must happen before arrays and objects because if they evaluate an item or property,
|
||||
// then the unevaluatedItems/Properties schemas don't apply to it.
|
||||
// See https://json-schema.org/draft/2020-12/json-schema-core#section-11.2, paragraph 4.
|
||||
//
|
||||
// If any of these fail, then validation fails, even if there is an unevaluatedXXX
|
||||
// keyword in the schema. The spec is unclear about this, but that is the intention.
|
||||
|
||||
valid := func(s *Schema, anns *annotations) bool { return st.validate(instance, s, anns) == nil }
|
||||
|
||||
if schema.AllOf != nil {
|
||||
for _, ss := range schema.AllOf {
|
||||
if err := st.validate(instance, ss, &anns); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if schema.AnyOf != nil {
|
||||
// We must visit them all, to collect annotations.
|
||||
var errs []error
|
||||
for _, ss := range schema.AnyOf {
|
||||
if err := st.validate(instance, ss, &anns); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) == len(schema.AnyOf) {
|
||||
return fmt.Errorf("anyOf: did not validate against any of %v:\n%v",
|
||||
schema.AnyOf, errors.Join(errs...))
|
||||
}
|
||||
}
|
||||
if schema.OneOf != nil {
|
||||
// Exactly one.
|
||||
var okSchema *Schema
|
||||
for _, ss := range schema.OneOf {
|
||||
if valid(ss, &anns) {
|
||||
if okSchema != nil {
|
||||
return fmt.Errorf("oneOf: validated against both %v and %v", okSchema, ss)
|
||||
}
|
||||
okSchema = ss
|
||||
}
|
||||
}
|
||||
if okSchema == nil {
|
||||
return fmt.Errorf("oneOf: did not validate against any of %v", schema.OneOf)
|
||||
}
|
||||
}
|
||||
if schema.Not != nil {
|
||||
// Ignore annotations from "not".
|
||||
if valid(schema.Not, nil) {
|
||||
return fmt.Errorf("not: validated against %v", schema.Not)
|
||||
}
|
||||
}
|
||||
if schema.If != nil {
|
||||
var ss *Schema
|
||||
if valid(schema.If, &anns) {
|
||||
ss = schema.Then
|
||||
} else {
|
||||
ss = schema.Else
|
||||
}
|
||||
if ss != nil {
|
||||
if err := st.validate(instance, ss, &anns); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// arrays
|
||||
if instance.Kind() == reflect.Array || instance.Kind() == reflect.Slice {
|
||||
// Handle both draft-07 and draft 2020-12
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.1
|
||||
// This validate call doesn't collect annotations for the items of the instance; they are separate
|
||||
// instances in their own right.
|
||||
// TODO(jba): if the test suite doesn't cover this case, add a test. For example, nested arrays.
|
||||
if st.rs.draft == draft7 {
|
||||
// For draft-07: additionalItems applies to remaining items after items array.
|
||||
// If items is a Schema or if items is not set, additionalItems should be ignored
|
||||
if schema.ItemsArray != nil {
|
||||
for i, ischema := range schema.ItemsArray {
|
||||
if i >= instance.Len() {
|
||||
break // shorter is OK
|
||||
}
|
||||
if err := st.validate(instance.Index(i), ischema, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
anns.noteEndIndex(min(len(schema.ItemsArray), instance.Len()))
|
||||
if schema.AdditionalItems != nil {
|
||||
for i := len(schema.ItemsArray); i < instance.Len(); i++ {
|
||||
if err := st.validate(instance.Index(i), schema.AdditionalItems, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
anns.allItems = true
|
||||
}
|
||||
} else if schema.Items != nil {
|
||||
for i := 0; i < instance.Len(); i++ {
|
||||
if err := st.validate(instance.Index(i), schema.Items, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Note that all the items in this array have been validated.
|
||||
anns.allItems = true
|
||||
}
|
||||
} else if st.rs.draft == draft2020 {
|
||||
// For draft 2020-12: items applies to remaining items after prefixItems
|
||||
for i, ischema := range schema.PrefixItems {
|
||||
if i >= instance.Len() {
|
||||
break // shorter is OK
|
||||
}
|
||||
if err := st.validate(instance.Index(i), ischema, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
anns.noteEndIndex(min(len(schema.PrefixItems), instance.Len()))
|
||||
if schema.Items != nil {
|
||||
for i := len(schema.PrefixItems); i < instance.Len(); i++ {
|
||||
if err := st.validate(instance.Index(i), schema.Items, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Note that all the items in this array have been validated.
|
||||
anns.allItems = true
|
||||
}
|
||||
}
|
||||
nContains := 0
|
||||
if schema.Contains != nil {
|
||||
for i := range instance.Len() {
|
||||
if err := st.validate(instance.Index(i), schema.Contains, nil); err == nil {
|
||||
nContains++
|
||||
anns.noteIndex(i)
|
||||
}
|
||||
}
|
||||
if nContains == 0 && (schema.MinContains == nil || *schema.MinContains > 0) {
|
||||
return fmt.Errorf("contains: %s does not have an item matching %s", instance, schema.Contains)
|
||||
}
|
||||
}
|
||||
|
||||
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.4
|
||||
// TODO(jba): check that these next four keywords' values are integers.
|
||||
if schema.MinContains != nil && schema.Contains != nil {
|
||||
if m := *schema.MinContains; nContains < m {
|
||||
return fmt.Errorf("minContains: contains validated %d items, less than %d", nContains, m)
|
||||
}
|
||||
}
|
||||
if schema.MaxContains != nil && schema.Contains != nil {
|
||||
if m := *schema.MaxContains; nContains > m {
|
||||
return fmt.Errorf("maxContains: contains validated %d items, greater than %d", nContains, m)
|
||||
}
|
||||
}
|
||||
if schema.MinItems != nil {
|
||||
if m := *schema.MinItems; instance.Len() < m {
|
||||
return fmt.Errorf("minItems: array length %d is less than %d", instance.Len(), m)
|
||||
}
|
||||
}
|
||||
if schema.MaxItems != nil {
|
||||
if m := *schema.MaxItems; instance.Len() > m {
|
||||
return fmt.Errorf("maxItems: array length %d is greater than %d", instance.Len(), m)
|
||||
}
|
||||
}
|
||||
if schema.UniqueItems {
|
||||
if instance.Len() > 1 {
|
||||
// Hash each item and compare the hashes.
|
||||
// If two hashes differ, the items differ.
|
||||
// If two hashes are the same, compare the collisions for equality.
|
||||
// (The same logic as hash table lookup.)
|
||||
// TODO(jba): Use container/hash.Map when it becomes available (https://go.dev/issue/69559),
|
||||
hashes := map[uint64][]int{} // from hash to indices
|
||||
seed := maphash.MakeSeed()
|
||||
for i := range instance.Len() {
|
||||
item := instance.Index(i)
|
||||
var h maphash.Hash
|
||||
h.SetSeed(seed)
|
||||
hashValue(&h, item)
|
||||
hv := h.Sum64()
|
||||
if sames := hashes[hv]; len(sames) > 0 {
|
||||
for _, j := range sames {
|
||||
if equalValue(item, instance.Index(j)) {
|
||||
return fmt.Errorf("uniqueItems: array items %d and %d are equal", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
hashes[hv] = append(hashes[hv], i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-11.2
|
||||
if schema.UnevaluatedItems != nil && !anns.allItems {
|
||||
// Apply this subschema to all items in the array that haven't been successfully validated.
|
||||
// That includes validations by subschemas on the same instance, like allOf.
|
||||
for i := anns.endIndex; i < instance.Len(); i++ {
|
||||
if !anns.evaluatedIndexes[i] {
|
||||
if err := st.validate(instance.Index(i), schema.UnevaluatedItems, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
anns.allItems = true
|
||||
}
|
||||
}
|
||||
|
||||
// objects
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2
|
||||
// Validating structs is problematic. See https://github.com/google/jsonschema-go/issues/23.
|
||||
if instance.Kind() == reflect.Struct {
|
||||
return errors.New("cannot validate against a struct; see https://github.com/google/jsonschema-go/issues/23 for details")
|
||||
}
|
||||
if instance.Kind() == reflect.Map {
|
||||
if kt := instance.Type().Key(); kt.Kind() != reflect.String {
|
||||
return fmt.Errorf("map key type %s is not a string", kt)
|
||||
}
|
||||
// Track the evaluated properties for just this schema, to support additionalProperties.
|
||||
// If we used anns here, then we'd be including properties evaluated in subschemas
|
||||
// from allOf, etc., which additionalProperties shouldn't observe.
|
||||
evalProps := map[string]bool{}
|
||||
for prop, subschema := range schema.Properties {
|
||||
val := property(instance, prop)
|
||||
if !val.IsValid() {
|
||||
// It's OK if the instance doesn't have the property.
|
||||
continue
|
||||
}
|
||||
// If the instance is a struct and an optional property has the zero
|
||||
// value, then we could interpret it as present or missing. Be generous:
|
||||
// assume it's missing, and thus always validates successfully.
|
||||
if instance.Kind() == reflect.Struct && val.IsZero() && !schemaInfo.isRequired[prop] {
|
||||
continue
|
||||
}
|
||||
if err := st.validate(val, subschema, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
evalProps[prop] = true
|
||||
}
|
||||
if len(schema.PatternProperties) > 0 {
|
||||
for prop, val := range properties(instance) {
|
||||
// Check every matching pattern.
|
||||
for re, schema := range schemaInfo.patternProperties {
|
||||
if re.MatchString(prop) {
|
||||
if err := st.validate(val, schema, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
evalProps[prop] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if schema.AdditionalProperties != nil {
|
||||
// Special case for a better error message when additional properties is
|
||||
// 'falsy'
|
||||
//
|
||||
// If additionalProperties is {"not":{}} (which is how we
|
||||
// unmarshal "false"), we can produce a better error message that
|
||||
// summarizes all the extra properties. Otherwise, we fall back to the
|
||||
// default validation.
|
||||
//
|
||||
// Note: this is much faster than comparing with falseSchema using Equal.
|
||||
isFalsy := schema.AdditionalProperties.Not != nil && reflect.ValueOf(*schema.AdditionalProperties.Not).IsZero()
|
||||
if isFalsy {
|
||||
var disallowed []string
|
||||
for prop := range properties(instance) {
|
||||
if !evalProps[prop] {
|
||||
disallowed = append(disallowed, prop)
|
||||
}
|
||||
}
|
||||
if len(disallowed) > 0 {
|
||||
return fmt.Errorf("unexpected additional properties %q", disallowed)
|
||||
}
|
||||
} else {
|
||||
// Apply to all properties not handled above.
|
||||
for prop, val := range properties(instance) {
|
||||
if !evalProps[prop] {
|
||||
if err := st.validate(val, schema.AdditionalProperties, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
evalProps[prop] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
anns.noteProperties(evalProps)
|
||||
if schema.PropertyNames != nil {
|
||||
// Note: properties unnecessarily fetches each value. We could define a propertyNames function
|
||||
// if performance ever matters.
|
||||
for prop := range properties(instance) {
|
||||
if err := st.validate(reflect.ValueOf(prop), schema.PropertyNames, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.5
|
||||
var min, max int
|
||||
if schema.MinProperties != nil || schema.MaxProperties != nil {
|
||||
min, max = numPropertiesBounds(instance, schemaInfo.isRequired)
|
||||
}
|
||||
if schema.MinProperties != nil {
|
||||
if n, m := max, *schema.MinProperties; n < m {
|
||||
return fmt.Errorf("minProperties: object has %d properties, less than %d", n, m)
|
||||
}
|
||||
}
|
||||
if schema.MaxProperties != nil {
|
||||
if n, m := min, *schema.MaxProperties; n > m {
|
||||
return fmt.Errorf("maxProperties: object has %d properties, greater than %d", n, m)
|
||||
}
|
||||
}
|
||||
|
||||
hasProperty := func(prop string) bool {
|
||||
return property(instance, prop).IsValid()
|
||||
}
|
||||
|
||||
missingProperties := func(props []string) []string {
|
||||
var missing []string
|
||||
for _, p := range props {
|
||||
if !hasProperty(p) {
|
||||
missing = append(missing, p)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
if schema.Required != nil {
|
||||
if m := missingProperties(schema.Required); len(m) > 0 {
|
||||
return fmt.Errorf("required: missing properties: %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
if st.rs.draft == draft7 {
|
||||
if schema.DependencyStrings != nil {
|
||||
for dprop, dstrings := range schema.DependencyStrings {
|
||||
if hasProperty(dprop) {
|
||||
if m := missingProperties(dstrings); len(m) > 0 {
|
||||
return fmt.Errorf("dependentRequired[%q]: missing properties %q", dprop, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if schema.DependencySchemas != nil {
|
||||
for dprop, dschema := range schema.DependencySchemas {
|
||||
if hasProperty(dprop) {
|
||||
err := st.validate(instance, dschema, &anns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if st.rs.draft == draft2020 {
|
||||
if schema.DependentRequired != nil {
|
||||
// "Validation succeeds if, for each name that appears in both the instance
|
||||
// and as a name within this keyword's value, every item in the corresponding
|
||||
// array is also the name of a property in the instance." §6.5.4
|
||||
for dprop, reqs := range schema.DependentRequired {
|
||||
if hasProperty(dprop) {
|
||||
if m := missingProperties(reqs); len(m) > 0 {
|
||||
return fmt.Errorf("dependentRequired[%q]: missing properties %q", dprop, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.2.2.4
|
||||
if schema.DependentSchemas != nil {
|
||||
// This does not collect annotations, although it seems like it should.
|
||||
for dprop, ss := range schema.DependentSchemas {
|
||||
if hasProperty(dprop) {
|
||||
// TODO: include dependentSchemas[dprop] in the errors.
|
||||
err := st.validate(instance, ss, &anns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if schema.UnevaluatedProperties != nil && !anns.allProperties {
|
||||
// This looks a lot like AdditionalProperties, but depends on in-place keywords like allOf
|
||||
// in addition to sibling keywords.
|
||||
for prop, val := range properties(instance) {
|
||||
if !anns.evaluatedProperties[prop] {
|
||||
if err := st.validate(val, schema.UnevaluatedProperties, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// The spec says the annotation should be the set of evaluated properties, but we can optimize
|
||||
// by setting a single boolean, since after this succeeds all properties will be validated.
|
||||
// See https://json-schema.slack.com/archives/CT7FF623C/p1745592564381459.
|
||||
anns.allProperties = true
|
||||
}
|
||||
}
|
||||
|
||||
if callerAnns != nil {
|
||||
// Our caller wants to know what we've validated.
|
||||
callerAnns.merge(&anns)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDynamicRef returns the schema referred to by the argument schema's
|
||||
// $dynamicRef value.
|
||||
// It returns an error if the dynamic reference has no referent.
|
||||
// If there is no $dynamicRef, resolveDynamicRef returns nil, nil.
|
||||
// See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.2.
|
||||
func (st *state) resolveDynamicRef(schema *Schema) (*Schema, error) {
|
||||
if schema.DynamicRef == "" {
|
||||
return nil, nil
|
||||
}
|
||||
info := st.rs.resolvedInfos[schema]
|
||||
// The ref behaves lexically or dynamically, but not both.
|
||||
assert((info.resolvedDynamicRef == nil) != (info.dynamicRefAnchor == ""),
|
||||
"DynamicRef not statically resolved properly")
|
||||
if r := info.resolvedDynamicRef; r != nil {
|
||||
// Same as $ref.
|
||||
return r, nil
|
||||
}
|
||||
// Dynamic behavior.
|
||||
// Look for the base of the outermost schema on the stack with this dynamic
|
||||
// anchor. (Yes, outermost: the one farthest from here. This the opposite
|
||||
// of how ordinary dynamic variables behave.)
|
||||
// Why the base of the schema being validated and not the schema itself?
|
||||
// Because the base is the scope for anchors. In fact it's possible to
|
||||
// refer to a schema that is not on the stack, but a child of some base
|
||||
// on the stack.
|
||||
// For an example, search for "detached" in testdata/draft2020-12/dynamicRef.json.
|
||||
for _, s := range st.stack {
|
||||
base := st.rs.resolvedInfos[s].base
|
||||
info, ok := st.rs.resolvedInfos[base].anchors[info.dynamicRefAnchor]
|
||||
if ok && info.dynamic {
|
||||
return info.schema, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("missing dynamic anchor %q", info.dynamicRefAnchor)
|
||||
}
|
||||
|
||||
// ApplyDefaults modifies an instance by applying the schema's defaults to it. If
|
||||
// a schema or sub-schema has a default, then a corresponding missing instance value
|
||||
// is set to the default.
|
||||
//
|
||||
// The JSON Schema specification does not describe how defaults should be interpreted.
|
||||
// This method honors defaults only on properties, and only those that are not required.
|
||||
// If the instance is a map and the property is missing, the property is added to
|
||||
// the map with the default.
|
||||
// ApplyDefaults does not support structs, because it cannot know whether a field
|
||||
// is missing in the JSON, or was explicitly set to its zero value.
|
||||
//
|
||||
// ApplyDefaults can panic if a default cannot be assigned to a field.
|
||||
//
|
||||
// The argument must be a pointer to the instance.
|
||||
// (In case we decide that top-level defaults are meaningful.)
|
||||
//
|
||||
// It is recommended to first call Resolve with a ValidateDefaults option of true,
|
||||
// then call this method, and lastly call Validate.
|
||||
func (rs *Resolved) ApplyDefaults(instancep any) error {
|
||||
// TODO(jba): consider what defaults on top-level or array instances might mean.
|
||||
// TODO(jba): follow $ref and $dynamicRef
|
||||
st := &state{rs: rs}
|
||||
return st.applyDefaults(reflect.ValueOf(instancep), rs.root)
|
||||
}
|
||||
|
||||
// Recursive helper used by ApplyDefaults. Applies defaults on sub-schemas
|
||||
// of object properties recursively.
|
||||
func (st *state) applyDefaults(instancep reflect.Value, schema *Schema) (err error) {
|
||||
defer wrapf(&err, "applyDefaults: schema %s, instance %v", st.rs.schemaString(schema), instancep)
|
||||
|
||||
schemaInfo := st.rs.resolvedInfos[schema]
|
||||
instance := instancep.Elem()
|
||||
if instance.Kind() == reflect.Interface && instance.IsValid() {
|
||||
// If we unmarshalled into 'any', the default object unmarshalling will be map[string]any.
|
||||
instance = instance.Elem()
|
||||
}
|
||||
if instance.Kind() == reflect.Map || instance.Kind() == reflect.Struct {
|
||||
if instance.Kind() == reflect.Map {
|
||||
if kt := instance.Type().Key(); kt.Kind() != reflect.String {
|
||||
return fmt.Errorf("map key type %s is not a string", kt)
|
||||
}
|
||||
}
|
||||
for prop, subschema := range schema.Properties {
|
||||
// Ignore defaults on required properties. (A required property shouldn't have a default.)
|
||||
if schemaInfo.isRequired[prop] {
|
||||
continue
|
||||
}
|
||||
val := property(instance, prop)
|
||||
switch instance.Kind() {
|
||||
case reflect.Map:
|
||||
// If there is a default for this property, and the map key is missing,
|
||||
// set the map value to the default.
|
||||
if subschema.Default != nil && !val.IsValid() {
|
||||
// Create an lvalue, since map values aren't addressable.
|
||||
lvalue := reflect.New(instance.Type().Elem())
|
||||
if err := json.Unmarshal(subschema.Default, lvalue.Interface()); err != nil {
|
||||
return err
|
||||
}
|
||||
// Recurse unconditionally; applyDefaults will only act on object-like values.
|
||||
if err := st.applyDefaults(lvalue, subschema); err != nil {
|
||||
return err
|
||||
}
|
||||
instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem())
|
||||
} else if val.IsValid() {
|
||||
// Recurse into an existing sub-instance.
|
||||
// MapIndex returns a non-addressable value; copy into an addressable lvalue, recurse, then set back.
|
||||
lvalue := reflect.New(instance.Type().Elem())
|
||||
// Initialize the lvalue with current value.
|
||||
lvalue.Elem().Set(val)
|
||||
if err := st.applyDefaults(lvalue, subschema); err != nil {
|
||||
return err
|
||||
}
|
||||
instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem())
|
||||
} else if schemaHasDefaultsInProperties(subschema) {
|
||||
// Property is missing, but descendants still have some defaults
|
||||
// Create an empty container and recurse to populate
|
||||
elemType := instance.Type().Elem()
|
||||
var child reflect.Value
|
||||
switch elemType.Kind() {
|
||||
case reflect.Interface:
|
||||
child = reflect.ValueOf(map[string]any{})
|
||||
case reflect.Map:
|
||||
child = reflect.MakeMap(elemType)
|
||||
case reflect.Struct:
|
||||
child = reflect.New(elemType).Elem()
|
||||
}
|
||||
if child.IsValid() {
|
||||
lvalue := reflect.New(elemType)
|
||||
lvalue.Elem().Set(child)
|
||||
if err := st.applyDefaults(lvalue, subschema); err != nil {
|
||||
return err
|
||||
}
|
||||
instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem())
|
||||
}
|
||||
}
|
||||
case reflect.Struct:
|
||||
return errors.New("cannot apply defaults to a struct")
|
||||
default:
|
||||
panic(fmt.Sprintf("applyDefaults: property %s: bad value %s of kind %s",
|
||||
prop, instance, instance.Kind()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// schemaHasDefaultsInProperties reports whether s or any descendant schema under
|
||||
// its Properties contains a default. Only walks Properties to match ApplyDefaults semantics.
|
||||
func schemaHasDefaultsInProperties(s *Schema) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
if s.Default != nil {
|
||||
return true
|
||||
}
|
||||
if s.Properties != nil {
|
||||
for _, ss := range s.Properties {
|
||||
if schemaHasDefaultsInProperties(ss) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// property returns the value of the property of v with the given name, or the invalid
|
||||
// reflect.Value if there is none.
|
||||
// If v is a map, the property is the value of the map whose key is name.
|
||||
// If v is a struct, the property is the value of the field with the given name according
|
||||
// to the encoding/json package (see [jsonName]).
|
||||
// If v is anything else, property panics.
|
||||
func property(v reflect.Value, name string) reflect.Value {
|
||||
switch v.Kind() {
|
||||
case reflect.Map:
|
||||
return v.MapIndex(reflect.ValueOf(name))
|
||||
case reflect.Struct:
|
||||
props := structPropertiesOf(v.Type())
|
||||
// Ignore nonexistent properties.
|
||||
if sf, ok := props[name]; ok {
|
||||
return v.FieldByIndex(sf.Index)
|
||||
}
|
||||
return reflect.Value{}
|
||||
default:
|
||||
panic(fmt.Sprintf("property(%q): bad value %s of kind %s", name, v, v.Kind()))
|
||||
}
|
||||
}
|
||||
|
||||
// properties returns an iterator over the names and values of all properties
|
||||
// in v, which must be a map or a struct.
|
||||
// If a struct, zero-valued properties that are marked omitempty or omitzero
|
||||
// are excluded.
|
||||
func properties(v reflect.Value) iter.Seq2[string, reflect.Value] {
|
||||
return func(yield func(string, reflect.Value) bool) {
|
||||
switch v.Kind() {
|
||||
case reflect.Map:
|
||||
for k, e := range v.Seq2() {
|
||||
if !yield(k.String(), e) {
|
||||
return
|
||||
}
|
||||
}
|
||||
case reflect.Struct:
|
||||
for name, sf := range structPropertiesOf(v.Type()) {
|
||||
val := v.FieldByIndex(sf.Index)
|
||||
if val.IsZero() {
|
||||
info := fieldJSONInfo(sf)
|
||||
if info.settings["omitempty"] || info.settings["omitzero"] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !yield(name, val) {
|
||||
return
|
||||
}
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("bad value %s of kind %s", v, v.Kind()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// numPropertiesBounds returns bounds on the number of v's properties.
|
||||
// v must be a map or a struct.
|
||||
// If v is a map, both bounds are the map's size.
|
||||
// If v is a struct, the max is the number of struct properties.
|
||||
// But since we don't know whether a zero value indicates a missing optional property
|
||||
// or not, be generous and use the number of non-zero properties as the min.
|
||||
func numPropertiesBounds(v reflect.Value, isRequired map[string]bool) (int, int) {
|
||||
switch v.Kind() {
|
||||
case reflect.Map:
|
||||
return v.Len(), v.Len()
|
||||
case reflect.Struct:
|
||||
sp := structPropertiesOf(v.Type())
|
||||
min := 0
|
||||
for prop, sf := range sp {
|
||||
if !v.FieldByIndex(sf.Index).IsZero() || isRequired[prop] {
|
||||
min++
|
||||
}
|
||||
}
|
||||
return min, len(sp)
|
||||
default:
|
||||
panic(fmt.Sprintf("properties: bad value: %s of kind %s", v, v.Kind()))
|
||||
}
|
||||
}
|
||||
|
||||
// A propertyMap is a map from property name to struct field index.
|
||||
type propertyMap = map[string]reflect.StructField
|
||||
|
||||
var structProperties sync.Map // from reflect.Type to propertyMap
|
||||
|
||||
// structPropertiesOf returns the JSON Schema properties for the struct type t.
|
||||
// The caller must not mutate the result.
|
||||
func structPropertiesOf(t reflect.Type) propertyMap {
|
||||
// Mutex not necessary: at worst we'll recompute the same value.
|
||||
if props, ok := structProperties.Load(t); ok {
|
||||
return props.(propertyMap)
|
||||
}
|
||||
props := map[string]reflect.StructField{}
|
||||
for _, sf := range reflect.VisibleFields(t) {
|
||||
if sf.Anonymous {
|
||||
continue
|
||||
}
|
||||
info := fieldJSONInfo(sf)
|
||||
if !info.omit {
|
||||
props[info.name] = sf
|
||||
}
|
||||
}
|
||||
structProperties.Store(t, props)
|
||||
return props
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
|
||||
|
||||
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
|
||||
|
||||
No rights beyond those granted by the applicable original license are conveyed for such contributions.
|
||||
|
||||
---
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright
|
||||
owner or by an individual or Legal Entity authorized to submit on behalf
|
||||
of the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
Creative Commons Attribution 4.0 International (CC-BY-4.0)
|
||||
|
||||
Documentation in this project (excluding specifications) is licensed under
|
||||
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
|
||||
the full license text.
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
)
|
||||
|
||||
// TokenInfo holds information from a bearer token.
|
||||
type TokenInfo struct {
|
||||
Scopes []string
|
||||
Expiration time.Time
|
||||
// UserID is an optional identifier for the authenticated user.
|
||||
// If set by a TokenVerifier, it can be used by transports to prevent
|
||||
// session hijacking by ensuring that all requests for a given session
|
||||
// come from the same user.
|
||||
UserID string
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
// The error that a TokenVerifier should return if the token cannot be verified.
|
||||
var ErrInvalidToken = errors.New("invalid token")
|
||||
|
||||
// The error that a TokenVerifier should return for OAuth-specific protocol errors.
|
||||
var ErrOAuth = errors.New("oauth error")
|
||||
|
||||
// A TokenVerifier checks the validity of a bearer token, and extracts information
|
||||
// from it. If verification fails, it should return an error that unwraps to ErrInvalidToken.
|
||||
// The HTTP request is provided in case verifying the token involves checking it.
|
||||
type TokenVerifier func(ctx context.Context, token string, req *http.Request) (*TokenInfo, error)
|
||||
|
||||
// RequireBearerTokenOptions are options for [RequireBearerToken].
|
||||
type RequireBearerTokenOptions struct {
|
||||
// The URL for the resource server metadata OAuth flow, to be returned as part
|
||||
// of the WWW-Authenticate header.
|
||||
ResourceMetadataURL string
|
||||
// The required scopes.
|
||||
Scopes []string
|
||||
// AllowMissingExpiration opts the middleware out of the
|
||||
// `tokenInfo.Expiration.IsZero()` reject. Default false preserves the
|
||||
// existing strict behaviour (every TokenInfo must carry an Expiration).
|
||||
//
|
||||
// Some IdPs emit session-bound bearer tokens that do not carry a standalone
|
||||
// `exp` claim — the token's lifetime is bounded by an external session and
|
||||
// is not advertised in-band. Resource servers integrating with such IdPs
|
||||
// need to opt in to validating the rest of the token (scopes, signature
|
||||
// via the verifier callback, etc.) without requiring the expiration field
|
||||
// to be present.
|
||||
//
|
||||
// When enabled, the verifier is still responsible for any session-level
|
||||
// validity check it can perform; this option only relaxes the middleware's
|
||||
// own expiration enforcement.
|
||||
AllowMissingExpiration bool
|
||||
// ClockSkew bounds the tolerance applied to a token's Expiration when
|
||||
// deciding whether it has elapsed. A token is rejected only if
|
||||
// Expiration + ClockSkew is before the current time. Zero (the default)
|
||||
// preserves strict comparison: any expired token is rejected immediately.
|
||||
//
|
||||
// Resource servers running behind a CDN, in distributed deployments, or
|
||||
// communicating with an authorization server whose clock drifts a few
|
||||
// seconds (common with cloud-managed IdPs) need a small positive value
|
||||
// here to avoid rejecting tokens that are valid by the issuer's clock
|
||||
// but momentarily appear expired by the verifier's. The same tolerance
|
||||
// guards against an issuer's clock running slightly fast at /token
|
||||
// issuance time.
|
||||
ClockSkew time.Duration
|
||||
}
|
||||
|
||||
type tokenInfoKey struct{}
|
||||
|
||||
// TokenInfoFromContext returns the [TokenInfo] stored in ctx, or nil if none.
|
||||
func TokenInfoFromContext(ctx context.Context) *TokenInfo {
|
||||
ti := ctx.Value(tokenInfoKey{})
|
||||
if ti == nil {
|
||||
return nil
|
||||
}
|
||||
return ti.(*TokenInfo)
|
||||
}
|
||||
|
||||
// RequireBearerToken returns a piece of middleware that verifies a bearer token using the verifier.
|
||||
// If verification succeeds, the [TokenInfo] is added to the request's context and the request proceeds.
|
||||
// If verification fails, the request fails with a 401 Unauthenticated, and the WWW-Authenticate header
|
||||
// is populated to enable [protected resource metadata].
|
||||
//
|
||||
// [protected resource metadata]: https://datatracker.ietf.org/doc/rfc9728
|
||||
func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions) func(http.Handler) http.Handler {
|
||||
// Based on typescript-sdk/src/server/auth/middleware/bearerAuth.ts.
|
||||
|
||||
return func(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenInfo, errmsg, code := verify(r, verifier, opts)
|
||||
if code != 0 {
|
||||
if code == http.StatusUnauthorized || code == http.StatusForbidden {
|
||||
if opts != nil {
|
||||
var params []string
|
||||
if opts.ResourceMetadataURL != "" {
|
||||
params = append(params, fmt.Sprintf("resource_metadata=%q", opts.ResourceMetadataURL))
|
||||
}
|
||||
if len(opts.Scopes) > 0 {
|
||||
params = append(params, fmt.Sprintf("scope=%q", strings.Join(opts.Scopes, " ")))
|
||||
}
|
||||
if len(params) > 0 {
|
||||
w.Header().Add("WWW-Authenticate", "Bearer "+strings.Join(params, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Error(w, errmsg, code)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(context.WithValue(r.Context(), tokenInfoKey{}, tokenInfo))
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenOptions) (_ *TokenInfo, errmsg string, code int) {
|
||||
// Extract bearer token.
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
fields := strings.Fields(authHeader)
|
||||
if len(fields) != 2 || strings.ToLower(fields[0]) != "bearer" {
|
||||
return nil, "no bearer token", http.StatusUnauthorized
|
||||
}
|
||||
|
||||
// Verify the token and get information from it.
|
||||
tokenInfo, err := verifier(req.Context(), fields[1], req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidToken) {
|
||||
return nil, err.Error(), http.StatusUnauthorized
|
||||
}
|
||||
if errors.Is(err, ErrOAuth) {
|
||||
return nil, err.Error(), http.StatusBadRequest
|
||||
}
|
||||
return nil, err.Error(), http.StatusInternalServerError
|
||||
}
|
||||
if tokenInfo == nil {
|
||||
return nil, "token validation failed", http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// Check scopes. All must be present.
|
||||
if opts != nil {
|
||||
// Note: quadratic, but N is small.
|
||||
for _, s := range opts.Scopes {
|
||||
if !slices.Contains(tokenInfo.Scopes, s) {
|
||||
return nil, "insufficient scope", http.StatusForbidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
opts = &RequireBearerTokenOptions{}
|
||||
}
|
||||
// Check expiration, with optional clock-skew tolerance. Skew only applies
|
||||
// when an expiration is present; a missing expiration is governed solely by
|
||||
// AllowMissingExpiration.
|
||||
if tokenInfo.Expiration.IsZero() {
|
||||
if !opts.AllowMissingExpiration {
|
||||
return nil, "token missing expiration", http.StatusUnauthorized
|
||||
}
|
||||
} else if tokenInfo.Expiration.Add(opts.ClockSkew).Before(time.Now()) {
|
||||
return nil, "token expired", http.StatusUnauthorized
|
||||
}
|
||||
return tokenInfo, "", 0
|
||||
}
|
||||
|
||||
// ProtectedResourceMetadataHandler returns an http.Handler that serves OAuth 2.0
|
||||
// protected resource metadata (RFC 9728) with CORS support.
|
||||
//
|
||||
// This handler allows cross-origin requests from any origin (Access-Control-Allow-Origin: *)
|
||||
// because OAuth metadata is public information intended for client discovery (RFC 9728 §3.1).
|
||||
// The metadata contains only non-sensitive configuration data about authorization servers
|
||||
// and supported scopes.
|
||||
//
|
||||
// No validation of metadata fields is performed; ensure metadata accuracy at configuration time.
|
||||
//
|
||||
// For more sophisticated CORS policies or to restrict origins, wrap this handler with a
|
||||
// CORS middleware like github.com/rs/cors or github.com/jub0bs/cors.
|
||||
func ProtectedResourceMetadataHandler(metadata *oauthex.ProtectedResourceMetadata) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set CORS headers for cross-origin client discovery.
|
||||
// OAuth metadata is public information, so allowing any origin is safe.
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
// Handle CORS preflight requests
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Only GET allowed for metadata retrieval
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(metadata); err != nil {
|
||||
http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
+684
@@ -0,0 +1,684 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// ClientIDMetadataDocumentConfig is used to configure the Client ID Metadata Document
|
||||
// based client registration per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents.
|
||||
// See https://client.dev/ for more information.
|
||||
type ClientIDMetadataDocumentConfig struct {
|
||||
// URL is the client identifier URL as per
|
||||
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3.
|
||||
URL string
|
||||
}
|
||||
|
||||
// DynamicClientRegistrationConfig is used to configure dynamic client registration per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration.
|
||||
type DynamicClientRegistrationConfig struct {
|
||||
// Metadata to be used in dynamic client registration request as per
|
||||
// https://datatracker.ietf.org/doc/html/rfc7591#section-2.
|
||||
//
|
||||
// If Metadata.ApplicationType is empty, it will be inferred from
|
||||
// Metadata.RedirectURIs. When set, it will be validated against the inferred type
|
||||
// and an error will be returned if they conflict.
|
||||
Metadata *oauthex.ClientRegistrationMetadata
|
||||
}
|
||||
|
||||
// AuthorizationResult is the result of an authorization flow.
|
||||
// It is returned by [AuthorizationCodeHandler].AuthorizationCodeFetcher implementations.
|
||||
type AuthorizationResult struct {
|
||||
// Code is the authorization code obtained from the authorization server.
|
||||
Code string
|
||||
// State string returned by the authorization server.
|
||||
State string
|
||||
// Iss is the issuer identifier returned by the authorization server in the
|
||||
// authorization response per [RFC 9207]. The AuthorizationCodeFetcher should
|
||||
// populate this from the "iss" query parameter in the redirect URI if present.
|
||||
//
|
||||
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
|
||||
Iss string
|
||||
}
|
||||
|
||||
// AuthorizationArgs is the input to [AuthorizationCodeFetcher].
|
||||
type AuthorizationArgs struct {
|
||||
// Authorization URL to be opened in a browser for the user to start the authorization process.
|
||||
URL string
|
||||
}
|
||||
|
||||
// AuthorizationCodeFetcher is called to initiate the OAuth authorization flow.
|
||||
// It is responsible for directing the user to the authorization URL (e.g., opening
|
||||
// in a browser) and returning the authorization code and state once the Authorization
|
||||
// Server redirects back to the configured RedirectURL.
|
||||
type AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error)
|
||||
|
||||
// AuthorizationCodeHandlerConfig is the configuration for [AuthorizationCodeHandler].
|
||||
type AuthorizationCodeHandlerConfig struct {
|
||||
// Client registration configuration.
|
||||
// It is attempted in the following order:
|
||||
// 1. Client ID Metadata Document
|
||||
// 2. Preregistration
|
||||
// 3. Dynamic Client Registration
|
||||
// At least one method must be configured.
|
||||
ClientIDMetadataDocumentConfig *ClientIDMetadataDocumentConfig
|
||||
PreregisteredClient *oauthex.ClientCredentials
|
||||
DynamicClientRegistrationConfig *DynamicClientRegistrationConfig
|
||||
|
||||
// RedirectURL is a required URL to redirect to after authorization.
|
||||
// The caller is responsible for handling the redirect out of band.
|
||||
//
|
||||
// If Dynamic Client Registration is used:
|
||||
// - this field is permitted to be empty, in which case it will be set
|
||||
// to the first redirect URI from
|
||||
// DynamicClientRegistrationConfig.Metadata.RedirectURIs.
|
||||
// - if the field is not empty, it must be one of the redirect URIs in
|
||||
// DynamicClientRegistrationConfig.Metadata.RedirectURIs.
|
||||
RedirectURL string
|
||||
|
||||
// AuthorizationCodeFetcher is a required function called to initiate the authorization flow.
|
||||
// See [AuthorizationCodeFetcher] for details.
|
||||
AuthorizationCodeFetcher AuthorizationCodeFetcher
|
||||
|
||||
// RequestRefreshToken indicates that the client intends to use refresh
|
||||
// tokens and is capable of storing them securely.
|
||||
//
|
||||
// When true and the Authorization Server metadata contains "offline_access"
|
||||
// in its scopes_supported, the client adds "offline_access" to the
|
||||
// requested scopes.
|
||||
//
|
||||
// When using Dynamic Client Registration, callers should include
|
||||
// "refresh_token" in [DynamicClientRegistrationConfig].Metadata.GrantTypes
|
||||
// directly to advertise refresh token support to the Authorization Server.
|
||||
//
|
||||
// When using Client ID Metadata Document, the document hosted at the
|
||||
// Client ID URL should include "refresh_token" in its grant_types.
|
||||
//
|
||||
// See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance.
|
||||
RequestRefreshToken bool
|
||||
|
||||
// Client is an optional HTTP client to use for HTTP requests.
|
||||
// It is used for the following requests:
|
||||
// - Fetching Protected Resource Metadata
|
||||
// - Fetching Authorization Server Metadata
|
||||
// - Registering a client dynamically
|
||||
// - Exchanging an authorization code for an access token
|
||||
// - Refreshing an access token
|
||||
// Custom clients can include additional security configurations,
|
||||
// such as SSRF protections, see
|
||||
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
|
||||
// If not provided, http.DefaultClient will be used.
|
||||
Client *http.Client
|
||||
|
||||
// NewTokenSource is an optional function that can be set to construct the
|
||||
// token source that will be used by the [AuthorizationCodeHandler]. If
|
||||
// non-nil, it is called after the authorization code is successfully
|
||||
// exchanged for a token in [AuthorizationCodeHandler.Authorize]
|
||||
// to obtain the [oauth2.TokenSource] returned by
|
||||
// [AuthorizationCodeHandler.TokenSource]. Implementations must use the
|
||||
// provided context, which is properly configured for constructing a
|
||||
// TokenSource. The default is to call [oauth2.Config.TokenSource].
|
||||
NewTokenSource func(context.Context, *oauth2.Config, *oauth2.Token) (oauth2.TokenSource, error)
|
||||
|
||||
// InitialTokenSource is an optional field that can be set to inject the
|
||||
// token source that will be used by the [AuthorizationCodeHandler]. If
|
||||
// non-nil, it is set as the token source that will be returned by
|
||||
// [AuthorizationCodeHandler.TokenSource] during handler initialization.
|
||||
// The default is nil, which means no token source has been set initially,
|
||||
// and will trigger a call to [AuthorizationCodeHandler.Authorize].
|
||||
InitialTokenSource oauth2.TokenSource
|
||||
}
|
||||
|
||||
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
|
||||
// the authorization code flow to obtain access tokens.
|
||||
type AuthorizationCodeHandler struct {
|
||||
config *AuthorizationCodeHandlerConfig
|
||||
|
||||
// mu protects concurrent access to tokenSource and grantedScopes.
|
||||
mu sync.RWMutex
|
||||
|
||||
// tokenSource is the token source to use for authorization.
|
||||
tokenSource oauth2.TokenSource
|
||||
|
||||
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
|
||||
grantedScopes map[string][]string
|
||||
}
|
||||
|
||||
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
|
||||
|
||||
func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.tokenSource, nil
|
||||
}
|
||||
|
||||
// NewAuthorizationCodeHandler creates a new AuthorizationCodeHandler.
|
||||
// It performs validation of the configuration and returns an error if it is invalid.
|
||||
// The passed config is consumed by the handler and should not be modified after.
|
||||
func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*AuthorizationCodeHandler, error) {
|
||||
if config == nil {
|
||||
return nil, errors.New("config must be provided")
|
||||
}
|
||||
if config.ClientIDMetadataDocumentConfig == nil &&
|
||||
config.PreregisteredClient == nil &&
|
||||
config.DynamicClientRegistrationConfig == nil {
|
||||
return nil, errors.New("at least one client registration configuration must be provided")
|
||||
}
|
||||
if config.AuthorizationCodeFetcher == nil {
|
||||
return nil, errors.New("AuthorizationCodeFetcher is required")
|
||||
}
|
||||
if config.ClientIDMetadataDocumentConfig != nil && !isNonRootHTTPSURL(config.ClientIDMetadataDocumentConfig.URL) {
|
||||
return nil, fmt.Errorf("client ID metadata document URL must be a non-root HTTPS URL")
|
||||
}
|
||||
if config.PreregisteredClient != nil {
|
||||
if err := config.PreregisteredClient.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid PreregisteredClient configuration: %w", err)
|
||||
}
|
||||
}
|
||||
dCfg := config.DynamicClientRegistrationConfig
|
||||
if dCfg != nil {
|
||||
if dCfg.Metadata == nil {
|
||||
return nil, errors.New("dynamic client registration requires non-nil Metadata")
|
||||
}
|
||||
if len(dCfg.Metadata.RedirectURIs) == 0 {
|
||||
return nil, errors.New("Metadata.RedirectURIs is required for dynamic client registration")
|
||||
}
|
||||
if config.RedirectURL == "" {
|
||||
config.RedirectURL = dCfg.Metadata.RedirectURIs[0]
|
||||
} else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) {
|
||||
return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL)
|
||||
}
|
||||
applicationType := inferApplicationType(dCfg.Metadata.RedirectURIs)
|
||||
if dCfg.Metadata.ApplicationType == "" {
|
||||
dCfg.Metadata.ApplicationType = applicationType
|
||||
} else if dCfg.Metadata.ApplicationType != applicationType {
|
||||
return nil, fmt.Errorf("application type %q conflicts with the application type inferred from redirect URIs", dCfg.Metadata.ApplicationType)
|
||||
}
|
||||
}
|
||||
if config.RedirectURL == "" {
|
||||
// If the RedirectURL was supposed to be set by the dynamic client registration,
|
||||
// it should have been set by now. Otherwise, it is required.
|
||||
return nil, errors.New("RedirectURL is required")
|
||||
}
|
||||
if config.Client == nil {
|
||||
config.Client = http.DefaultClient
|
||||
}
|
||||
return &AuthorizationCodeHandler{
|
||||
config: config,
|
||||
tokenSource: config.InitialTokenSource,
|
||||
grantedScopes: make(map[string][]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isNonRootHTTPSURL(u string) bool {
|
||||
pu, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return pu.Scheme == "https" && pu.Path != ""
|
||||
}
|
||||
|
||||
// inferApplicationType returns an application type based on the redirect URIs.
|
||||
func inferApplicationType(redirectURIs []string) string {
|
||||
hasNative := false
|
||||
hasWeb := false
|
||||
for _, uri := range redirectURIs {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
if util.IsLoopback(u.Hostname()) {
|
||||
hasNative = true
|
||||
} else {
|
||||
hasWeb = true
|
||||
}
|
||||
default:
|
||||
hasNative = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasNative && hasWeb {
|
||||
return ""
|
||||
}
|
||||
if hasNative {
|
||||
return "native"
|
||||
}
|
||||
return "web"
|
||||
}
|
||||
|
||||
// Authorize performs the authorization flow.
|
||||
// It is designed to perform the whole Authorization Code Grant flow.
|
||||
// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token.
|
||||
func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
|
||||
defer resp.Body.Close()
|
||||
defer io.Copy(io.Discard, resp.Body)
|
||||
|
||||
wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden && errorFromChallenges(wwwChallenges) != "insufficient_scope" {
|
||||
// We only want to perform step-up authorization for insufficient_scope errors.
|
||||
// Returning nil, so that the call is retried immediately and the response
|
||||
// is handled appropriately by the connection.
|
||||
// Step-up authorization is defined at
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow
|
||||
return nil
|
||||
}
|
||||
|
||||
prm, err := h.getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
asm, err := GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], h.config.Client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get authorization server metadata: %w", err)
|
||||
}
|
||||
if asm == nil {
|
||||
// Fallback to 2025-03-26 spec: predefined endpoints.
|
||||
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
||||
authServerURL := prm.AuthorizationServers[0]
|
||||
asm = &oauthex.AuthServerMeta{
|
||||
Issuer: authServerURL,
|
||||
AuthorizationEndpoint: authServerURL + "/authorize",
|
||||
TokenEndpoint: authServerURL + "/token",
|
||||
RegistrationEndpoint: authServerURL + "/register",
|
||||
}
|
||||
}
|
||||
|
||||
resolvedClientConfig, err := h.handleRegistration(ctx, asm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestedScopes := scopesFromChallenges(wwwChallenges)
|
||||
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
|
||||
requestedScopes = prm.ScopesSupported
|
||||
}
|
||||
|
||||
// SEP-2207: when the client desires refresh tokens and the Authorization
|
||||
// Server advertises offline_access support, add it to the requested scopes.
|
||||
if h.config.RequestRefreshToken &&
|
||||
slices.Contains(asm.ScopesSupported, "offline_access") &&
|
||||
!slices.Contains(requestedScopes, "offline_access") {
|
||||
requestedScopes = append(requestedScopes, "offline_access")
|
||||
}
|
||||
|
||||
// Accumulate scopes: union previously granted scopes with the newly
|
||||
// challenged scopes so that step-up authorization does not lose
|
||||
// permissions granted in earlier rounds (SEP-2350).
|
||||
h.mu.RLock()
|
||||
granted := h.grantedScopes[asm.Issuer]
|
||||
h.mu.RUnlock()
|
||||
requestedScopes = authutil.UnionScopes(granted, requestedScopes)
|
||||
|
||||
cfg := &oauth2.Config{
|
||||
ClientID: resolvedClientConfig.clientID,
|
||||
ClientSecret: resolvedClientConfig.clientSecret,
|
||||
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: asm.AuthorizationEndpoint,
|
||||
TokenURL: asm.TokenEndpoint,
|
||||
AuthStyle: resolvedClientConfig.authStyle,
|
||||
},
|
||||
RedirectURL: h.config.RedirectURL,
|
||||
Scopes: requestedScopes,
|
||||
}
|
||||
|
||||
authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
|
||||
if err != nil {
|
||||
// Purposefully leaving the error unwrappable so it can be handled by the caller.
|
||||
return err
|
||||
}
|
||||
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
|
||||
}
|
||||
|
||||
// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges,
|
||||
// or the empty string if there is none.
|
||||
func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string {
|
||||
for _, c := range cs {
|
||||
if u := c.Params["resource_metadata"]; u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scopesFromChallenges returns the scopes from the given "WWW-Authenticate" header challenges.
|
||||
// It only looks at challenges with the "Bearer" scheme.
|
||||
func scopesFromChallenges(cs []oauthex.Challenge) []string {
|
||||
for _, c := range cs {
|
||||
if c.Scheme == "bearer" && c.Params["scope"] != "" {
|
||||
return strings.Fields(c.Params["scope"])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// errorFromChallenges returns the error from the given "WWW-Authenticate" header challenges.
|
||||
// It only looks at challenges with the "Bearer" scheme.
|
||||
func errorFromChallenges(cs []oauthex.Challenge) string {
|
||||
for _, c := range cs {
|
||||
if c.Scheme == "bearer" && c.Params["error"] != "" {
|
||||
return c.Params["error"]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getProtectedResourceMetadata returns the protected resource metadata.
|
||||
// If no metadata was found or the fetched metadata fails security checks,
|
||||
// it returns an error.
|
||||
func (h *AuthorizationCodeHandler) getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string) (*oauthex.ProtectedResourceMetadata, error) {
|
||||
// Use MCP server URL as the resource URI per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri.
|
||||
for _, url := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) {
|
||||
prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, h.config.Client)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if prm == nil {
|
||||
continue
|
||||
}
|
||||
if len(prm.AuthorizationServers) == 0 {
|
||||
// If we found PRM, we enforce the 2025-11-25 spec and not search further.
|
||||
return nil, fmt.Errorf("protected resource metadata has no authorization servers specified")
|
||||
}
|
||||
return prm, nil
|
||||
}
|
||||
// Fallback to 2025-03-26 spec MCP server root is the Authorization Server:
|
||||
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery
|
||||
u, err := url.Parse(mcpServerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse MCP server URL: %v", err)
|
||||
}
|
||||
u.Path = ""
|
||||
prm := &oauthex.ProtectedResourceMetadata{
|
||||
AuthorizationServers: []string{u.String()},
|
||||
Resource: mcpServerURL,
|
||||
}
|
||||
return prm, nil
|
||||
}
|
||||
|
||||
type prmURL struct {
|
||||
// URL represents a URL where Protected Resource Metadata may be retrieved.
|
||||
URL string
|
||||
// Resource represents the corresponding resource URL for [URL].
|
||||
// It is required to perform validation described in RFC 9728, section 3.3.
|
||||
Resource string
|
||||
}
|
||||
|
||||
// protectedResourceMetadataURLs returns a list of URLs to try when looking for
|
||||
// protected resource metadata as mandated by the MCP specification:
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
|
||||
func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL {
|
||||
var urls []prmURL
|
||||
if metadataURL != "" {
|
||||
urls = append(urls, prmURL{
|
||||
URL: metadataURL,
|
||||
Resource: resourceURL,
|
||||
})
|
||||
}
|
||||
ru, err := url.Parse(resourceURL)
|
||||
if err != nil {
|
||||
return urls
|
||||
}
|
||||
mu := *ru
|
||||
// "At the path of the server's MCP endpoint".
|
||||
mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/")
|
||||
urls = append(urls, prmURL{
|
||||
URL: mu.String(),
|
||||
Resource: resourceURL,
|
||||
})
|
||||
// "At the root".
|
||||
mu.Path = "/.well-known/oauth-protected-resource"
|
||||
ru.Path = ""
|
||||
urls = append(urls, prmURL{
|
||||
URL: mu.String(),
|
||||
Resource: ru.String(),
|
||||
})
|
||||
return urls
|
||||
}
|
||||
|
||||
type registrationType int
|
||||
|
||||
const (
|
||||
registrationTypeClientIDMetadataDocument registrationType = iota
|
||||
registrationTypePreregistered
|
||||
registrationTypeDynamic
|
||||
)
|
||||
|
||||
type resolvedClientConfig struct {
|
||||
registrationType registrationType
|
||||
clientID string
|
||||
clientSecret string
|
||||
authStyle oauth2.AuthStyle
|
||||
}
|
||||
|
||||
func selectTokenAuthMethod(supported []string) oauth2.AuthStyle {
|
||||
prefOrder := []string{
|
||||
// Preferred in OAuth 2.1 draft: https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-14.html#name-client-secret.
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
}
|
||||
for _, method := range prefOrder {
|
||||
if slices.Contains(supported, method) {
|
||||
return authMethodToStyle(method)
|
||||
}
|
||||
}
|
||||
return oauth2.AuthStyleAutoDetect
|
||||
}
|
||||
|
||||
func authMethodToStyle(method string) oauth2.AuthStyle {
|
||||
switch method {
|
||||
case "client_secret_post":
|
||||
return oauth2.AuthStyleInParams
|
||||
case "client_secret_basic":
|
||||
return oauth2.AuthStyleInHeader
|
||||
case "none":
|
||||
// "none" is equivalent to "client_secret_post" but without sending client secret.
|
||||
return oauth2.AuthStyleInParams
|
||||
default:
|
||||
// "client_secret_basic" is the default per https://datatracker.ietf.org/doc/html/rfc7591#section-2.
|
||||
return oauth2.AuthStyleInHeader
|
||||
}
|
||||
}
|
||||
|
||||
// handleRegistration handles client registration.
|
||||
// The provided authorization server metadata must be non-nil.
|
||||
// Support for different registration methods is defined as follows:
|
||||
// - Client ID Metadata Document: metadata must have
|
||||
// `ClientIDMetadataDocumentSupported` set to true.
|
||||
// - Pre-registered client: assumed to be supported.
|
||||
// - Dynamic client registration: metadata must have
|
||||
// `RegistrationEndpoint` set to a non-empty value.
|
||||
func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm *oauthex.AuthServerMeta) (*resolvedClientConfig, error) {
|
||||
// 1. Attempt to use Client ID Metadata Document (SEP-991).
|
||||
cimdCfg := h.config.ClientIDMetadataDocumentConfig
|
||||
if cimdCfg != nil && asm.ClientIDMetadataDocumentSupported {
|
||||
return &resolvedClientConfig{
|
||||
registrationType: registrationTypeClientIDMetadataDocument,
|
||||
clientID: cimdCfg.URL,
|
||||
}, nil
|
||||
}
|
||||
// 2. Attempt to use pre-registered client configuration.
|
||||
preCfg := h.config.PreregisteredClient
|
||||
if preCfg != nil {
|
||||
if preCfg.Issuer != "" && !authutil.IssuersEqual(preCfg.Issuer, asm.Issuer) {
|
||||
return nil, fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, preCfg.Issuer)
|
||||
}
|
||||
authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported)
|
||||
clientSecret := ""
|
||||
if preCfg.ClientSecretAuth != nil {
|
||||
clientSecret = preCfg.ClientSecretAuth.ClientSecret
|
||||
}
|
||||
return &resolvedClientConfig{
|
||||
registrationType: registrationTypePreregistered,
|
||||
clientID: preCfg.ClientID,
|
||||
clientSecret: clientSecret,
|
||||
authStyle: authStyle,
|
||||
}, nil
|
||||
}
|
||||
// 3. Attempt to use dynamic client registration.
|
||||
dcrCfg := h.config.DynamicClientRegistrationConfig
|
||||
if dcrCfg != nil && asm.RegistrationEndpoint != "" {
|
||||
regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, h.config.Client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register client: %w", err)
|
||||
}
|
||||
cfg := &resolvedClientConfig{
|
||||
registrationType: registrationTypeDynamic,
|
||||
clientID: regResp.ClientID,
|
||||
clientSecret: regResp.ClientSecret,
|
||||
authStyle: authMethodToStyle(regResp.TokenEndpointAuthMethod),
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no configured client registration methods are supported by the authorization server")
|
||||
}
|
||||
|
||||
type authResult struct {
|
||||
*AuthorizationResult
|
||||
// usedCodeVerifier is the PKCE code verifier used to obtain the authorization code.
|
||||
// It is preserved for the token exchange step.
|
||||
usedCodeVerifier string
|
||||
}
|
||||
|
||||
// getAuthorizationCode uses the [AuthorizationCodeHandler.AuthorizationCodeFetcher]
|
||||
// to obtain an authorization code.
|
||||
func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg *oauth2.Config, resourceURL string) (*authResult, error) {
|
||||
codeVerifier := oauth2.GenerateVerifier()
|
||||
state := rand.Text()
|
||||
|
||||
authURL := cfg.AuthCodeURL(state,
|
||||
oauth2.S256ChallengeOption(codeVerifier),
|
||||
oauth2.SetAuthURLParam("resource", resourceURL),
|
||||
)
|
||||
|
||||
authRes, err := h.config.AuthorizationCodeFetcher(ctx, &AuthorizationArgs{URL: authURL})
|
||||
if err != nil {
|
||||
// Purposefully leaving the error unwrappable so it can be handled by the caller.
|
||||
return nil, err
|
||||
}
|
||||
if authRes.State != state {
|
||||
return nil, fmt.Errorf("state mismatch")
|
||||
}
|
||||
return &authResult{
|
||||
AuthorizationResult: authRes,
|
||||
usedCodeVerifier: codeVerifier,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateIssuerResponse validates the "iss" parameter in an authorization response
|
||||
// per [RFC 9207].
|
||||
//
|
||||
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
|
||||
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error {
|
||||
if issParameterSupported {
|
||||
if iss == "" {
|
||||
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
|
||||
}
|
||||
if iss != expectedIssuer {
|
||||
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
|
||||
}
|
||||
} else {
|
||||
if iss != "" {
|
||||
return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// exchangeAuthorizationCode exchanges the authorization code for a token
|
||||
// and stores it in a token source.
|
||||
func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error {
|
||||
opts := []oauth2.AuthCodeOption{
|
||||
oauth2.VerifierOption(authResult.usedCodeVerifier),
|
||||
oauth2.SetAuthURLParam("resource", resourceURL),
|
||||
}
|
||||
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client)
|
||||
token, err := cfg.Exchange(clientCtx, authResult.Code, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token exchange failed: %w", err)
|
||||
}
|
||||
// The token source outlives this authorization request: it is stored on the
|
||||
// handler and used by the transport for the lifetime of the connection. The
|
||||
// oauth2 library captures the context passed to TokenSource and reuses it for
|
||||
// every subsequent token refresh (see golang.org/x/oauth2: tokenRefresher
|
||||
// retains the context and passes it to each refresh round-trip). Binding it to
|
||||
// the per-request ctx makes all later refreshes fail with "context canceled"
|
||||
// once that request (or the connect operation that triggered authorization)
|
||||
// completes. Use a background context that still carries the configured HTTP
|
||||
// client so refreshes keep working for the life of the token source.
|
||||
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
|
||||
var ts oauth2.TokenSource
|
||||
if h.config.NewTokenSource == nil {
|
||||
ts = cfg.TokenSource(refreshCtx, token)
|
||||
} else {
|
||||
var err error
|
||||
ts, err = h.config.NewTokenSource(refreshCtx, cfg, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("constructing token source failed: %w", err)
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.tokenSource = ts
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateGrantedScopes updates the granted scopes based on the token source and requested scopes.
|
||||
func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
|
||||
h.mu.RLock()
|
||||
ts := h.tokenSource
|
||||
h.mu.RUnlock()
|
||||
|
||||
if ts == nil {
|
||||
return nil
|
||||
}
|
||||
tok, err := ts.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
|
||||
h.grantedScopes[issuer] = requestedScopes
|
||||
} else {
|
||||
h.grantedScopes[issuer] = tokenScopes
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OAuthHandler is an interface for handling OAuth flows.
|
||||
//
|
||||
// If a transport wishes to support OAuth 2 authorization, it should support
|
||||
// being configured with an OAuthHandler. It should call the handler's
|
||||
// TokenSource method whenever it sends an HTTP request to set the
|
||||
// Authorization header. If a request fails with a 401 or 403, it should call
|
||||
// Authorize, and if that returns nil, it should retry the request. It should
|
||||
// not call Authorize after the second failure. See
|
||||
// [github.com/modelcontextprotocol/go-sdk/mcp.StreamableClientTransport]
|
||||
// for an example.
|
||||
type OAuthHandler interface {
|
||||
// TokenSource returns a token source to be used for outgoing requests.
|
||||
// Returned token source might be nil. In that case, the transport will not
|
||||
// add any authorization headers to the request.
|
||||
TokenSource(context.Context) (oauth2.TokenSource, error)
|
||||
|
||||
// Authorize is called when an HTTP request results in an error that may
|
||||
// be addressed by the authorization flow (currently 401 Unauthorized and 403 Forbidden).
|
||||
// It is responsible for performing the OAuth flow to obtain an access token.
|
||||
// The arguments are the request that failed and the response that was received for it.
|
||||
// The headers of the request are available, but the body will have already been consumed
|
||||
// when Authorize is called.
|
||||
// If the returned error is nil, TokenSource is expected to return a non-nil token source.
|
||||
// After a successful call to Authorize, the HTTP request will be retried by the transport.
|
||||
// The function is responsible for closing the response body.
|
||||
Authorize(context.Context, *http.Request, *http.Response) error
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file contains shared utilities for OAuth handlers.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
)
|
||||
|
||||
// GetAuthServerMetadata fetches authorization server metadata for the given issuer URL.
|
||||
// It tries standard well-known endpoints (OAuth 2.0 and OIDC) and returns the first successful result.
|
||||
//
|
||||
// Returns (nil, nil) when no metadata endpoints respond (404s), allowing callers to implement
|
||||
// fallback logic. Returns an error for any non-client error (network failures, invalid JSON, etc.).
|
||||
func GetAuthServerMetadata(ctx context.Context, issuerURL string, httpClient *http.Client) (*oauthex.AuthServerMeta, error) {
|
||||
for _, metadataURL := range authorizationServerMetadataURLs(issuerURL) {
|
||||
asm, err := oauthex.GetAuthServerMeta(ctx, metadataURL, issuerURL, httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if asm != nil {
|
||||
return asm, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// authorizationServerMetadataURLs returns a list of URLs to try when looking for
|
||||
// authorization server metadata as mandated by the MCP specification:
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery.
|
||||
func authorizationServerMetadataURLs(issuerURL string) []string {
|
||||
var urls []string
|
||||
|
||||
baseURL, err := url.Parse(issuerURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if baseURL.Path == "" {
|
||||
// "OAuth 2.0 Authorization Server Metadata".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server"
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0".
|
||||
baseURL.Path = "/.well-known/openid-configuration"
|
||||
urls = append(urls, baseURL.String())
|
||||
return urls
|
||||
}
|
||||
|
||||
originalPath := baseURL.Path
|
||||
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path insertion".
|
||||
baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path appending".
|
||||
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
|
||||
urls = append(urls, baseURL.String())
|
||||
|
||||
return urls
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
package authutil
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// UnionScopes returns the union of the existing and challenged scope sets.
|
||||
// It is used during step-up authorization to accumulate scopes across
|
||||
// authorization rounds (SEP-2350).
|
||||
func UnionScopes(existing, challenged []string) []string {
|
||||
combined := make(map[string]struct{})
|
||||
for _, s := range existing {
|
||||
combined[s] = struct{}{}
|
||||
}
|
||||
for _, s := range challenged {
|
||||
combined[s] = struct{}{}
|
||||
}
|
||||
return slices.Collect(maps.Keys(combined))
|
||||
}
|
||||
|
||||
// ScopesFromToken extracts the granted scopes from an OAuth2 token response.
|
||||
// Per RFC 6749 §5.1, the scope parameter is optional; returns nil if absent.
|
||||
func ScopesFromToken(token *oauth2.Token) []string {
|
||||
scope, ok := token.Extra("scope").(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(scope)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package authutil
|
||||
|
||||
import "strings"
|
||||
|
||||
// IssuersEqual reports whether two OAuth 2.0 authorization server issuer
|
||||
// identifiers refer to the same server comparing them without the final trailing slash.
|
||||
func IssuersEqual(a, b string) bool {
|
||||
return strings.TrimSuffix(a, "/") == strings.TrimSuffix(b, "/")
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
// Package json provides internal JSON utilities.
|
||||
|
||||
package json
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
type Decoder struct {
|
||||
dec *json.Decoder
|
||||
}
|
||||
|
||||
func NewDecoder(r io.Reader) *Decoder {
|
||||
dec := json.NewDecoder(r)
|
||||
dec.DontMatchCaseInsensitiveStructFields()
|
||||
return &Decoder{dec: dec}
|
||||
}
|
||||
|
||||
func (d *Decoder) Decode(v any) error {
|
||||
return d.dec.Decode(v)
|
||||
}
|
||||
|
||||
func Unmarshal(data []byte, v any) error {
|
||||
return NewDecoder(bytes.NewReader(data)).Decode(v)
|
||||
}
|
||||
+820
@@ -0,0 +1,820 @@
|
||||
// Copyright 2018 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonrpc2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug"
|
||||
)
|
||||
|
||||
// nomethodnotfoundcodeinerror is a compatibility parameter that restores the
|
||||
// pre-fix behavior of [processResult], where wrapped [ErrNotHandled] or
|
||||
// [ErrMethodNotFound] errors returned by request handlers were not
|
||||
// recognized as "method not found" signals. The original switch statement
|
||||
// compared sentinel errors with ==, which never matched errors returned via
|
||||
// fmt.Errorf("%w: ...", ErrNotHandled, ...) — including the ones produced
|
||||
// by checkRequest. As a result the wire error response carried code 0
|
||||
// instead of code -32601. The fix uses errors.Is to recognize wrapped
|
||||
// sentinels and append the method name to the message.
|
||||
//
|
||||
// To restore the previous behavior, set MCPGODEBUG=nomethodnotfoundcodeinerror=1.
|
||||
// This option will be removed in a future SDK version.
|
||||
// See the documentation for the mcpgodebug package for instructions on how
|
||||
// to use it.
|
||||
var nomethodnotfoundcodeinerror = mcpgodebug.Value("nomethodnotfoundcodeinerror")
|
||||
|
||||
// Connection manages the jsonrpc2 protocol, connecting responses back to their
|
||||
// calls. Connection is bidirectional; it does not have a designated server or
|
||||
// client end.
|
||||
//
|
||||
// Note that the word 'Connection' is overloaded: the mcp.Connection represents
|
||||
// the bidirectional stream of messages between client an server. The
|
||||
// jsonrpc2.Connection layers RPC logic on top of that stream, dispatching RPC
|
||||
// handlers, and correlating requests with responses from the peer.
|
||||
//
|
||||
// Some of the complexity of the Connection type is grown out of its usage in
|
||||
// gopls: it could probably be simplified based on our usage in MCP.
|
||||
type Connection struct {
|
||||
seq int64 // must only be accessed using atomic operations
|
||||
|
||||
stateMu sync.Mutex
|
||||
state inFlightState // accessed only in updateInFlight
|
||||
done chan struct{} // closed (under stateMu) when state.closed is true and all goroutines have completed
|
||||
|
||||
writer Writer
|
||||
handler Handler
|
||||
|
||||
onInternalError func(error)
|
||||
onDone func()
|
||||
}
|
||||
|
||||
// inFlightState records the state of the incoming and outgoing calls on a
|
||||
// Connection.
|
||||
type inFlightState struct {
|
||||
connClosing bool // true when the Connection's Close method has been called
|
||||
reading bool // true while the readIncoming goroutine is running
|
||||
readErr error // non-nil when the readIncoming goroutine exits (typically io.EOF)
|
||||
writeErr error // non-nil if a call to the Writer has failed with a non-canceled Context
|
||||
|
||||
// closer shuts down and cleans up the Reader and Writer state, ideally
|
||||
// interrupting any Read or Write call that is currently blocked. It is closed
|
||||
// when the state is idle and one of: connClosing is true, readErr is non-nil,
|
||||
// or writeErr is non-nil.
|
||||
//
|
||||
// After the closer has been invoked, the closer field is set to nil
|
||||
// and the closeErr field is simultaneously set to its result.
|
||||
closer io.Closer
|
||||
closeErr error // error returned from closer.Close
|
||||
|
||||
outgoingCalls map[ID]*AsyncCall // calls only
|
||||
outgoingNotifications int // # of notifications awaiting "write"
|
||||
|
||||
// incoming stores the total number of incoming calls and notifications
|
||||
// that have not yet written or processed a result.
|
||||
incoming int
|
||||
|
||||
incomingByID map[ID]*incomingRequest // calls only
|
||||
|
||||
// handlerQueue stores the backlog of calls and notifications that were not
|
||||
// already handled by a preempter.
|
||||
// The queue does not include the request currently being handled (if any).
|
||||
handlerQueue []*incomingRequest
|
||||
handlerRunning bool
|
||||
}
|
||||
|
||||
// updateInFlight locks the state of the connection's in-flight requests, allows
|
||||
// f to mutate that state, and closes the connection if it is idle and either
|
||||
// is closing or has a read or write error.
|
||||
func (c *Connection) updateInFlight(f func(*inFlightState)) {
|
||||
c.stateMu.Lock()
|
||||
defer c.stateMu.Unlock()
|
||||
|
||||
s := &c.state
|
||||
|
||||
f(s)
|
||||
|
||||
select {
|
||||
case <-c.done:
|
||||
// The connection was already completely done at the start of this call to
|
||||
// updateInFlight, so it must remain so. (The call to f should have noticed
|
||||
// that and avoided making any updates that would cause the state to be
|
||||
// non-idle.)
|
||||
if !s.idle() {
|
||||
panic("jsonrpc2: updateInFlight transitioned to non-idle when already done")
|
||||
}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if s.idle() && s.shuttingDown(ErrUnknown) != nil {
|
||||
if s.closer != nil {
|
||||
s.closeErr = s.closer.Close()
|
||||
s.closer = nil // prevent duplicate Close calls
|
||||
}
|
||||
if s.reading {
|
||||
// The readIncoming goroutine is still running. Our call to Close should
|
||||
// cause it to exit soon, at which point it will make another call to
|
||||
// updateInFlight, set s.reading to false, and mark the Connection done.
|
||||
} else {
|
||||
// The readIncoming goroutine has exited, or never started to begin with.
|
||||
// Since everything else is idle, we're completely done.
|
||||
if c.onDone != nil {
|
||||
c.onDone()
|
||||
}
|
||||
close(c.done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// idle reports whether the connection is in a state with no pending calls or
|
||||
// notifications.
|
||||
//
|
||||
// If idle returns true, the readIncoming goroutine may still be running,
|
||||
// but no other goroutines are doing work on behalf of the connection.
|
||||
func (s *inFlightState) idle() bool {
|
||||
return len(s.outgoingCalls) == 0 && s.outgoingNotifications == 0 && s.incoming == 0 && !s.handlerRunning
|
||||
}
|
||||
|
||||
// shuttingDown reports whether the connection is in a state that should
|
||||
// disallow new (incoming and outgoing) calls. It returns either nil or
|
||||
// an error that is or wraps the provided errClosing.
|
||||
func (s *inFlightState) shuttingDown(errClosing error) error {
|
||||
if s.connClosing {
|
||||
// If Close has been called explicitly, it doesn't matter what state the
|
||||
// Reader and Writer are in: we shouldn't be starting new work because the
|
||||
// caller told us not to start new work.
|
||||
return errClosing
|
||||
}
|
||||
if s.readErr != nil {
|
||||
// If the read side of the connection is broken, we cannot read new call
|
||||
// requests, and cannot read responses to our outgoing calls.
|
||||
return fmt.Errorf("%w: %v", errClosing, s.readErr)
|
||||
}
|
||||
if s.writeErr != nil {
|
||||
// If the write side of the connection is broken, we cannot write responses
|
||||
// for incoming calls, and cannot write requests for outgoing calls.
|
||||
return fmt.Errorf("%w: %v", errClosing, s.writeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// incomingRequest is used to track an incoming request as it is being handled
|
||||
type incomingRequest struct {
|
||||
*Request // the request being processed
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Reader abstracts the transport mechanics from the JSON RPC protocol.
|
||||
// A Connection reads messages from the reader it was provided on construction,
|
||||
// and assumes that each call to Read fully transfers a single message,
|
||||
// or returns an error.
|
||||
//
|
||||
// A reader is not safe for concurrent use, it is expected it will be used by
|
||||
// a single Connection in a safe manner.
|
||||
type Reader interface {
|
||||
// Read gets the next message from the stream.
|
||||
Read(context.Context) (Message, error)
|
||||
}
|
||||
|
||||
// Writer abstracts the transport mechanics from the JSON RPC protocol.
|
||||
// A Connection writes messages using the writer it was provided on construction,
|
||||
// and assumes that each call to Write fully transfers a single message,
|
||||
// or returns an error.
|
||||
//
|
||||
// A writer must be safe for concurrent use, as writes may occur concurrently
|
||||
// in practice: libraries may make calls or respond to requests asynchronously.
|
||||
type Writer interface {
|
||||
// Write sends a message to the stream.
|
||||
Write(context.Context, Message) error
|
||||
}
|
||||
|
||||
// A ConnectionConfig configures a bidirectional jsonrpc2 connection.
|
||||
type ConnectionConfig struct {
|
||||
Reader Reader // required
|
||||
Writer Writer // required
|
||||
Closer io.Closer // required
|
||||
Preempter Preempter // optional
|
||||
Bind func(*Connection) Handler // required
|
||||
OnDone func() // optional
|
||||
OnInternalError func(error) // optional
|
||||
|
||||
// PropagateCancellation controls whether cancellation of the context
|
||||
// passed to [NewConnection] is observable by request handlers.
|
||||
//
|
||||
// By default (false), the connection wraps that context (see [notDone])
|
||||
// so handlers' Done channels do not fire when the connection's root
|
||||
// context is cancelled. Cancellation of an in-flight handler is then
|
||||
// expected to flow only through the jsonrpc2 layer's explicit channels
|
||||
// (the [Preempter] reacting to the peer's cancel notification, or a
|
||||
// transport read/write failure cancelling every in-flight request).
|
||||
//
|
||||
// Set this true when cancellation of the connection's context is itself
|
||||
// a meaningful signal that handlers should react to (for example, when
|
||||
// the connection is tied to a carrier that owns it and whose end means
|
||||
// the request is cancelled).
|
||||
PropagateCancellation bool // optional
|
||||
}
|
||||
|
||||
// NewConnection creates a new [Connection] object and starts processing
|
||||
// incoming messages.
|
||||
func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection {
|
||||
if !cfg.PropagateCancellation {
|
||||
ctx = notDone{ctx}
|
||||
}
|
||||
|
||||
c := &Connection{
|
||||
state: inFlightState{closer: cfg.Closer},
|
||||
done: make(chan struct{}),
|
||||
writer: cfg.Writer,
|
||||
onDone: cfg.OnDone,
|
||||
onInternalError: cfg.OnInternalError,
|
||||
}
|
||||
c.handler = cfg.Bind(c)
|
||||
c.start(ctx, cfg.Reader, cfg.Preempter)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Connection) start(ctx context.Context, reader Reader, preempter Preempter) {
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
select {
|
||||
case <-c.done:
|
||||
// The connection was already closed; don't start a goroutine to read it.
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// The goroutine started here will continue until the underlying stream is closed.
|
||||
s.reading = true
|
||||
go c.readIncoming(ctx, reader, preempter)
|
||||
})
|
||||
}
|
||||
|
||||
// Notify invokes the target method but does not wait for a response.
|
||||
// The params will be marshaled to JSON before sending over the wire, and will
|
||||
// be handed to the method invoked.
|
||||
func (c *Connection) Notify(ctx context.Context, method string, params any) (err error) {
|
||||
attempted := false
|
||||
|
||||
defer func() {
|
||||
if attempted {
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
s.outgoingNotifications--
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
// If the connection is shutting down, allow outgoing notifications only if
|
||||
// there is at least one call still in flight. The number of calls in flight
|
||||
// cannot increase once shutdown begins, and allowing outgoing notifications
|
||||
// may permit notifications that will cancel in-flight calls.
|
||||
if len(s.outgoingCalls) == 0 && len(s.incomingByID) == 0 {
|
||||
err = s.shuttingDown(ErrClientClosing)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
s.outgoingNotifications++
|
||||
attempted = true
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notify, err := NewNotification(method, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling notify parameters: %v", err)
|
||||
}
|
||||
|
||||
return c.write(ctx, notify)
|
||||
}
|
||||
|
||||
// Call invokes the target method and returns an object that can be used to await the response.
|
||||
// The params will be marshaled to JSON before sending over the wire, and will
|
||||
// be handed to the method invoked.
|
||||
// You do not have to wait for the response, it can just be ignored if not needed.
|
||||
// If sending the call failed, the response will be ready and have the error in it.
|
||||
func (c *Connection) Call(ctx context.Context, method string, params any) *AsyncCall {
|
||||
// Generate a new request identifier.
|
||||
id := Int64ID(atomic.AddInt64(&c.seq, 1))
|
||||
|
||||
ac := &AsyncCall{
|
||||
id: id,
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
// When this method returns, either ac is retired, or the request has been
|
||||
// written successfully and the call is awaiting a response (to be provided by
|
||||
// the readIncoming goroutine).
|
||||
|
||||
call, err := NewCall(ac.id, method, params)
|
||||
if err != nil {
|
||||
ac.retire(&Response{ID: id, Error: fmt.Errorf("marshaling call parameters: %w", err)})
|
||||
return ac
|
||||
}
|
||||
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
err = s.shuttingDown(ErrClientClosing)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if s.outgoingCalls == nil {
|
||||
s.outgoingCalls = make(map[ID]*AsyncCall)
|
||||
}
|
||||
s.outgoingCalls[ac.id] = ac
|
||||
})
|
||||
if err != nil {
|
||||
ac.retire(&Response{ID: id, Error: err})
|
||||
return ac
|
||||
}
|
||||
|
||||
if err := c.write(ctx, call); err != nil {
|
||||
// Sending failed. We will never get a response, so deliver a fake one if it
|
||||
// wasn't already retired by the connection breaking.
|
||||
c.Retire(ac, err)
|
||||
}
|
||||
return ac
|
||||
}
|
||||
|
||||
// Retire stops tracking the call, and reports err as its terminal error.
|
||||
//
|
||||
// Retire is safe to call multiple times: if the call is already no longer
|
||||
// tracked, Retire is a no op.
|
||||
func (c *Connection) Retire(ac *AsyncCall, err error) {
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if s.outgoingCalls[ac.id] == ac {
|
||||
delete(s.outgoingCalls, ac.id)
|
||||
ac.retire(&Response{ID: ac.id, Error: err})
|
||||
} else {
|
||||
// ac was already retired elsewhere.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Async, signals that the current jsonrpc2 request may be handled
|
||||
// asynchronously to subsequent requests, when ctx is the request context.
|
||||
//
|
||||
// Async must be called at most once on each request's context (and its
|
||||
// descendants).
|
||||
func Async(ctx context.Context) {
|
||||
if r, ok := ctx.Value(asyncKey).(*releaser); ok {
|
||||
r.release(false)
|
||||
}
|
||||
}
|
||||
|
||||
type asyncKeyType struct{}
|
||||
|
||||
var asyncKey = asyncKeyType{}
|
||||
|
||||
// A releaser implements concurrency safe 'releasing' of async requests. (A
|
||||
// request is released when it is allowed to run concurrent with other
|
||||
// requests, via a call to [Async].)
|
||||
type releaser struct {
|
||||
mu sync.Mutex
|
||||
ch chan struct{}
|
||||
released bool
|
||||
}
|
||||
|
||||
// release closes the associated channel. If soft is set, multiple calls to
|
||||
// release are allowed.
|
||||
func (r *releaser) release(soft bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.released {
|
||||
if !soft {
|
||||
panic("jsonrpc2.Async called multiple times")
|
||||
}
|
||||
} else {
|
||||
close(r.ch)
|
||||
r.released = true
|
||||
}
|
||||
}
|
||||
|
||||
type AsyncCall struct {
|
||||
id ID
|
||||
ready chan struct{} // closed after response has been set
|
||||
response *Response
|
||||
}
|
||||
|
||||
// ID used for this call.
|
||||
// This can be used to cancel the call if needed.
|
||||
func (ac *AsyncCall) ID() ID { return ac.id }
|
||||
|
||||
// retire processes the response to the call.
|
||||
//
|
||||
// It is an error to call retire more than once: retire is guarded by the
|
||||
// connection's outgoingCalls map.
|
||||
func (ac *AsyncCall) retire(response *Response) {
|
||||
select {
|
||||
case <-ac.ready:
|
||||
panic(fmt.Sprintf("jsonrpc2: retire called twice for ID %v", ac.id))
|
||||
default:
|
||||
}
|
||||
|
||||
ac.response = response
|
||||
close(ac.ready)
|
||||
}
|
||||
|
||||
// Await waits for (and decodes) the results of a Call.
|
||||
// The response will be unmarshaled from JSON into the result.
|
||||
//
|
||||
// If the call is cancelled due to context cancellation, the result is
|
||||
// ctx.Err().
|
||||
func (ac *AsyncCall) Await(ctx context.Context, result any) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ac.ready:
|
||||
}
|
||||
if ac.response.Error != nil {
|
||||
return ac.response.Error
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(ac.response.Result, result)
|
||||
}
|
||||
|
||||
// Cancel cancels the Context passed to the Handle call for the inbound message
|
||||
// with the given ID.
|
||||
//
|
||||
// Cancel will not complain if the ID is not a currently active message, and it
|
||||
// will not cause any messages that have not arrived yet with that ID to be
|
||||
// cancelled.
|
||||
func (c *Connection) Cancel(id ID) {
|
||||
var req *incomingRequest
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
req = s.incomingByID[id]
|
||||
})
|
||||
if req != nil {
|
||||
req.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the connection is fully closed, but does not close it.
|
||||
func (c *Connection) Wait() error {
|
||||
return c.wait(true)
|
||||
}
|
||||
|
||||
// wait for the connection to close, and aggregates the most cause of its
|
||||
// termination, if abnormal.
|
||||
//
|
||||
// The fromWait argument allows this logic to be shared with Close, where we
|
||||
// only want to expose the closeErr.
|
||||
//
|
||||
// (Previously, Wait also only returned the closeErr, which was misleading if
|
||||
// the connection was broken for another reason).
|
||||
func (c *Connection) wait(fromWait bool) error {
|
||||
var err error
|
||||
<-c.done
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if fromWait {
|
||||
if !errors.Is(s.readErr, io.EOF) {
|
||||
err = s.readErr
|
||||
}
|
||||
if err == nil && !errors.Is(s.writeErr, io.EOF) {
|
||||
err = s.writeErr
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = s.closeErr
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Close stops accepting new requests, waits for in-flight requests and enqueued
|
||||
// Handle calls to complete, and then closes the underlying stream.
|
||||
//
|
||||
// After the start of a Close, notification requests (that lack IDs and do not
|
||||
// receive responses) will continue to be passed to the Preempter, but calls
|
||||
// with IDs will receive immediate responses with ErrServerClosing, and no new
|
||||
// requests (not even notifications!) will be enqueued to the Handler.
|
||||
func (c *Connection) Close() error {
|
||||
// Stop handling new requests, and interrupt the reader (by closing the
|
||||
// connection) as soon as the active requests finish.
|
||||
c.updateInFlight(func(s *inFlightState) { s.connClosing = true })
|
||||
return c.wait(false)
|
||||
}
|
||||
|
||||
// readIncoming collects inbound messages from the reader and delivers them, either responding
|
||||
// to outgoing calls or feeding requests to the queue.
|
||||
func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter Preempter) {
|
||||
var err error
|
||||
for {
|
||||
var msg Message
|
||||
msg, err = reader.Read(ctx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case *Request:
|
||||
c.acceptRequest(ctx, msg, preempter)
|
||||
|
||||
case *Response:
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if ac, ok := s.outgoingCalls[msg.ID]; ok {
|
||||
delete(s.outgoingCalls, msg.ID)
|
||||
ac.retire(msg)
|
||||
} else {
|
||||
// TODO: How should we report unexpected responses?
|
||||
}
|
||||
})
|
||||
|
||||
default:
|
||||
c.internalErrorf("Read returned an unexpected message of type %T", msg)
|
||||
}
|
||||
}
|
||||
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
s.reading = false
|
||||
s.readErr = err
|
||||
|
||||
// Retire any outgoing requests that were still in flight: with the Reader no
|
||||
// longer being processed, they necessarily cannot receive a response.
|
||||
for id, ac := range s.outgoingCalls {
|
||||
ac.retire(&Response{ID: id, Error: err})
|
||||
}
|
||||
s.outgoingCalls = nil
|
||||
|
||||
// Cancel any incoming requests still in flight: with the reader gone we
|
||||
// cannot receive cancellation notifications, and likely cannot write a
|
||||
// response either, so parked handlers have nothing useful left to do.
|
||||
// Mirrors the equivalent cleanup on write failure.
|
||||
for _, r := range s.incomingByID {
|
||||
r.cancel()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// acceptRequest either handles msg synchronously or enqueues it to be handled
|
||||
// asynchronously.
|
||||
func (c *Connection) acceptRequest(ctx context.Context, msg *Request, preempter Preempter) {
|
||||
// In theory notifications cannot be cancelled, but we build them a cancel
|
||||
// context anyway.
|
||||
reqCtx, cancel := context.WithCancel(ctx)
|
||||
req := &incomingRequest{
|
||||
Request: msg,
|
||||
ctx: reqCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// If the request is a call, add it to the incoming map so it can be
|
||||
// cancelled (or responded) by ID.
|
||||
var err error
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
s.incoming++
|
||||
|
||||
if req.IsCall() {
|
||||
if s.incomingByID[req.ID] != nil {
|
||||
err = fmt.Errorf("%w: request ID %v already in use", ErrInvalidRequest, req.ID)
|
||||
req.ID = ID{} // Don't misattribute this error to the existing request.
|
||||
return
|
||||
}
|
||||
|
||||
if s.incomingByID == nil {
|
||||
s.incomingByID = make(map[ID]*incomingRequest)
|
||||
}
|
||||
s.incomingByID[req.ID] = req
|
||||
|
||||
// When shutting down, reject all new Call requests, even if they could
|
||||
// theoretically be handled by the preempter. The preempter could return
|
||||
// ErrAsyncResponse, which would increase the amount of work in flight
|
||||
// when we're trying to ensure that it strictly decreases.
|
||||
err = s.shuttingDown(ErrServerClosing)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
c.processResult("acceptRequest", req, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
if preempter != nil {
|
||||
result, err := preempter.Preempt(req.ctx, req.Request)
|
||||
|
||||
if !errors.Is(err, ErrNotHandled) {
|
||||
c.processResult("Preempt", req, result, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
// If the connection is shutting down, don't enqueue anything to the
|
||||
// handler — not even notifications. That ensures that if the handler
|
||||
// continues to make progress, it will eventually become idle and
|
||||
// close the connection.
|
||||
err = s.shuttingDown(ErrServerClosing)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// We enqueue requests that have not been preempted to an unbounded slice.
|
||||
// Unfortunately, we cannot in general limit the size of the handler
|
||||
// queue: we have to read every response that comes in on the wire
|
||||
// (because it may be responding to a request issued by, say, an
|
||||
// asynchronous handler), and in order to get to that response we have
|
||||
// to read all of the requests that came in ahead of it.
|
||||
s.handlerQueue = append(s.handlerQueue, req)
|
||||
if !s.handlerRunning {
|
||||
// We start the handleAsync goroutine when it has work to do, and let it
|
||||
// exit when the queue empties.
|
||||
//
|
||||
// Otherwise, in order to synchronize the handler we would need some other
|
||||
// goroutine (probably readIncoming?) to explicitly wait for handleAsync
|
||||
// to finish, and that would complicate error reporting: either the error
|
||||
// report from the goroutine would be blocked on the handler emptying its
|
||||
// queue (which was tried, and introduced a deadlock detected by
|
||||
// TestCloseCallRace), or the error would need to be reported separately
|
||||
// from synchronizing completion. Allowing the handler goroutine to exit
|
||||
// when idle seems simpler than trying to implement either of those
|
||||
// alternatives correctly.
|
||||
s.handlerRunning = true
|
||||
go c.handleAsync()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
c.processResult("acceptRequest", req, nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAsync invokes the handler on the requests in the handler queue
|
||||
// sequentially until the queue is empty.
|
||||
func (c *Connection) handleAsync() {
|
||||
for {
|
||||
var req *incomingRequest
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if len(s.handlerQueue) > 0 {
|
||||
req, s.handlerQueue = s.handlerQueue[0], s.handlerQueue[1:]
|
||||
} else {
|
||||
s.handlerRunning = false
|
||||
}
|
||||
})
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only deliver to the Handler if not already canceled.
|
||||
if err := req.ctx.Err(); err != nil {
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if s.writeErr != nil {
|
||||
// Assume that req.ctx was canceled due to s.writeErr.
|
||||
// TODO(#51365): use a Context API to plumb this through req.ctx.
|
||||
err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr)
|
||||
}
|
||||
})
|
||||
c.processResult("handleAsync", req, nil, err)
|
||||
continue
|
||||
}
|
||||
|
||||
releaser := &releaser{ch: make(chan struct{})}
|
||||
ctx := context.WithValue(req.ctx, asyncKey, releaser)
|
||||
go func() {
|
||||
defer releaser.release(true)
|
||||
result, err := c.handler.Handle(ctx, req.Request)
|
||||
c.processResult(c.handler, req, result, err)
|
||||
}()
|
||||
<-releaser.ch
|
||||
}
|
||||
}
|
||||
|
||||
// processResult processes the result of a request and, if appropriate, sends a response.
|
||||
func (c *Connection) processResult(from any, req *incomingRequest, result any, err error) error {
|
||||
if nomethodnotfoundcodeinerror != "1" && (errors.Is(err, ErrNotHandled) || errors.Is(err, ErrMethodNotFound)) {
|
||||
err = fmt.Errorf("%w: %q", ErrMethodNotFound, req.Method)
|
||||
}
|
||||
|
||||
if result != nil && err != nil {
|
||||
c.internalErrorf("%#v returned a non-nil result with a non-nil error for %s:\n%v\n%#v", from, req.Method, err, result)
|
||||
result = nil // Discard the spurious result and respond with err.
|
||||
}
|
||||
|
||||
if req.IsCall() {
|
||||
if result == nil && err == nil {
|
||||
err = c.internalErrorf("%#v returned a nil result and nil error for a %q Request that requires a Response", from, req.Method)
|
||||
}
|
||||
|
||||
response, respErr := NewResponse(req.ID, result, err)
|
||||
|
||||
// The caller could theoretically reuse the request's ID as soon as we've
|
||||
// sent the response, so ensure that it is removed from the incoming map
|
||||
// before sending.
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
delete(s.incomingByID, req.ID)
|
||||
})
|
||||
if respErr == nil {
|
||||
writeErr := c.write(notDone{req.ctx}, response)
|
||||
if err == nil {
|
||||
err = writeErr
|
||||
}
|
||||
} else {
|
||||
err = c.internalErrorf("%#v returned a malformed result for %q: %w", from, req.Method, respErr)
|
||||
}
|
||||
} else { // req is a notification
|
||||
if result != nil {
|
||||
err = c.internalErrorf("%#v returned a non-nil result for a %q Request without an ID", from, req.Method)
|
||||
} else if err != nil {
|
||||
err = fmt.Errorf("%w: %q notification failed: %v", ErrInternal, req.Method, err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// TODO: can/should we do anything with this error beyond writing it to the event log?
|
||||
// (Is this the right label to attach to the log?)
|
||||
}
|
||||
|
||||
// Cancel the request to free any associated resources.
|
||||
req.cancel()
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if s.incoming == 0 {
|
||||
panic("jsonrpc2: processResult called when incoming count is already zero")
|
||||
}
|
||||
s.incoming--
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// write is used by all things that write outgoing messages, including replies.
|
||||
// it makes sure that writes are atomic
|
||||
func (c *Connection) write(ctx context.Context, msg Message) error {
|
||||
var err error
|
||||
// Fail writes immediately if the connection is shutting down.
|
||||
//
|
||||
// Allow outgoing "notifications" forwarded by the Notify method.
|
||||
// This will allow to send the cancelled notification when the client is shutting down.
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if req, ok := msg.(*Request); ok && !req.IsCall() && s.outgoingNotifications > 0 {
|
||||
return
|
||||
}
|
||||
err = s.shuttingDown(ErrServerClosing)
|
||||
})
|
||||
if err == nil {
|
||||
err = c.writer.Write(ctx, msg)
|
||||
}
|
||||
|
||||
// For cancelled or rejected requests, we don't set the writeErr (which would
|
||||
// break the connection). They can just be returned to the caller.
|
||||
if err != nil && ctx.Err() == nil && !errors.Is(err, ErrRejected) {
|
||||
// The call to Write failed, and since ctx.Err() is nil we can't attribute
|
||||
// the failure (even indirectly) to Context cancellation. The writer appears
|
||||
// to be broken, and future writes are likely to also fail.
|
||||
//
|
||||
// If the read side of the connection is also broken, we might not even be
|
||||
// able to receive cancellation notifications. Since we can't reliably write
|
||||
// the results of incoming calls and can't receive explicit cancellations,
|
||||
// cancel the calls now.
|
||||
c.updateInFlight(func(s *inFlightState) {
|
||||
if s.writeErr == nil {
|
||||
s.writeErr = err
|
||||
for _, r := range s.incomingByID {
|
||||
r.cancel()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// internalErrorf reports an internal error. By default it panics, but if
|
||||
// c.onInternalError is non-nil it instead calls that and returns an error
|
||||
// wrapping ErrInternal.
|
||||
func (c *Connection) internalErrorf(format string, args ...any) error {
|
||||
err := fmt.Errorf(format, args...)
|
||||
if c.onInternalError == nil {
|
||||
panic("jsonrpc2: " + err.Error())
|
||||
}
|
||||
c.onInternalError(err)
|
||||
|
||||
return fmt.Errorf("%w: %v", ErrInternal, err)
|
||||
}
|
||||
|
||||
// notDone is a context.Context wrapper that returns a nil Done channel.
|
||||
//
|
||||
// Request handlers' contexts are derived from the connection's root context,
|
||||
// which by default is wrapped in notDone so a transport-level cancellation
|
||||
// does not implicitly cancel every in-flight handler. Cancellation of an
|
||||
// in-flight handler is instead expected to flow only through the jsonrpc2
|
||||
// layer's explicit channels: the [Preempter] calling [Connection.Cancel] in
|
||||
// response to the peer's cancel notification, or the transport itself
|
||||
// failing (the read loop exits on EOF or a write fails) — both of which
|
||||
// cancel every in-flight incoming request in turn.
|
||||
type notDone struct{ ctx context.Context }
|
||||
|
||||
func (ic notDone) Value(key any) any {
|
||||
return ic.ctx.Value(key)
|
||||
}
|
||||
|
||||
func (notDone) Done() <-chan struct{} { return nil }
|
||||
func (notDone) Err() error { return nil }
|
||||
func (notDone) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// Copyright 2018 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package jsonrpc2 is a minimal implementation of the JSON RPC 2 spec.
|
||||
// https://www.jsonrpc.org/specification
|
||||
// It is intended to be compatible with other implementations at the wire level.
|
||||
package jsonrpc2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrNotHandled is returned from a Handler or Preempter to indicate it did
|
||||
// not handle the request.
|
||||
//
|
||||
// If a Handler returns ErrNotHandled, the server replies with
|
||||
// ErrMethodNotFound.
|
||||
var ErrNotHandled = errors.New("JSON RPC not handled")
|
||||
|
||||
// Preempter handles messages on a connection before they are queued to the main
|
||||
// handler.
|
||||
// Primarily this is used for cancel handlers or notifications for which out of
|
||||
// order processing is not an issue.
|
||||
type Preempter interface {
|
||||
// Preempt is invoked for each incoming request before it is queued for handling.
|
||||
//
|
||||
// If Preempt returns ErrNotHandled, the request will be queued,
|
||||
// and eventually passed to a Handle call.
|
||||
//
|
||||
// Otherwise, the result and error are processed as if returned by Handle.
|
||||
//
|
||||
// Preempt must not block. (The Context passed to it is for Values only.)
|
||||
Preempt(ctx context.Context, req *Request) (result any, err error)
|
||||
}
|
||||
|
||||
// Handler handles messages on a connection.
|
||||
type Handler interface {
|
||||
// Handle is invoked sequentially for each incoming request that has not
|
||||
// already been handled by a Preempter.
|
||||
//
|
||||
// If the Request has a nil ID, Handle must return a nil result,
|
||||
// and any error may be logged but will not be reported to the caller.
|
||||
//
|
||||
// If the Request has a non-nil ID, Handle must return either a
|
||||
// non-nil, JSON-marshalable result, or a non-nil error.
|
||||
//
|
||||
// The Context passed to Handle will be canceled if the
|
||||
// connection is broken or the request is canceled or completed.
|
||||
// (If Handle returns ErrAsyncResponse, ctx will remain uncanceled
|
||||
// until either Cancel or Respond is called for the request's ID.)
|
||||
Handle(ctx context.Context, req *Request) (result any, err error)
|
||||
}
|
||||
|
||||
// A HandlerFunc implements the Handler interface for a standalone Handle function.
|
||||
type HandlerFunc func(ctx context.Context, req *Request) (any, error)
|
||||
|
||||
func (f HandlerFunc) Handle(ctx context.Context, req *Request) (any, error) {
|
||||
return f(ctx, req)
|
||||
}
|
||||
|
||||
var _ Handler = HandlerFunc(nil)
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
// Copyright 2018 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonrpc2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
)
|
||||
|
||||
// ID is a Request identifier, which is defined by the spec to be a string, integer, or null.
|
||||
// https://www.jsonrpc.org/specification#request_object
|
||||
type ID struct {
|
||||
value any
|
||||
}
|
||||
|
||||
// MakeID coerces the given Go value to an ID. The value should be the
|
||||
// default JSON marshaling of a Request identifier: nil, float64, or string.
|
||||
//
|
||||
// Returns an error if the value type was not a valid Request ID type.
|
||||
//
|
||||
// TODO: ID can't be a json.Marshaler/Unmarshaler, because we want to omitzero.
|
||||
// Simplify this package by making ID json serializable once we can rely on
|
||||
// omitzero.
|
||||
func MakeID(v any) (ID, error) {
|
||||
switch v := v.(type) {
|
||||
case nil:
|
||||
return ID{}, nil
|
||||
case float64:
|
||||
return Int64ID(int64(v)), nil
|
||||
case string:
|
||||
return StringID(v), nil
|
||||
}
|
||||
return ID{}, fmt.Errorf("%w: invalid ID type %T", ErrParse, v)
|
||||
}
|
||||
|
||||
// Message is the interface to all jsonrpc2 message types.
|
||||
// They share no common functionality, but are a closed set of concrete types
|
||||
// that are allowed to implement this interface. The message types are *Request
|
||||
// and *Response.
|
||||
type Message interface {
|
||||
// marshal builds the wire form from the API form.
|
||||
// It is private, which makes the set of Message implementations closed.
|
||||
marshal(to *wireCombined)
|
||||
}
|
||||
|
||||
// Request is a Message sent to a peer to request behavior.
|
||||
// If it has an ID it is a call, otherwise it is a notification.
|
||||
type Request struct {
|
||||
// ID of this request, used to tie the Response back to the request.
|
||||
// This will be nil for notifications.
|
||||
ID ID
|
||||
// Method is a string containing the method name to invoke.
|
||||
Method string
|
||||
// Params is either a struct or an array with the parameters of the method.
|
||||
Params json.RawMessage
|
||||
// Extra is additional information that does not appear on the wire. It can be
|
||||
// used to pass information from the application to the underlying transport.
|
||||
Extra any
|
||||
}
|
||||
|
||||
// Response is a Message used as a reply to a call Request.
|
||||
// It will have the same ID as the call it is a response to.
|
||||
type Response struct {
|
||||
// result is the content of the response.
|
||||
Result json.RawMessage
|
||||
// err is set only if the call failed.
|
||||
Error error
|
||||
// id of the request this is a response to.
|
||||
ID ID
|
||||
// Extra is additional information that does not appear on the wire. It can be
|
||||
// used to pass information from the underlying transport to the application.
|
||||
Extra any
|
||||
}
|
||||
|
||||
// StringID creates a new string request identifier.
|
||||
func StringID(s string) ID { return ID{value: s} }
|
||||
|
||||
// Int64ID creates a new integer request identifier.
|
||||
func Int64ID(i int64) ID { return ID{value: i} }
|
||||
|
||||
// IsValid returns true if the ID is a valid identifier.
|
||||
// The default value for ID will return false.
|
||||
func (id ID) IsValid() bool { return id.value != nil }
|
||||
|
||||
// Raw returns the underlying value of the ID.
|
||||
func (id ID) Raw() any { return id.value }
|
||||
|
||||
// NewNotification constructs a new Notification message for the supplied
|
||||
// method and parameters.
|
||||
func NewNotification(method string, params any) (*Request, error) {
|
||||
p, merr := marshalToRaw(params)
|
||||
return &Request{Method: method, Params: p}, merr
|
||||
}
|
||||
|
||||
// NewCall constructs a new Call message for the supplied ID, method and
|
||||
// parameters.
|
||||
func NewCall(id ID, method string, params any) (*Request, error) {
|
||||
p, merr := marshalToRaw(params)
|
||||
return &Request{ID: id, Method: method, Params: p}, merr
|
||||
}
|
||||
|
||||
func (msg *Request) IsCall() bool { return msg.ID.IsValid() }
|
||||
|
||||
func (msg *Request) marshal(to *wireCombined) {
|
||||
to.ID = msg.ID.value
|
||||
to.Method = msg.Method
|
||||
to.Params = msg.Params
|
||||
}
|
||||
|
||||
// NewResponse constructs a new Response message that is a reply to the
|
||||
// supplied. If err is set result may be ignored.
|
||||
func NewResponse(id ID, result any, rerr error) (*Response, error) {
|
||||
r, merr := marshalToRaw(result)
|
||||
return &Response{ID: id, Result: r, Error: rerr}, merr
|
||||
}
|
||||
|
||||
func (msg *Response) marshal(to *wireCombined) {
|
||||
to.ID = msg.ID.value
|
||||
to.Error = toWireError(msg.Error)
|
||||
to.Result = msg.Result
|
||||
}
|
||||
|
||||
func toWireError(err error) *WireError {
|
||||
if err == nil {
|
||||
// no error, the response is complete
|
||||
return nil
|
||||
}
|
||||
if err, ok := err.(*WireError); ok {
|
||||
// already a wire error, just use it
|
||||
return err
|
||||
}
|
||||
result := &WireError{Message: err.Error()}
|
||||
var wrapped *WireError
|
||||
if errors.As(err, &wrapped) {
|
||||
// if we wrapped a wire error, keep the code from the wrapped error
|
||||
// but the message from the outer error
|
||||
result.Code = wrapped.Code
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func EncodeMessage(msg Message) ([]byte, error) {
|
||||
wire := wireCombined{VersionTag: wireVersion}
|
||||
msg.marshal(&wire)
|
||||
data, err := jsonMarshal(&wire)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling jsonrpc message: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// EncodeIndent is like EncodeMessage, but honors indents.
|
||||
// TODO(rfindley): refactor so that this concern is handled independently.
|
||||
// Perhaps we should pass in a json.Encoder?
|
||||
func EncodeIndent(msg Message, prefix, indent string) ([]byte, error) {
|
||||
wire := wireCombined{VersionTag: wireVersion}
|
||||
msg.marshal(&wire)
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent(prefix, indent)
|
||||
if err := enc.Encode(&wire); err != nil {
|
||||
return nil, fmt.Errorf("marshaling jsonrpc message: %w", err)
|
||||
}
|
||||
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
||||
}
|
||||
|
||||
// wireDecode is the decode form of [wireCombined]. Method is a [json.RawMessage]
|
||||
// so we can tell whether the "method" key was present on the wire, including
|
||||
// when its value is the empty string (see go-sdk#976).
|
||||
type wireDecode struct {
|
||||
VersionTag string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Method json.RawMessage `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *WireError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func DecodeMessage(data []byte) (Message, error) {
|
||||
msg := wireDecode{}
|
||||
if err := internaljson.Unmarshal(data, &msg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err)
|
||||
}
|
||||
if msg.VersionTag != wireVersion {
|
||||
return nil, fmt.Errorf("invalid message version tag %q; expected %q", msg.VersionTag, wireVersion)
|
||||
}
|
||||
id, err := MakeID(msg.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(msg.Method) > 0 {
|
||||
// The "method" key was present. Decode its value (including "").
|
||||
var method string
|
||||
if err := internaljson.Unmarshal(msg.Method, &method); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err)
|
||||
}
|
||||
return &Request{
|
||||
Method: method,
|
||||
ID: id,
|
||||
Params: msg.Params,
|
||||
}, nil
|
||||
}
|
||||
// no method key, should be a response
|
||||
if !id.IsValid() {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
resp := &Response{
|
||||
ID: id,
|
||||
Result: msg.Result,
|
||||
}
|
||||
// we have to check if msg.Error is nil to avoid a typed error
|
||||
if msg.Error != nil {
|
||||
resp.Error = msg.Error
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func marshalToRaw(obj any) (json.RawMessage, error) {
|
||||
if obj == nil {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := jsonMarshal(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.RawMessage(data), nil
|
||||
}
|
||||
|
||||
// jsonMarshal marshals obj to JSON like json.Marshal but without HTML escaping.
|
||||
func jsonMarshal(obj any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// json.Encoder.Encode adds a trailing newline. Trim it to be consistent with json.Marshal.
|
||||
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
// Copyright 2018 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package jsonrpc2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// This file contains the go forms of the wire specification.
|
||||
// see http://www.jsonrpc.org/specification for details
|
||||
|
||||
var (
|
||||
// ErrParse is used when invalid JSON was received by the server.
|
||||
ErrParse = NewError(-32700, "parse error")
|
||||
// ErrInvalidRequest is used when the JSON sent is not a valid Request object.
|
||||
ErrInvalidRequest = NewError(-32600, "invalid request")
|
||||
// ErrMethodNotFound should be returned by the handler when the method does
|
||||
// not exist / is not available.
|
||||
ErrMethodNotFound = NewError(-32601, "method not found")
|
||||
// ErrInvalidParams should be returned by the handler when method
|
||||
// parameter(s) were invalid.
|
||||
ErrInvalidParams = NewError(-32602, "invalid params")
|
||||
// ErrInternal indicates a failure to process a call correctly
|
||||
ErrInternal = NewError(-32603, "internal error")
|
||||
|
||||
// The following errors are not part of the json specification, but
|
||||
// compliant extensions specific to this implementation.
|
||||
|
||||
// ErrUnknown should be used for all non coded errors.
|
||||
ErrUnknown = NewError(-32001, "unknown error")
|
||||
// ErrServerClosing is returned for calls that arrive while the server is closing.
|
||||
ErrServerClosing = NewError(-32004, "server is closing")
|
||||
// ErrClientClosing is a dummy error returned for calls initiated while the client is closing.
|
||||
ErrClientClosing = NewError(-32003, "client is closing")
|
||||
|
||||
// The following errors have special semantics for MCP transports
|
||||
|
||||
// ErrRejected may be wrapped to return errors from calls to Writer.Write
|
||||
// that signal that the request was rejected by the transport layer as
|
||||
// invalid.
|
||||
//
|
||||
// Such failures do not indicate that the connection is broken, but rather
|
||||
// should be returned to the caller to indicate that the specific request is
|
||||
// invalid in the current context.
|
||||
ErrRejected = NewError(-32005, "rejected by transport")
|
||||
)
|
||||
|
||||
const wireVersion = "2.0"
|
||||
|
||||
// wireCombined has all the fields of both Request and Response.
|
||||
// We can decode this and then work out which it is.
|
||||
type wireCombined struct {
|
||||
VersionTag string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *WireError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WireError represents a structured error in a Response.
|
||||
type WireError struct {
|
||||
// Code is an error code indicating the type of failure.
|
||||
Code int64 `json:"code"`
|
||||
// Message is a short description of the error.
|
||||
Message string `json:"message"`
|
||||
// Data is optional structured data containing additional information about the error.
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// NewError returns an error that will encode on the wire correctly.
|
||||
// The standard codes are made available from this package, this function should
|
||||
// only be used to build errors for application specific codes as allowed by the
|
||||
// specification.
|
||||
func NewError(code int64, message string) error {
|
||||
return &WireError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func (err *WireError) Error() string {
|
||||
return err.Message
|
||||
}
|
||||
|
||||
func (err *WireError) Is(other error) bool {
|
||||
w, ok := other.(*WireError)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return err.Code == w.Code
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
// Package mcpgodebug provides a mechanism to configure compatibility parameters
|
||||
// via the MCPGODEBUG environment variable.
|
||||
//
|
||||
// The value of MCPGODEBUG is a comma-separated list of key=value pairs.
|
||||
// For example:
|
||||
//
|
||||
// MCPGODEBUG=someoption=1,otheroption=value
|
||||
package mcpgodebug
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const compatibilityEnvKey = "MCPGODEBUG"
|
||||
|
||||
var compatibilityParams map[string]string
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
compatibilityParams, err = parseCompatibility(os.Getenv(compatibilityEnvKey))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Value returns the value of the compatibility parameter with the given key.
|
||||
// It returns an empty string if the key is not set.
|
||||
func Value(key string) string {
|
||||
return compatibilityParams[key]
|
||||
}
|
||||
|
||||
func parseCompatibility(envValue string) (map[string]string, error) {
|
||||
if envValue == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
params := make(map[string]string)
|
||||
for part := range strings.SplitSeq(envValue, ",") {
|
||||
k, v, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("MCPGODEBUG: invalid format: %q", part)
|
||||
}
|
||||
params[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Wrapf wraps *errp with the given formatted message if *errp is not nil.
|
||||
func Wrapf(errp *error, format string, args ...any) {
|
||||
if *errp != nil {
|
||||
*errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp)
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
package util
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func IsLoopback(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
// If SplitHostPort fails, it might be just a host without a port.
|
||||
host = strings.Trim(addr, "[]")
|
||||
}
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
// Copyright 2019 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package xcontext is a package to offer the extra functionality we need
|
||||
// from contexts that is not available from the standard context package.
|
||||
package xcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Detach returns a context that keeps all the values of its parent context
|
||||
// but detaches from the cancellation and error handling.
|
||||
func Detach(ctx context.Context) context.Context { return detachedContext{ctx} }
|
||||
|
||||
type detachedContext struct{ parent context.Context }
|
||||
|
||||
func (v detachedContext) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
func (v detachedContext) Done() <-chan struct{} { return nil }
|
||||
func (v detachedContext) Err() error { return nil }
|
||||
func (v detachedContext) Value(key any) any { return v.parent.Value(key) }
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package jsonrpc exposes part of a JSON-RPC v2 implementation
|
||||
// for use by mcp transport authors.
|
||||
package jsonrpc
|
||||
|
||||
import "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
|
||||
type (
|
||||
// ID is a JSON-RPC request ID.
|
||||
ID = jsonrpc2.ID
|
||||
// Message is a JSON-RPC message.
|
||||
Message = jsonrpc2.Message
|
||||
// Request is a JSON-RPC request.
|
||||
Request = jsonrpc2.Request
|
||||
// Response is a JSON-RPC response.
|
||||
Response = jsonrpc2.Response
|
||||
// Error is a structured error in a JSON-RPC response.
|
||||
Error = jsonrpc2.WireError
|
||||
)
|
||||
|
||||
// MakeID coerces the given Go value to an ID. The value should be the
|
||||
// default JSON marshaling of a Request identifier: nil, float64, or string.
|
||||
//
|
||||
// Returns an error if the value type was not a valid Request ID type.
|
||||
func MakeID(v any) (ID, error) {
|
||||
return jsonrpc2.MakeID(v)
|
||||
}
|
||||
|
||||
// EncodeMessage serializes a JSON-RPC message to its wire format.
|
||||
func EncodeMessage(msg Message) ([]byte, error) {
|
||||
return jsonrpc2.EncodeMessage(msg)
|
||||
}
|
||||
|
||||
// DecodeMessage deserializes JSON-RPC wire format data into a Message.
|
||||
// It returns either a Request or Response based on the message content.
|
||||
func DecodeMessage(data []byte) (Message, error) {
|
||||
return jsonrpc2.DecodeMessage(data)
|
||||
}
|
||||
|
||||
// Standard JSON-RPC 2.0 error codes.
|
||||
// See https://www.jsonrpc.org/specification#error_object
|
||||
const (
|
||||
// CodeParseError indicates invalid JSON was received by the server.
|
||||
CodeParseError = -32700
|
||||
// CodeInvalidRequest indicates the JSON sent is not a valid Request object.
|
||||
CodeInvalidRequest = -32600
|
||||
// CodeMethodNotFound indicates the method does not exist or is not available.
|
||||
CodeMethodNotFound = -32601
|
||||
// CodeInvalidParams indicates invalid method parameter(s).
|
||||
CodeInvalidParams = -32602
|
||||
// CodeInternalError indicates an internal JSON-RPC error.
|
||||
CodeInternalError = -32603
|
||||
)
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// methodCache is a per-method TTL cache for list and read results, as
|
||||
// described in SEP-2549. Each entry is keyed by cursor (for paginated list
|
||||
// methods) or URI (for resources/read).
|
||||
type methodCache[R CacheableResult] struct {
|
||||
mu sync.Mutex
|
||||
cachedValues map[string]*cacheEntry[R]
|
||||
}
|
||||
|
||||
type cacheEntry[R CacheableResult] struct {
|
||||
result R
|
||||
receivedAt time.Time
|
||||
}
|
||||
|
||||
func (e *cacheEntry[R]) isValid() bool {
|
||||
return time.Since(e.receivedAt) < time.Duration(e.result.GetTTLMs())*time.Millisecond
|
||||
}
|
||||
|
||||
func (mc *methodCache[R]) get(key string) (R, bool) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
entry, ok := mc.cachedValues[key]
|
||||
if !ok {
|
||||
var zero R
|
||||
return zero, false
|
||||
}
|
||||
if entry.result.GetTTLMs() <= 0 || !entry.isValid() {
|
||||
delete(mc.cachedValues, key)
|
||||
var zero R
|
||||
return zero, false
|
||||
}
|
||||
return entry.result, true
|
||||
}
|
||||
|
||||
func (mc *methodCache[R]) put(key string, result R) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
if mc.cachedValues == nil {
|
||||
mc.cachedValues = make(map[string]*cacheEntry[R])
|
||||
}
|
||||
mc.cachedValues[key] = &cacheEntry[R]{
|
||||
result: result,
|
||||
receivedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *methodCache[R]) invalidate() {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
clear(mc.cachedValues)
|
||||
}
|
||||
|
||||
func (mc *methodCache[R]) invalidateKey(key string) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
delete(mc.cachedValues, key)
|
||||
}
|
||||
|
||||
// cursorParams is the constraint for list-method params that carry a pagination
|
||||
// cursor and can be checked for nil. Both methods are already implemented by
|
||||
// every concrete list-params type.
|
||||
type cursorParams interface {
|
||||
Params
|
||||
cursorPtr() *string
|
||||
}
|
||||
|
||||
// cachedListResult returns a cached list result keyed by the request cursor
|
||||
// (SEP-2549). It returns the zero value and false on miss or when params is nil.
|
||||
func cachedListResult[P cursorParams, R CacheableResult](cache *methodCache[R], params P) (R, bool) {
|
||||
key := ""
|
||||
if !params.isNil() {
|
||||
if cp := params.cursorPtr(); cp != nil {
|
||||
key = *cp
|
||||
}
|
||||
}
|
||||
return cache.get(key)
|
||||
}
|
||||
+1682
File diff suppressed because it is too large
Load Diff
+108
@@ -0,0 +1,108 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var defaultTerminateDuration = 5 * time.Second // mutable for testing
|
||||
|
||||
// A CommandTransport is a [Transport] that runs a command and communicates
|
||||
// with it over stdin/stdout, using newline-delimited JSON.
|
||||
type CommandTransport struct {
|
||||
Command *exec.Cmd
|
||||
// TerminateDuration controls how long Close waits after closing stdin
|
||||
// for the process to exit before sending SIGTERM.
|
||||
// If zero or negative, the default of 5s is used.
|
||||
TerminateDuration time.Duration
|
||||
}
|
||||
|
||||
// Connect starts the command, and connects to it over stdin/stdout.
|
||||
func (t *CommandTransport) Connect(ctx context.Context) (Connection, error) {
|
||||
stdout, err := t.Command.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout = io.NopCloser(stdout) // close the connection by closing stdin, not stdout
|
||||
stdin, err := t.Command.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.Command.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
td := t.TerminateDuration
|
||||
if td <= 0 {
|
||||
td = defaultTerminateDuration
|
||||
}
|
||||
return newIOConn(&pipeRWC{t.Command, stdout, stdin, td}), nil
|
||||
}
|
||||
|
||||
// A pipeRWC is an io.ReadWriteCloser that communicates with a subprocess over
|
||||
// stdin/stdout pipes.
|
||||
type pipeRWC struct {
|
||||
cmd *exec.Cmd
|
||||
stdout io.ReadCloser
|
||||
stdin io.WriteCloser
|
||||
terminateDuration time.Duration
|
||||
}
|
||||
|
||||
func (s *pipeRWC) Read(p []byte) (n int, err error) {
|
||||
return s.stdout.Read(p)
|
||||
}
|
||||
|
||||
func (s *pipeRWC) Write(p []byte) (n int, err error) {
|
||||
return s.stdin.Write(p)
|
||||
}
|
||||
|
||||
// Close closes the input stream to the child process, and awaits normal
|
||||
// termination of the command. If the command does not exit, it is signalled to
|
||||
// terminate, and then eventually killed.
|
||||
func (s *pipeRWC) Close() error {
|
||||
// Spec:
|
||||
// "For the stdio transport, the client SHOULD initiate shutdown by:...
|
||||
|
||||
// "...First, closing the input stream to the child process (the server)"
|
||||
if err := s.stdin.Close(); err != nil {
|
||||
return fmt.Errorf("closing stdin: %v", err)
|
||||
}
|
||||
resChan := make(chan error, 1)
|
||||
go func() {
|
||||
resChan <- s.cmd.Wait()
|
||||
}()
|
||||
// "...Waiting for the server to exit, or sending SIGTERM if the server does not exit within a reasonable time"
|
||||
wait := func() (error, bool) {
|
||||
select {
|
||||
case err := <-resChan:
|
||||
return err, true
|
||||
case <-time.After(s.terminateDuration):
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if err, ok := wait(); ok {
|
||||
return err
|
||||
}
|
||||
// Note the condition here: if sending SIGTERM fails, don't wait and just
|
||||
// move on to SIGKILL.
|
||||
if err := s.cmd.Process.Signal(syscall.SIGTERM); err == nil {
|
||||
if err, ok := wait(); ok {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// "...Sending SIGKILL if the server does not exit within a reasonable time after SIGTERM"
|
||||
if err := s.cmd.Process.Kill(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err, ok := wait(); ok {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("unresponsive subprocess")
|
||||
}
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// TODO(findleyr): update JSON marshalling of all content types to preserve required fields.
|
||||
// (See [TextContent.MarshalJSON], which handles this for text content).
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
)
|
||||
|
||||
// A Content is a [TextContent], [ImageContent], [AudioContent],
|
||||
// [ResourceLink], [EmbeddedResource], [ToolUseContent], or [ToolResultContent].
|
||||
//
|
||||
// Note: [ToolUseContent] and [ToolResultContent] are only valid in sampling
|
||||
// message contexts (CreateMessageParams/CreateMessageResult).
|
||||
type Content interface {
|
||||
MarshalJSON() ([]byte, error)
|
||||
fromWire(*wireContent)
|
||||
}
|
||||
|
||||
// TextContent is a textual content.
|
||||
type TextContent struct {
|
||||
Text string
|
||||
Meta Meta
|
||||
Annotations *Annotations
|
||||
}
|
||||
|
||||
func (c *TextContent) MarshalJSON() ([]byte, error) {
|
||||
// Custom wire format to ensure the required "text" field is always included, even when empty.
|
||||
wire := struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
Annotations *Annotations `json:"annotations,omitempty"`
|
||||
}{
|
||||
Type: "text",
|
||||
Text: c.Text,
|
||||
Meta: c.Meta,
|
||||
Annotations: c.Annotations,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func (c *TextContent) fromWire(wire *wireContent) {
|
||||
c.Text = wire.Text
|
||||
c.Meta = wire.Meta
|
||||
c.Annotations = wire.Annotations
|
||||
}
|
||||
|
||||
// ImageContent contains base64-encoded image data.
|
||||
type ImageContent struct {
|
||||
Meta Meta
|
||||
Annotations *Annotations
|
||||
Data []byte // base64-encoded
|
||||
MIMEType string
|
||||
}
|
||||
|
||||
func (c *ImageContent) MarshalJSON() ([]byte, error) {
|
||||
// Custom wire format to ensure required fields are always included, even when empty.
|
||||
data := c.Data
|
||||
if data == nil {
|
||||
data = []byte{}
|
||||
}
|
||||
wire := imageAudioWire{
|
||||
Type: "image",
|
||||
MIMEType: c.MIMEType,
|
||||
Data: data,
|
||||
Meta: c.Meta,
|
||||
Annotations: c.Annotations,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func (c *ImageContent) fromWire(wire *wireContent) {
|
||||
c.MIMEType = wire.MIMEType
|
||||
c.Data = wire.Data
|
||||
c.Meta = wire.Meta
|
||||
c.Annotations = wire.Annotations
|
||||
}
|
||||
|
||||
// AudioContent contains base64-encoded audio data.
|
||||
type AudioContent struct {
|
||||
Data []byte
|
||||
MIMEType string
|
||||
Meta Meta
|
||||
Annotations *Annotations
|
||||
}
|
||||
|
||||
func (c AudioContent) MarshalJSON() ([]byte, error) {
|
||||
// Custom wire format to ensure required fields are always included, even when empty.
|
||||
data := c.Data
|
||||
if data == nil {
|
||||
data = []byte{}
|
||||
}
|
||||
wire := imageAudioWire{
|
||||
Type: "audio",
|
||||
MIMEType: c.MIMEType,
|
||||
Data: data,
|
||||
Meta: c.Meta,
|
||||
Annotations: c.Annotations,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func (c *AudioContent) fromWire(wire *wireContent) {
|
||||
c.MIMEType = wire.MIMEType
|
||||
c.Data = wire.Data
|
||||
c.Meta = wire.Meta
|
||||
c.Annotations = wire.Annotations
|
||||
}
|
||||
|
||||
// Custom wire format to ensure required fields are always included, even when empty.
|
||||
type imageAudioWire struct {
|
||||
Type string `json:"type"`
|
||||
MIMEType string `json:"mimeType"`
|
||||
Data []byte `json:"data"`
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
Annotations *Annotations `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceLink is a link to a resource
|
||||
type ResourceLink struct {
|
||||
URI string
|
||||
Name string
|
||||
Title string
|
||||
Description string
|
||||
MIMEType string
|
||||
Size *int64
|
||||
Meta Meta
|
||||
Annotations *Annotations
|
||||
// Icons for the resource link, if any.
|
||||
Icons []Icon `json:"icons,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ResourceLink) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(&wireContent{
|
||||
Type: "resource_link",
|
||||
URI: c.URI,
|
||||
Name: c.Name,
|
||||
Title: c.Title,
|
||||
Description: c.Description,
|
||||
MIMEType: c.MIMEType,
|
||||
Size: c.Size,
|
||||
Meta: c.Meta,
|
||||
Annotations: c.Annotations,
|
||||
Icons: c.Icons,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *ResourceLink) fromWire(wire *wireContent) {
|
||||
c.URI = wire.URI
|
||||
c.Name = wire.Name
|
||||
c.Title = wire.Title
|
||||
c.Description = wire.Description
|
||||
c.MIMEType = wire.MIMEType
|
||||
c.Size = wire.Size
|
||||
c.Meta = wire.Meta
|
||||
c.Annotations = wire.Annotations
|
||||
c.Icons = wire.Icons
|
||||
}
|
||||
|
||||
// EmbeddedResource contains embedded resources.
|
||||
type EmbeddedResource struct {
|
||||
Resource *ResourceContents
|
||||
Meta Meta
|
||||
Annotations *Annotations
|
||||
}
|
||||
|
||||
func (c *EmbeddedResource) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(&wireContent{
|
||||
Type: "resource",
|
||||
Resource: c.Resource,
|
||||
Meta: c.Meta,
|
||||
Annotations: c.Annotations,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *EmbeddedResource) fromWire(wire *wireContent) {
|
||||
c.Resource = wire.Resource
|
||||
c.Meta = wire.Meta
|
||||
c.Annotations = wire.Annotations
|
||||
}
|
||||
|
||||
// ToolUseContent represents a request from the assistant to invoke a tool.
|
||||
// This content type is only valid in sampling messages.
|
||||
//
|
||||
// Deprecated: the sampling feature is deprecated as of protocol version
|
||||
// 2026-07-28 (SEP-2577). It remains functional during the deprecation window
|
||||
// (at least twelve months). See
|
||||
// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging.
|
||||
type ToolUseContent struct {
|
||||
// ID is a unique identifier for this tool use, used to match with ToolResultContent.
|
||||
ID string
|
||||
// Name is the name of the tool to invoke.
|
||||
Name string
|
||||
// Input contains the tool arguments as a JSON object.
|
||||
Input map[string]any
|
||||
Meta Meta
|
||||
}
|
||||
|
||||
func (c *ToolUseContent) MarshalJSON() ([]byte, error) {
|
||||
input := c.Input
|
||||
if input == nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
wire := struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input map[string]any `json:"input"`
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
}{
|
||||
Type: "tool_use",
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Input: input,
|
||||
Meta: c.Meta,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func (c *ToolUseContent) fromWire(wire *wireContent) {
|
||||
c.ID = wire.ID
|
||||
c.Name = wire.Name
|
||||
c.Input = wire.Input
|
||||
c.Meta = wire.Meta
|
||||
}
|
||||
|
||||
// ToolResultContent represents the result of a tool invocation.
|
||||
// This content type is only valid in sampling messages with role "user".
|
||||
//
|
||||
// Deprecated: the sampling feature is deprecated as of protocol version
|
||||
// 2026-07-28 (SEP-2577). It remains functional during the deprecation window
|
||||
// (at least twelve months). See
|
||||
// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging.
|
||||
type ToolResultContent struct {
|
||||
// ToolUseID references the ID from the corresponding ToolUseContent.
|
||||
ToolUseID string
|
||||
// Content holds the unstructured result of the tool call.
|
||||
Content []Content
|
||||
// StructuredContent holds an optional structured result. Per SEP-2106, it
|
||||
// may be any valid JSON value (object, array, or primitive) conforming to
|
||||
// the tool's output schema.
|
||||
StructuredContent any
|
||||
// IsError indicates whether the tool call ended in an error.
|
||||
IsError bool
|
||||
Meta Meta
|
||||
}
|
||||
|
||||
func (c *ToolResultContent) MarshalJSON() ([]byte, error) {
|
||||
// Marshal nested content
|
||||
var contentWire []*wireContent
|
||||
for _, content := range c.Content {
|
||||
data, err := content.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var w wireContent
|
||||
if err := internaljson.Unmarshal(data, &w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentWire = append(contentWire, &w)
|
||||
}
|
||||
if contentWire == nil {
|
||||
contentWire = []*wireContent{} // avoid JSON null
|
||||
}
|
||||
|
||||
wire := struct {
|
||||
Type string `json:"type"`
|
||||
ToolUseID string `json:"toolUseId"`
|
||||
Content []*wireContent `json:"content"`
|
||||
StructuredContent any `json:"structuredContent,omitempty"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
}{
|
||||
Type: "tool_result",
|
||||
ToolUseID: c.ToolUseID,
|
||||
Content: contentWire,
|
||||
StructuredContent: c.StructuredContent,
|
||||
IsError: c.IsError,
|
||||
Meta: c.Meta,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func (c *ToolResultContent) fromWire(wire *wireContent) {
|
||||
c.ToolUseID = wire.ToolUseID
|
||||
c.StructuredContent = wire.StructuredContent
|
||||
c.IsError = wire.IsError
|
||||
c.Meta = wire.Meta
|
||||
// Content is handled separately in contentFromWire due to nested content
|
||||
}
|
||||
|
||||
// ResourceContents contains the contents of a specific resource or
|
||||
// sub-resource.
|
||||
type ResourceContents struct {
|
||||
URI string `json:"uri"`
|
||||
MIMEType string `json:"mimeType,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Blob []byte `json:"blob,omitzero"`
|
||||
Meta Meta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
// wireContent is the wire format for content.
|
||||
// It represents the protocol types TextContent, ImageContent, AudioContent,
|
||||
// ResourceLink, EmbeddedResource, ToolUseContent, and ToolResultContent.
|
||||
// The Type field distinguishes them. In the protocol, each type has a constant
|
||||
// value for the field.
|
||||
type wireContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"` // TextContent
|
||||
MIMEType string `json:"mimeType,omitempty"` // ImageContent, AudioContent, ResourceLink
|
||||
Data []byte `json:"data,omitempty"` // ImageContent, AudioContent
|
||||
Resource *ResourceContents `json:"resource,omitempty"` // EmbeddedResource
|
||||
URI string `json:"uri,omitempty"` // ResourceLink
|
||||
Name string `json:"name,omitempty"` // ResourceLink, ToolUseContent
|
||||
Title string `json:"title,omitempty"` // ResourceLink
|
||||
Description string `json:"description,omitempty"` // ResourceLink
|
||||
Size *int64 `json:"size,omitempty"` // ResourceLink
|
||||
Meta Meta `json:"_meta,omitempty"` // all types
|
||||
Annotations *Annotations `json:"annotations,omitempty"` // all types except ToolUseContent, ToolResultContent
|
||||
Icons []Icon `json:"icons,omitempty"` // ResourceLink
|
||||
ID string `json:"id,omitempty"` // ToolUseContent
|
||||
Input map[string]any `json:"input,omitempty"` // ToolUseContent
|
||||
ToolUseID string `json:"toolUseId,omitempty"` // ToolResultContent
|
||||
NestedContent []*wireContent `json:"content,omitempty"` // ToolResultContent
|
||||
StructuredContent any `json:"structuredContent,omitempty"` // ToolResultContent
|
||||
IsError bool `json:"isError,omitempty"` // ToolResultContent
|
||||
}
|
||||
|
||||
// unmarshalContent unmarshals JSON that is either a single content object or
|
||||
// an array of content objects. A single object is wrapped in a one-element slice.
|
||||
func unmarshalContent(raw json.RawMessage, allow map[string]bool) ([]Content, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, fmt.Errorf("nil content")
|
||||
}
|
||||
// Try array first, then fall back to single object.
|
||||
var wires []*wireContent
|
||||
if err := internaljson.Unmarshal(raw, &wires); err == nil {
|
||||
return contentsFromWire(wires, allow)
|
||||
}
|
||||
var wire wireContent
|
||||
if err := internaljson.Unmarshal(raw, &wire); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := contentFromWire(&wire, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Content{c}, nil
|
||||
}
|
||||
|
||||
func contentsFromWire(wires []*wireContent, allow map[string]bool) ([]Content, error) {
|
||||
blocks := make([]Content, 0, len(wires))
|
||||
for _, wire := range wires {
|
||||
block, err := contentFromWire(wire, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func contentFromWire(wire *wireContent, allow map[string]bool) (Content, error) {
|
||||
if wire == nil {
|
||||
return nil, fmt.Errorf("nil content")
|
||||
}
|
||||
if allow != nil && !allow[wire.Type] {
|
||||
return nil, fmt.Errorf("invalid content type %q", wire.Type)
|
||||
}
|
||||
switch wire.Type {
|
||||
case "text":
|
||||
v := new(TextContent)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "image":
|
||||
v := new(ImageContent)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "audio":
|
||||
v := new(AudioContent)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "resource_link":
|
||||
v := new(ResourceLink)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "resource":
|
||||
v := new(EmbeddedResource)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "tool_use":
|
||||
v := new(ToolUseContent)
|
||||
v.fromWire(wire)
|
||||
return v, nil
|
||||
case "tool_result":
|
||||
v := new(ToolResultContent)
|
||||
v.fromWire(wire)
|
||||
// Handle nested content - tool_result content can contain text, image, audio,
|
||||
// resource_link, and resource (same as CallToolResult.content)
|
||||
if wire.NestedContent != nil {
|
||||
toolResultContentAllow := map[string]bool{
|
||||
"text": true, "image": true, "audio": true,
|
||||
"resource_link": true, "resource": true,
|
||||
}
|
||||
nestedContent, err := contentsFromWire(wire.NestedContent, toolResultContentAllow)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool_result nested content: %w", err)
|
||||
}
|
||||
v.Content = nestedContent
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unrecognized content type %q", wire.Type)
|
||||
}
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file is for SSE events.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// If true, MemoryEventStore will do frequent validation to check invariants, slowing it down.
|
||||
// Enable for debugging.
|
||||
const validateMemoryEventStore = false
|
||||
|
||||
// An Event is a server-sent event.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#fields.
|
||||
type Event struct {
|
||||
Name string // the "event" field
|
||||
ID string // the "id" field
|
||||
Data []byte // the "data" field
|
||||
Retry string // the "retry" field
|
||||
}
|
||||
|
||||
// Empty reports whether the Event is empty.
|
||||
func (e Event) Empty() bool {
|
||||
return e.Name == "" && e.ID == "" && len(e.Data) == 0 && e.Retry == ""
|
||||
}
|
||||
|
||||
// writeEvent writes the event to w, and flushes.
|
||||
func writeEvent(w http.ResponseWriter, evt Event) (int, error) {
|
||||
var b bytes.Buffer
|
||||
if evt.Name != "" {
|
||||
fmt.Fprintf(&b, "event: %s\n", evt.Name)
|
||||
}
|
||||
if evt.ID != "" {
|
||||
fmt.Fprintf(&b, "id: %s\n", evt.ID)
|
||||
}
|
||||
if evt.Retry != "" {
|
||||
fmt.Fprintf(&b, "retry: %s\n", evt.Retry)
|
||||
}
|
||||
// Write the payload directly into a pre-grown buffer to avoid the extra
|
||||
// copy from string(evt.Data) and repeated buffer regrowths.
|
||||
b.Grow(len("data: \n\n") + len(evt.Data))
|
||||
b.WriteString("data: ")
|
||||
b.Write(evt.Data)
|
||||
b.WriteString("\n\n")
|
||||
n, err := w.Write(b.Bytes())
|
||||
rc := http.NewResponseController(w)
|
||||
// Ignore returned error as flushing is best-effort.
|
||||
_ = rc.Flush()
|
||||
return n, err
|
||||
}
|
||||
|
||||
// scanEvents iterates SSE events in the given scanner. The iterated error is
|
||||
// terminal: if encountered, the stream is corrupt or broken and should no
|
||||
// longer be used.
|
||||
//
|
||||
// TODO(rfindley): consider a different API here that makes failure modes more
|
||||
// apparent.
|
||||
func scanEvents(r io.Reader) iter.Seq2[Event, error] {
|
||||
reader := bufio.NewReader(r)
|
||||
|
||||
// TODO: investigate proper behavior when events are out of order, or have
|
||||
// non-standard names.
|
||||
var (
|
||||
eventKey = []byte("event")
|
||||
idKey = []byte("id")
|
||||
dataKey = []byte("data")
|
||||
retryKey = []byte("retry")
|
||||
)
|
||||
|
||||
return func(yield func(Event, error) bool) {
|
||||
// iterate event from the wire.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#examples
|
||||
//
|
||||
// - `key: value` line records.
|
||||
// - Consecutive `data: ...` fields are joined with newlines.
|
||||
// - Unrecognized fields are ignored. Since we only care about 'event', 'id', and
|
||||
// 'data', these are the only three we consider.
|
||||
// - Lines starting with ":" are ignored.
|
||||
// - Records are terminated with two consecutive newlines.
|
||||
var (
|
||||
evt Event
|
||||
dataBuf *bytes.Buffer // if non-nil, preceding field was also data
|
||||
)
|
||||
yieldEvent := func() bool {
|
||||
if dataBuf != nil {
|
||||
evt.Data = dataBuf.Bytes()
|
||||
dataBuf = nil
|
||||
}
|
||||
if evt.Empty() {
|
||||
return true
|
||||
}
|
||||
if !yield(evt, nil) {
|
||||
return false
|
||||
}
|
||||
evt = Event{}
|
||||
return true
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
yield(Event{}, fmt.Errorf("error reading event: %v", err))
|
||||
return
|
||||
}
|
||||
line = bytes.TrimRight(line, "\r\n")
|
||||
isEOF := errors.Is(err, io.EOF)
|
||||
|
||||
if len(line) == 0 {
|
||||
if !yieldEvent() {
|
||||
return
|
||||
}
|
||||
if isEOF {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
before, after, found := bytes.Cut(line, []byte{':'})
|
||||
if !found {
|
||||
yield(Event{}, fmt.Errorf("%w: malformed line in SSE stream: %q", errMalformedEvent, string(line)))
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case bytes.Equal(before, eventKey):
|
||||
evt.Name = strings.TrimSpace(string(after))
|
||||
case bytes.Equal(before, idKey):
|
||||
evt.ID = strings.TrimSpace(string(after))
|
||||
case bytes.Equal(before, retryKey):
|
||||
evt.Retry = strings.TrimSpace(string(after))
|
||||
case bytes.Equal(before, dataKey):
|
||||
data := bytes.TrimSpace(after)
|
||||
if dataBuf == nil {
|
||||
dataBuf = new(bytes.Buffer)
|
||||
} else {
|
||||
dataBuf.WriteByte('\n')
|
||||
}
|
||||
dataBuf.Write(data)
|
||||
}
|
||||
|
||||
if isEOF {
|
||||
yieldEvent()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An EventStore tracks data for SSE streams.
|
||||
// A single EventStore suffices for all sessions, since session IDs are
|
||||
// globally unique. So one EventStore can be created per process, for
|
||||
// all Servers in the process.
|
||||
// Such a store is able to bound resource usage for the entire process.
|
||||
//
|
||||
// All of an EventStore's methods must be safe for use by multiple goroutines.
|
||||
type EventStore interface {
|
||||
// Open is called when a new stream is created. It may be used to ensure that
|
||||
// the underlying data structure for the stream is initialized, making it
|
||||
// ready to store and replay event streams.
|
||||
Open(_ context.Context, sessionID, streamID string) error
|
||||
|
||||
// Append appends data for an outgoing event to given stream, which is part of the
|
||||
// given session.
|
||||
Append(_ context.Context, sessionID, streamID string, data []byte) error
|
||||
|
||||
// After returns an iterator over the data for the given session and stream, beginning
|
||||
// just after the given index.
|
||||
//
|
||||
// Once the iterator yields a non-nil error, it will stop.
|
||||
// After's iterator must return an error immediately if any data after index was
|
||||
// dropped; it must not return partial results.
|
||||
// The stream must have been opened previously (see [EventStore.Open]).
|
||||
After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error]
|
||||
|
||||
// SessionClosed informs the store that the given session is finished, along
|
||||
// with all of its streams.
|
||||
//
|
||||
// A store cannot rely on this method being called for cleanup. It should institute
|
||||
// additional mechanisms, such as timeouts, to reclaim storage.
|
||||
SessionClosed(_ context.Context, sessionID string) error
|
||||
|
||||
// There is no StreamClosed method. A server doesn't know when a stream is finished, because
|
||||
// the client can always send a GET with a Last-Event-ID referring to the stream.
|
||||
}
|
||||
|
||||
// A dataList is a list of []byte.
|
||||
// The zero dataList is ready to use.
|
||||
type dataList struct {
|
||||
size int // total size of data bytes
|
||||
first int // the stream index of the first element in data
|
||||
data [][]byte
|
||||
}
|
||||
|
||||
func (dl *dataList) appendData(d []byte) {
|
||||
// Empty data consumes memory but doesn't increment size. However, it should
|
||||
// be rare.
|
||||
dl.data = append(dl.data, d)
|
||||
dl.size += len(d)
|
||||
}
|
||||
|
||||
// removeFirst removes the first data item in dl, returning the size of the item.
|
||||
// It panics if dl is empty.
|
||||
func (dl *dataList) removeFirst() int {
|
||||
if len(dl.data) == 0 {
|
||||
panic("empty dataList")
|
||||
}
|
||||
r := len(dl.data[0])
|
||||
dl.size -= r
|
||||
dl.data[0] = nil // help GC
|
||||
dl.data = dl.data[1:]
|
||||
dl.first++
|
||||
return r
|
||||
}
|
||||
|
||||
// A MemoryEventStore is an [EventStore] backed by memory.
|
||||
type MemoryEventStore struct {
|
||||
mu sync.Mutex
|
||||
maxBytes int // max total size of all data
|
||||
nBytes int // current total size of all data
|
||||
store map[string]map[string]*dataList // session ID -> stream ID -> *dataList
|
||||
}
|
||||
|
||||
// MemoryEventStoreOptions are options for a [MemoryEventStore].
|
||||
type MemoryEventStoreOptions struct{}
|
||||
|
||||
// MaxBytes returns the maximum number of bytes that the store will retain before
|
||||
// purging data.
|
||||
func (s *MemoryEventStore) MaxBytes() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.maxBytes
|
||||
}
|
||||
|
||||
// SetMaxBytes sets the maximum number of bytes the store will retain before purging
|
||||
// data. The argument must not be negative. If it is zero, a suitable default will be used.
|
||||
// SetMaxBytes can be called at any time. The size of the store will be adjusted
|
||||
// immediately.
|
||||
func (s *MemoryEventStore) SetMaxBytes(n int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch {
|
||||
case n < 0:
|
||||
panic("negative argument")
|
||||
case n == 0:
|
||||
s.maxBytes = defaultMaxBytes
|
||||
default:
|
||||
s.maxBytes = n
|
||||
}
|
||||
s.purge()
|
||||
}
|
||||
|
||||
const defaultMaxBytes = 10 << 20 // 10 MiB
|
||||
|
||||
// NewMemoryEventStore creates a [MemoryEventStore] with the default value
|
||||
// for MaxBytes.
|
||||
func NewMemoryEventStore(opts *MemoryEventStoreOptions) *MemoryEventStore {
|
||||
return &MemoryEventStore{
|
||||
maxBytes: defaultMaxBytes,
|
||||
store: make(map[string]map[string]*dataList),
|
||||
}
|
||||
}
|
||||
|
||||
// Open implements [EventStore.Open]. It ensures that the underlying data
|
||||
// structures for the given session are initialized and ready for use.
|
||||
func (s *MemoryEventStore) Open(_ context.Context, sessionID, streamID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.init(sessionID, streamID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// init is an internal helper function that ensures the nested map structure for a
|
||||
// given sessionID and streamID exists, creating it if necessary. It returns the
|
||||
// dataList associated with the specified IDs.
|
||||
// Requires s.mu.
|
||||
func (s *MemoryEventStore) init(sessionID, streamID string) *dataList {
|
||||
streamMap, ok := s.store[sessionID]
|
||||
if !ok {
|
||||
streamMap = make(map[string]*dataList)
|
||||
s.store[sessionID] = streamMap
|
||||
}
|
||||
dl, ok := streamMap[streamID]
|
||||
if !ok {
|
||||
dl = &dataList{}
|
||||
streamMap[streamID] = dl
|
||||
}
|
||||
return dl
|
||||
}
|
||||
|
||||
// Append implements [EventStore.Append] by recording data in memory.
|
||||
func (s *MemoryEventStore) Append(_ context.Context, sessionID, streamID string, data []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
dl := s.init(sessionID, streamID)
|
||||
// Purge before adding, so at least the current data item will be present.
|
||||
// (That could result in nBytes > maxBytes, but we'll live with that.)
|
||||
s.purge()
|
||||
dl.appendData(data)
|
||||
s.nBytes += len(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrEventsPurged is the error that [EventStore.After] should return if the event just after the
|
||||
// index is no longer available.
|
||||
var ErrEventsPurged = errors.New("data purged")
|
||||
|
||||
// errMalformedEvent is returned when an SSE event cannot be parsed due to format violations.
|
||||
// This is a hard error indicating corrupted data or protocol violations, as opposed to
|
||||
// transient I/O errors which may be retryable.
|
||||
var errMalformedEvent = errors.New("malformed event")
|
||||
|
||||
// After implements [EventStore.After].
|
||||
func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] {
|
||||
// Return the data items to yield.
|
||||
// We must copy, because dataList.removeFirst nils out slice elements.
|
||||
copyData := func() ([][]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
streamMap, ok := s.store[sessionID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("MemoryEventStore.After: unknown session ID %q", sessionID)
|
||||
}
|
||||
dl, ok := streamMap[streamID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("MemoryEventStore.After: unknown stream ID %v in session %q", streamID, sessionID)
|
||||
}
|
||||
start := index + 1
|
||||
if dl.first > start {
|
||||
return nil, fmt.Errorf("MemoryEventStore.After: index %d, stream ID %v, session %q: %w",
|
||||
index, streamID, sessionID, ErrEventsPurged)
|
||||
}
|
||||
return slices.Clone(dl.data[start-dl.first:]), nil
|
||||
}
|
||||
|
||||
return func(yield func([]byte, error) bool) {
|
||||
ds, err := copyData()
|
||||
if err != nil {
|
||||
yield(nil, err)
|
||||
return
|
||||
}
|
||||
for _, d := range ds {
|
||||
if !yield(d, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SessionClosed implements [EventStore.SessionClosed].
|
||||
func (s *MemoryEventStore) SessionClosed(_ context.Context, sessionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, dl := range s.store[sessionID] {
|
||||
s.nBytes -= dl.size
|
||||
}
|
||||
delete(s.store, sessionID)
|
||||
s.validate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// purge removes data until no more than s.maxBytes bytes are in use.
|
||||
// It must be called with s.mu held.
|
||||
func (s *MemoryEventStore) purge() {
|
||||
// Remove the first element of every dataList until below the max.
|
||||
for s.nBytes > s.maxBytes {
|
||||
changed := false
|
||||
for _, sm := range s.store {
|
||||
for _, dl := range sm {
|
||||
if dl.size > 0 {
|
||||
r := dl.removeFirst()
|
||||
// Even if we remove an empty chunk, that's
|
||||
// still progress. There may be non-empty
|
||||
// chunks after it.
|
||||
changed = true
|
||||
s.nBytes -= r
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
panic("no progress during purge")
|
||||
}
|
||||
}
|
||||
s.validate()
|
||||
}
|
||||
|
||||
// validate checks that the store's data structures are valid.
|
||||
// It must be called with s.mu held.
|
||||
func (s *MemoryEventStore) validate() {
|
||||
if !validateMemoryEventStore {
|
||||
return
|
||||
}
|
||||
// Check that we're accounting for the size correctly.
|
||||
n := 0
|
||||
for _, sm := range s.store {
|
||||
for _, dl := range sm {
|
||||
for _, d := range dl.data {
|
||||
n += len(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n != s.nBytes {
|
||||
panic("sizes don't add up")
|
||||
}
|
||||
}
|
||||
|
||||
// debugString returns a string containing the state of s.
|
||||
// Used in tests.
|
||||
func (s *MemoryEventStore) debugString() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var b strings.Builder
|
||||
for i, sess := range slices.Sorted(maps.Keys(s.store)) {
|
||||
if i > 0 {
|
||||
fmt.Fprintf(&b, "; ")
|
||||
}
|
||||
sm := s.store[sess]
|
||||
for i, sid := range slices.Sorted(maps.Keys(sm)) {
|
||||
if i > 0 {
|
||||
fmt.Fprintf(&b, "; ")
|
||||
}
|
||||
dl := sm[sid]
|
||||
fmt.Fprintf(&b, "%s %s first=%d", sess, sid, dl.first)
|
||||
for _, d := range dl.data {
|
||||
fmt.Fprintf(&b, " %s", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// This file contains implementations that are common to all features.
|
||||
// A feature is an item provided to a peer. In the 2025-03-26 spec,
|
||||
// the features are prompt, tool, resource and root.
|
||||
|
||||
// A featureSet is a collection of features of type T.
|
||||
// Every feature has a unique ID, and the spec never mentions
|
||||
// an ordering for the List calls, so what it calls a "list" is actually a set.
|
||||
//
|
||||
// An alternative implementation would use an ordered map, but that's probably
|
||||
// not necessary as adds and removes are rare, and usually batched.
|
||||
type featureSet[T any] struct {
|
||||
uniqueID func(T) string
|
||||
features map[string]T
|
||||
sortedKeys []string // lazily computed; nil after add or remove
|
||||
}
|
||||
|
||||
// newFeatureSet creates a new featureSet for features of type T.
|
||||
// The argument function should return the unique ID for a single feature.
|
||||
func newFeatureSet[T any](uniqueIDFunc func(T) string) *featureSet[T] {
|
||||
return &featureSet[T]{
|
||||
uniqueID: uniqueIDFunc,
|
||||
features: make(map[string]T),
|
||||
}
|
||||
}
|
||||
|
||||
// add adds each feature to the set if it is not present,
|
||||
// or replaces an existing feature.
|
||||
func (s *featureSet[T]) add(fs ...T) {
|
||||
for _, f := range fs {
|
||||
s.features[s.uniqueID(f)] = f
|
||||
}
|
||||
s.sortedKeys = nil
|
||||
}
|
||||
|
||||
// remove removes all features with the given uids from the set if present,
|
||||
// and returns whether any were removed.
|
||||
// It is not an error to remove a nonexistent feature.
|
||||
func (s *featureSet[T]) remove(uids ...string) bool {
|
||||
changed := false
|
||||
for _, uid := range uids {
|
||||
if _, ok := s.features[uid]; ok {
|
||||
changed = true
|
||||
delete(s.features, uid)
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.sortedKeys = nil
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// get returns the feature with the given uid.
|
||||
// If there is none, it returns zero, false.
|
||||
func (s *featureSet[T]) get(uid string) (T, bool) {
|
||||
t, ok := s.features[uid]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// len returns the number of features in the set.
|
||||
func (s *featureSet[T]) len() int { return len(s.features) }
|
||||
|
||||
// all returns an iterator over of all the features in the set
|
||||
// sorted by unique ID.
|
||||
func (s *featureSet[T]) all() iter.Seq[T] {
|
||||
s.sortKeys()
|
||||
return func(yield func(T) bool) {
|
||||
s.yieldFrom(0, yield)
|
||||
}
|
||||
}
|
||||
|
||||
// above returns an iterator over features in the set whose unique IDs are
|
||||
// greater than `uid`, in ascending ID order.
|
||||
func (s *featureSet[T]) above(uid string) iter.Seq[T] {
|
||||
s.sortKeys()
|
||||
index, found := slices.BinarySearch(s.sortedKeys, uid)
|
||||
if found {
|
||||
index++
|
||||
}
|
||||
return func(yield func(T) bool) {
|
||||
s.yieldFrom(index, yield)
|
||||
}
|
||||
}
|
||||
|
||||
// sortKeys is a helper that maintains a sorted list of feature IDs. It
|
||||
// computes this list lazily upon its first call after a modification, or
|
||||
// if it's nil.
|
||||
func (s *featureSet[T]) sortKeys() {
|
||||
if s.sortedKeys != nil {
|
||||
return
|
||||
}
|
||||
s.sortedKeys = slices.Sorted(maps.Keys(s.features))
|
||||
}
|
||||
|
||||
// yieldFrom is a helper that iterates over the features in the set,
|
||||
// starting at the given index, and calls the yield function for each one.
|
||||
func (s *featureSet[T]) yieldFrom(index int, yield func(T) bool) {
|
||||
for i := index; i < len(s.sortedKeys); i++ {
|
||||
if !yield(s.features[s.sortedKeys[i]]) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Logging levels.
|
||||
const (
|
||||
LevelDebug = slog.LevelDebug
|
||||
LevelInfo = slog.LevelInfo
|
||||
LevelNotice = (slog.LevelInfo + slog.LevelWarn) / 2
|
||||
LevelWarning = slog.LevelWarn
|
||||
LevelError = slog.LevelError
|
||||
LevelCritical = slog.LevelError + 4
|
||||
LevelAlert = slog.LevelError + 8
|
||||
LevelEmergency = slog.LevelError + 12
|
||||
)
|
||||
|
||||
var slogToMCP = map[slog.Level]LoggingLevel{
|
||||
LevelDebug: "debug",
|
||||
LevelInfo: "info",
|
||||
LevelNotice: "notice",
|
||||
LevelWarning: "warning",
|
||||
LevelError: "error",
|
||||
LevelCritical: "critical",
|
||||
LevelAlert: "alert",
|
||||
LevelEmergency: "emergency",
|
||||
}
|
||||
|
||||
var mcpToSlog = make(map[LoggingLevel]slog.Level)
|
||||
|
||||
func init() {
|
||||
for sl, ml := range slogToMCP {
|
||||
mcpToSlog[ml] = sl
|
||||
}
|
||||
}
|
||||
|
||||
func slogLevelToMCP(sl slog.Level) LoggingLevel {
|
||||
if ml, ok := slogToMCP[sl]; ok {
|
||||
return ml
|
||||
}
|
||||
return "debug" // for lack of a better idea
|
||||
}
|
||||
|
||||
func mcpLevelToSlog(ll LoggingLevel) slog.Level {
|
||||
if sl, ok := mcpToSlog[ll]; ok {
|
||||
return sl
|
||||
}
|
||||
// TODO: is there a better default?
|
||||
return LevelDebug
|
||||
}
|
||||
|
||||
// compareLevels behaves like [cmp.Compare] for [LoggingLevel]s.
|
||||
func compareLevels(l1, l2 LoggingLevel) int {
|
||||
return cmp.Compare(mcpLevelToSlog(l1), mcpLevelToSlog(l2))
|
||||
}
|
||||
|
||||
// LoggingHandlerOptions are options for a LoggingHandler.
|
||||
//
|
||||
// Deprecated: the logging feature is deprecated as of protocol version
|
||||
// 2026-07-28 (SEP-2577). It remains functional during the deprecation window
|
||||
// (at least twelve months). See
|
||||
// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging.
|
||||
type LoggingHandlerOptions struct {
|
||||
// The value for the "logger" field of logging notifications.
|
||||
LoggerName string
|
||||
// Limits the rate at which log messages are sent.
|
||||
// Excess messages are dropped.
|
||||
// If zero, there is no rate limiting.
|
||||
MinInterval time.Duration
|
||||
}
|
||||
|
||||
// A LoggingHandler is a [slog.Handler] for MCP.
|
||||
//
|
||||
// Deprecated: the logging feature is deprecated as of protocol version
|
||||
// 2026-07-28 (SEP-2577). It remains functional during the deprecation window
|
||||
// (at least twelve months). See
|
||||
// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging.
|
||||
type LoggingHandler struct {
|
||||
opts LoggingHandlerOptions
|
||||
ss *ServerSession
|
||||
// Ensures that the buffer reset is atomic with the write (see Handle).
|
||||
// A pointer so that clones share the mutex. See
|
||||
// https://github.com/golang/example/blob/master/slog-handler-guide/README.md#getting-the-mutex-right.
|
||||
mu *sync.Mutex
|
||||
limiter *rate.Limiter // for rate-limiting
|
||||
buf *bytes.Buffer
|
||||
handler slog.Handler
|
||||
}
|
||||
|
||||
// ensureLogger returns l if non-nil, otherwise a discard logger.
|
||||
func ensureLogger(l *slog.Logger) *slog.Logger {
|
||||
if l != nil {
|
||||
return l
|
||||
}
|
||||
return slog.New(slog.DiscardHandler)
|
||||
}
|
||||
|
||||
// NewLoggingHandler creates a [LoggingHandler] that logs to the given [ServerSession] using a
|
||||
// [slog.JSONHandler].
|
||||
//
|
||||
// Deprecated: the logging feature is deprecated as of protocol version
|
||||
// 2026-07-28 (SEP-2577). It remains functional during the deprecation window
|
||||
// (at least twelve months). See
|
||||
// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging.
|
||||
func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingHandler {
|
||||
var buf bytes.Buffer
|
||||
jsonHandler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||
// Remove level: it appears in LoggingMessageParams.
|
||||
if a.Key == slog.LevelKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
})
|
||||
lh := &LoggingHandler{
|
||||
ss: ss,
|
||||
mu: new(sync.Mutex),
|
||||
buf: &buf,
|
||||
handler: jsonHandler,
|
||||
}
|
||||
if opts != nil {
|
||||
lh.opts = *opts
|
||||
if opts.MinInterval > 0 {
|
||||
lh.limiter = rate.NewLimiter(rate.Every(opts.MinInterval), 1)
|
||||
}
|
||||
}
|
||||
return lh
|
||||
}
|
||||
|
||||
// Enabled implements [slog.Handler.Enabled] by comparing level to the [ServerSession]'s level.
|
||||
func (h *LoggingHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
// This is also checked in ServerSession.LoggingMessage, so checking it here
|
||||
// is just an optimization that skips building the JSON.
|
||||
h.ss.mu.Lock()
|
||||
mcpLevel := h.ss.state.LogLevel
|
||||
h.ss.mu.Unlock()
|
||||
return level >= mcpLevelToSlog(mcpLevel)
|
||||
}
|
||||
|
||||
// WithAttrs implements [slog.Handler.WithAttrs].
|
||||
func (h *LoggingHandler) WithAttrs(as []slog.Attr) slog.Handler {
|
||||
h2 := *h
|
||||
h2.handler = h.handler.WithAttrs(as)
|
||||
return &h2
|
||||
}
|
||||
|
||||
// WithGroup implements [slog.Handler.WithGroup].
|
||||
func (h *LoggingHandler) WithGroup(name string) slog.Handler {
|
||||
h2 := *h
|
||||
h2.handler = h.handler.WithGroup(name)
|
||||
return &h2
|
||||
}
|
||||
|
||||
// Handle implements [slog.Handler.Handle] by writing the Record to a JSONHandler,
|
||||
// then calling [ServerSession.LoggingMessage] with the result.
|
||||
func (h *LoggingHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
err := h.handle(ctx, r)
|
||||
// TODO(jba): find a way to surface the error.
|
||||
// The return value will probably be ignored.
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error {
|
||||
// Observe the rate limit.
|
||||
if h.limiter != nil && !h.limiter.Allow() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
var data json.RawMessage
|
||||
// Make the buffer reset atomic with the record write.
|
||||
// We are careful here in the unlikely event that the handler panics.
|
||||
// We don't want to hold the lock for the entire function, because Notify is
|
||||
// an I/O operation.
|
||||
// This can result in out-of-order delivery.
|
||||
func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.buf.Reset()
|
||||
err = h.handler.Handle(ctx, r)
|
||||
// Clone the buffer as Bytes() references the internal buffer.
|
||||
data = json.RawMessage(slices.Clone(h.buf.Bytes()))
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := &LoggingMessageParams{
|
||||
Logger: h.opts.LoggerName,
|
||||
Level: slogLevelToMCP(r.Level),
|
||||
Data: data,
|
||||
}
|
||||
// We pass the argument context to Notify, even though slog.Handler.Handle's
|
||||
// documentation says not to.
|
||||
// In this case logging is a service to clients, not a means for debugging the
|
||||
// server, so we want to cancel the log message.
|
||||
return h.ss.Log(ctx, params)
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// The mcp package provides an SDK for writing model context protocol clients
|
||||
// and servers.
|
||||
//
|
||||
// To get started, create either a [Client] or [Server], add features to it
|
||||
// using `AddXXX` functions, and connect it to a peer using a [Transport].
|
||||
//
|
||||
// For example, to run a simple server on the [StdioTransport]:
|
||||
//
|
||||
// server := mcp.NewServer(&mcp.Implementation{Name: "greeter"}, nil)
|
||||
//
|
||||
// // Using the generic AddTool automatically populates the the input and output
|
||||
// // schema of the tool.
|
||||
// type args struct {
|
||||
// Name string `json:"name" jsonschema:"the person to greet"`
|
||||
// }
|
||||
// mcp.AddTool(server, &mcp.Tool{
|
||||
// Name: "greet",
|
||||
// Description: "say hi",
|
||||
// }, func(ctx context.Context, req *mcp.CallToolRequest, args args) (*mcp.CallToolResult, any, error) {
|
||||
// return &mcp.CallToolResult{
|
||||
// Content: []mcp.Content{
|
||||
// &mcp.TextContent{Text: "Hi " + args.Name},
|
||||
// },
|
||||
// }, nil, nil
|
||||
// })
|
||||
//
|
||||
// // Run the server on the stdio transport.
|
||||
// if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
|
||||
// log.Printf("Server failed: %v", err)
|
||||
// }
|
||||
//
|
||||
// To connect to this server, use the [CommandTransport]:
|
||||
//
|
||||
// client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil)
|
||||
// transport := &mcp.CommandTransport{Command: exec.Command("myserver")}
|
||||
// session, err := client.Connect(ctx, transport, nil)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// defer session.Close()
|
||||
//
|
||||
// params := &mcp.CallToolParams{
|
||||
// Name: "greet",
|
||||
// Arguments: map[string]any{"name": "you"},
|
||||
// }
|
||||
// res, err := session.CallTool(ctx, params)
|
||||
// if err != nil {
|
||||
// log.Fatalf("CallTool failed: %v", err)
|
||||
// }
|
||||
//
|
||||
// # Clients, servers, and sessions
|
||||
//
|
||||
// In this SDK, both a [Client] and [Server] may handle many concurrent
|
||||
// connections. Each time a client or server is connected to a peer using a
|
||||
// [Transport], it creates a new session (either a [ClientSession] or
|
||||
// [ServerSession]):
|
||||
//
|
||||
// Client Server
|
||||
// ⇅ (jsonrpc2) ⇅
|
||||
// ClientSession ⇄ Client Transport ⇄ Server Transport ⇄ ServerSession
|
||||
//
|
||||
// The session types expose an API to interact with its peer. For example,
|
||||
// [ClientSession.CallTool] or [ServerSession.ListRoots].
|
||||
//
|
||||
// # Adding features
|
||||
//
|
||||
// Add MCP servers to your Client or Server using AddXXX methods (for example
|
||||
// [Client.AddRoot] or [Server.AddPrompt]). If any peers are connected when
|
||||
// AddXXX is called, they will receive a corresponding change notification
|
||||
// (for example notifications/roots/list_changed).
|
||||
//
|
||||
// Adding tools is special: tools may be bound to ordinary Go functions by
|
||||
// using the top-level generic [AddTool] function, which allows specifying an
|
||||
// input and output type. When AddTool is used, the tool's input schema and
|
||||
// output schema are automatically populated, and inputs are automatically
|
||||
// validated. As a special case, if the output type is 'any', no output schema
|
||||
// is generated.
|
||||
//
|
||||
// func double(_ context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) {
|
||||
// return nil, Out{Answer: 2*in.Number}, nil
|
||||
// }
|
||||
// ...
|
||||
// mcp.AddTool(server, &mcp.Tool{Name: "double"}, double)
|
||||
package mcp
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const maxMultiRoundTripRetries = 10
|
||||
const maxLoadSheddingMultiRoundTripRetries = 3
|
||||
|
||||
// MultiRoundTripOptions configures the client-side multi round-trip request (SEP-2322)
|
||||
// middleware. The middleware is enabled by default and automatically fulfills input
|
||||
// requests from the server by invoking the appropriate client handlers and
|
||||
// retrying the original call.
|
||||
type MultiRoundTripOptions struct {
|
||||
// Disabled prevents the automatic multi-round-tirp middleware from being installed.
|
||||
// When true, the client returns input-required results directly and callers must
|
||||
// handle the retry loop themselves using [CallToolResult.NeedsInput],
|
||||
// [GetPromptResult.NeedsInput], or [ReadResourceResult.NeedsInput].
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type multiRoundTripResponse interface {
|
||||
setResultType(resultType)
|
||||
inputRequests() map[string]InputRequest
|
||||
requestState() string
|
||||
hasContent() bool
|
||||
}
|
||||
|
||||
func handleMultiRoundTripResult(ss *ServerSession, logger *slog.Logger, res multiRoundTripResponse) error {
|
||||
if res == nil {
|
||||
return nil
|
||||
}
|
||||
hasInputRequests := res.inputRequests() != nil
|
||||
|
||||
if hasInputRequests && res.hasContent() {
|
||||
logger.Warn("handler returned both content and inputRequests")
|
||||
return &jsonrpc.Error{
|
||||
Code: jsonrpc.CodeInternalError,
|
||||
Message: "server bug: result has both content and inputRequests",
|
||||
}
|
||||
}
|
||||
|
||||
if clientSupportsMultiRoundTrip(ss) {
|
||||
// For older clients the resultType is left unset. Input requests will be handled
|
||||
// by serverMultiRoundTripMiddleware client calls and handler reinvocation.
|
||||
if hasInputRequests {
|
||||
res.setResultType(resultTypeInputRequired)
|
||||
} else {
|
||||
res.setResultType(resultTypeComplete)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clientSupportsMultiRoundTrip(ss *ServerSession) bool {
|
||||
protocolVersion := latestProtocolVersion
|
||||
if iparams := ss.InitializeParams(); iparams != nil {
|
||||
protocolVersion = iparams.ProtocolVersion
|
||||
}
|
||||
return protocolVersion >= protocolVersion20260728
|
||||
}
|
||||
|
||||
func clientMultiRoundTripMiddleware() Middleware {
|
||||
return func(next MethodHandler) MethodHandler {
|
||||
return func(ctx context.Context, method string, req Request) (Result, error) {
|
||||
if method != methodCallTool && method != methodGetPrompt && method != methodReadResource {
|
||||
return next(ctx, method, req)
|
||||
}
|
||||
|
||||
loadSheddingFailures := 0
|
||||
for retries := 1; ; retries++ {
|
||||
res, err := next(ctx, method, req)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
mrtrResult, ok := res.(multiRoundTripResponse)
|
||||
if !ok {
|
||||
return res, nil
|
||||
}
|
||||
reqMap := mrtrResult.inputRequests()
|
||||
if reqMap == nil {
|
||||
return res, nil
|
||||
}
|
||||
if len(reqMap) == 0 {
|
||||
loadSheddingFailures++
|
||||
}
|
||||
if loadSheddingFailures >= maxLoadSheddingMultiRoundTripRetries {
|
||||
return nil, fmt.Errorf("multi-round-trip: exceeded maximum load-shedding retries (%d)", maxLoadSheddingMultiRoundTripRetries)
|
||||
}
|
||||
if retries >= maxMultiRoundTripRetries {
|
||||
return nil, fmt.Errorf("multi-round-trip: exceeded maximum retries (%d)", maxMultiRoundTripRetries)
|
||||
}
|
||||
cs, ok := req.GetSession().(*ClientSession)
|
||||
if !ok {
|
||||
return res, nil
|
||||
}
|
||||
responses, err := fulfillInputRequests(ctx, cs, reqMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setMultiRoundTripRetryParams(req, responses, mrtrResult.requestState())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serverMultiRoundTripMiddleware is a receiving middleware for servers that transparently
|
||||
// handles multi-round-trip for clients on older protocol versions. When a handler returns
|
||||
// InputRequests and the client does not support multi-round-trip, the middleware fulfills
|
||||
// the requests by calling the client directly and reinvokes the handler once with the responses.
|
||||
func serverMultiRoundTripMiddleware() Middleware {
|
||||
return func(next MethodHandler) MethodHandler {
|
||||
return func(ctx context.Context, method string, req Request) (Result, error) {
|
||||
if method != methodCallTool && method != methodGetPrompt && method != methodReadResource {
|
||||
return next(ctx, method, req)
|
||||
}
|
||||
|
||||
ss, ok := req.GetSession().(*ServerSession)
|
||||
if !ok {
|
||||
return next(ctx, method, req)
|
||||
}
|
||||
if clientSupportsMultiRoundTrip(ss) {
|
||||
return next(ctx, method, req)
|
||||
}
|
||||
|
||||
res, err := next(ctx, method, req)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
mrtrResult, ok := res.(multiRoundTripResponse)
|
||||
if !ok {
|
||||
return res, nil
|
||||
}
|
||||
reqMap := mrtrResult.inputRequests()
|
||||
if reqMap == nil {
|
||||
return res, nil
|
||||
}
|
||||
if len(reqMap) == 0 {
|
||||
return nil, fmt.Errorf("the server is busy, retry later")
|
||||
}
|
||||
responses, err := fulfillServerInputRequests(ctx, ss, reqMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setMultiRoundTripRetryParams(req, responses, mrtrResult.requestState())
|
||||
return next(ctx, method, req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fulfillServerInputRequests(ctx context.Context, ss *ServerSession, requests InputRequestMap) (InputResponseMap, error) {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
var mu sync.Mutex
|
||||
responses := make(InputResponseMap, len(requests))
|
||||
for id, ir := range requests {
|
||||
g.Go(func() error {
|
||||
resp, err := fulfillServerInputRequest(ctx, ss, ir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fulfilling input request %q: %w", id, err)
|
||||
}
|
||||
mu.Lock()
|
||||
responses[id] = resp
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, fmt.Errorf("multi-round-trip: %w", err)
|
||||
}
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func fulfillServerInputRequest(ctx context.Context, ss *ServerSession, ir InputRequest) (InputResponse, error) {
|
||||
switch p := ir.(type) {
|
||||
case *ElicitParams:
|
||||
return ss.Elicit(ctx, p)
|
||||
case *CreateMessageParams:
|
||||
return ss.CreateMessageWithTools(ctx, createMessageParamsToWithTools(p))
|
||||
case *CreateMessageWithToolsParams:
|
||||
return ss.CreateMessageWithTools(ctx, p)
|
||||
case *ListRootsParams:
|
||||
return ss.ListRoots(ctx, p)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown input request type: %T", ir)
|
||||
}
|
||||
}
|
||||
|
||||
func createMessageParamsToWithTools(p *CreateMessageParams) *CreateMessageWithToolsParams {
|
||||
var msgs []*SamplingMessageV2
|
||||
for _, m := range p.Messages {
|
||||
msgs = append(msgs, &SamplingMessageV2{Content: []Content{m.Content}, Role: m.Role})
|
||||
}
|
||||
return &CreateMessageWithToolsParams{
|
||||
Meta: p.Meta,
|
||||
IncludeContext: p.IncludeContext,
|
||||
MaxTokens: p.MaxTokens,
|
||||
Messages: msgs,
|
||||
Metadata: p.Metadata,
|
||||
ModelPreferences: p.ModelPreferences,
|
||||
StopSequences: p.StopSequences,
|
||||
SystemPrompt: p.SystemPrompt,
|
||||
Temperature: p.Temperature,
|
||||
}
|
||||
}
|
||||
|
||||
func setMultiRoundTripRetryParams(req Request, responses InputResponseMap, state string) {
|
||||
switch p := req.GetParams().(type) {
|
||||
case *CallToolParams:
|
||||
p.InputResponses = responses
|
||||
p.RequestState = state
|
||||
case *CallToolParamsRaw:
|
||||
p.InputResponses = responses
|
||||
p.RequestState = state
|
||||
case *GetPromptParams:
|
||||
p.InputResponses = responses
|
||||
p.RequestState = state
|
||||
case *ReadResourceParams:
|
||||
p.InputResponses = responses
|
||||
p.RequestState = state
|
||||
}
|
||||
}
|
||||
|
||||
func fulfillInputRequests(ctx context.Context, cs *ClientSession, requests InputRequestMap) (InputResponseMap, error) {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
var mu sync.Mutex
|
||||
responses := make(InputResponseMap, len(requests))
|
||||
for id, ir := range requests {
|
||||
g.Go(func() error {
|
||||
resp, err := fulfillInputRequest(ctx, cs, ir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fulfilling input request %q: %w", id, err)
|
||||
}
|
||||
mu.Lock()
|
||||
responses[id] = resp
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, fmt.Errorf("multi round-trip: %w", err)
|
||||
}
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func fulfillInputRequest(ctx context.Context, cs *ClientSession, ir InputRequest) (InputResponse, error) {
|
||||
switch p := ir.(type) {
|
||||
case *ElicitParams:
|
||||
return cs.client.elicit(ctx, newClientRequest(cs, p))
|
||||
case *CreateMessageParams:
|
||||
return cs.client.createMessage(ctx, &CreateMessageWithToolsRequest{Session: cs, Params: createMessageParamsToWithTools(p)})
|
||||
case *CreateMessageWithToolsParams:
|
||||
return cs.client.createMessage(ctx, &CreateMessageWithToolsRequest{Session: cs, Params: p})
|
||||
case *ListRootsParams:
|
||||
return cs.client.listRoots(ctx, newClientRequest(cs, p))
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown input request type: %T", ir)
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// A PromptHandler handles a call to prompts/get.
|
||||
type PromptHandler func(context.Context, *GetPromptRequest) (*GetPromptResult, error)
|
||||
|
||||
type serverPrompt struct {
|
||||
prompt *Prompt
|
||||
handler PromptHandler
|
||||
}
|
||||
+2406
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file holds the request types.
|
||||
|
||||
package mcp
|
||||
|
||||
type (
|
||||
CallToolRequest = ServerRequest[*CallToolParamsRaw]
|
||||
CompleteRequest = ServerRequest[*CompleteParams]
|
||||
GetPromptRequest = ServerRequest[*GetPromptParams]
|
||||
InitializedRequest = ServerRequest[*InitializedParams]
|
||||
ListPromptsRequest = ServerRequest[*ListPromptsParams]
|
||||
ListResourcesRequest = ServerRequest[*ListResourcesParams]
|
||||
ListResourceTemplatesRequest = ServerRequest[*ListResourceTemplatesParams]
|
||||
ListToolsRequest = ServerRequest[*ListToolsParams]
|
||||
ProgressNotificationServerRequest = ServerRequest[*ProgressNotificationParams]
|
||||
ReadResourceRequest = ServerRequest[*ReadResourceParams]
|
||||
RootsListChangedRequest = ServerRequest[*RootsListChangedParams]
|
||||
SubscribeRequest = ServerRequest[*SubscribeParams]
|
||||
SubscriptionsListenRequest = ServerRequest[*SubscriptionsListenParams]
|
||||
UnsubscribeRequest = ServerRequest[*UnsubscribeParams]
|
||||
)
|
||||
|
||||
type (
|
||||
CreateMessageRequest = ClientRequest[*CreateMessageParams]
|
||||
CreateMessageWithToolsRequest = ClientRequest[*CreateMessageWithToolsParams]
|
||||
DiscoverRequest = ClientRequest[*DiscoverParams]
|
||||
ElicitRequest = ClientRequest[*ElicitParams]
|
||||
initializedClientRequest = ClientRequest[*InitializedParams]
|
||||
InitializeRequest = ClientRequest[*InitializeParams]
|
||||
ListRootsRequest = ClientRequest[*ListRootsParams]
|
||||
LoggingMessageRequest = ClientRequest[*LoggingMessageParams]
|
||||
ProgressNotificationClientRequest = ClientRequest[*ProgressNotificationParams]
|
||||
PromptListChangedRequest = ClientRequest[*PromptListChangedParams]
|
||||
ResourceListChangedRequest = ClientRequest[*ResourceListChangedParams]
|
||||
ResourceUpdatedNotificationRequest = ClientRequest[*ResourceUpdatedNotificationParams]
|
||||
ToolListChangedRequest = ClientRequest[*ToolListChangedParams]
|
||||
ElicitationCompleteNotificationRequest = ClientRequest[*ElicitationCompleteParams]
|
||||
)
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
"github.com/yosida95/uritemplate/v3"
|
||||
)
|
||||
|
||||
// A serverResource associates a Resource with its handler.
|
||||
type serverResource struct {
|
||||
resource *Resource
|
||||
handler ResourceHandler
|
||||
}
|
||||
|
||||
// A serverResourceTemplate associates a ResourceTemplate with its handler.
|
||||
type serverResourceTemplate struct {
|
||||
resourceTemplate *ResourceTemplate
|
||||
handler ResourceHandler
|
||||
}
|
||||
|
||||
// A ResourceHandler is a function that reads a resource.
|
||||
// It will be called when the client calls [ClientSession.ReadResource].
|
||||
// If it cannot find the resource, it should return the result of calling [ResourceNotFoundError].
|
||||
type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error)
|
||||
|
||||
// customresnotfounderrcode is a compatibility parameter that restores the
|
||||
// pre-1.7.0 behavior of [ResourceNotFoundError] and [CodeResourceNotFound],
|
||||
// where the error code was a custom -32002. See the documentation for the mcpgodebug
|
||||
// package for instructions on how to enable it.
|
||||
// The option will be removed in the future version of the SDK.
|
||||
var customresnotfounderrcode = mcpgodebug.Value("customresnotfounderrcode")
|
||||
|
||||
func init() {
|
||||
if customresnotfounderrcode == "1" {
|
||||
CodeResourceNotFound = -32002
|
||||
}
|
||||
}
|
||||
|
||||
// ResourceNotFoundError returns an error indicating that a resource being read could
|
||||
// not be found.
|
||||
//
|
||||
// By default, the error code is -32602 (Invalid Params), as specified in the
|
||||
// MCP specification (SEP-2164). To restore the pre-1.7.0 release behavior where the
|
||||
// error code was -32002, set MCPGODEBUG=customresnotfounderrcode=1.
|
||||
func ResourceNotFoundError(uri string) error {
|
||||
return &jsonrpc.Error{
|
||||
Code: CodeResourceNotFound,
|
||||
Message: "Resource not found",
|
||||
Data: json.RawMessage(fmt.Sprintf(`{"uri":%q}`, uri)),
|
||||
}
|
||||
}
|
||||
|
||||
// readFileResource reads from the filesystem at a URI relative to dirFilepath, respecting
|
||||
// the roots.
|
||||
// dirFilepath and rootFilepaths are absolute filesystem paths.
|
||||
func readFileResource(rawURI, dirFilepath string, rootFilepaths []string) ([]byte, error) {
|
||||
uriFilepath, err := computeURIFilepath(rawURI, dirFilepath, rootFilepaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data []byte
|
||||
err = withFile(dirFilepath, uriFilepath, func(f *os.File) error {
|
||||
var err error
|
||||
data, err = io.ReadAll(f)
|
||||
return err
|
||||
})
|
||||
if os.IsNotExist(err) {
|
||||
err = ResourceNotFoundError(rawURI)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// computeURIFilepath returns a path relative to dirFilepath.
|
||||
// The dirFilepath and rootFilepaths are absolute file paths.
|
||||
func computeURIFilepath(rawURI, dirFilepath string, rootFilepaths []string) (string, error) {
|
||||
// We use "file path" to mean a filesystem path.
|
||||
uri, err := url.Parse(rawURI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if uri.Scheme != "file" {
|
||||
return "", fmt.Errorf("URI is not a file: %s", uri)
|
||||
}
|
||||
if uri.Path == "" {
|
||||
// A more specific error than the one below, to catch the
|
||||
// common mistake "file://foo".
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
// The URI's path is interpreted relative to dirFilepath, and in the local filesystem.
|
||||
// It must not try to escape its directory.
|
||||
uriFilepathRel, err := filepath.Localize(strings.TrimPrefix(uri.Path, "/"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%q cannot be localized: %w", uriFilepathRel, err)
|
||||
}
|
||||
|
||||
// Check roots, if there are any.
|
||||
if len(rootFilepaths) > 0 {
|
||||
// To check against the roots, we need an absolute file path, not relative to the directory.
|
||||
// uriFilepath is local, so the joined path is under dirFilepath.
|
||||
uriFilepathAbs := filepath.Join(dirFilepath, uriFilepathRel)
|
||||
rootOK := false
|
||||
// Check that the requested file path is under some root.
|
||||
// Since both paths are absolute, that's equivalent to filepath.Rel constructing
|
||||
// a local path.
|
||||
for _, rootFilepathAbs := range rootFilepaths {
|
||||
if rel, err := filepath.Rel(rootFilepathAbs, uriFilepathAbs); err == nil && filepath.IsLocal(rel) {
|
||||
rootOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !rootOK {
|
||||
return "", fmt.Errorf("URI path %q is not under any root", uriFilepathAbs)
|
||||
}
|
||||
}
|
||||
return uriFilepathRel, nil
|
||||
}
|
||||
|
||||
// withFile calls f on the file at join(dir, rel),
|
||||
// protecting against path traversal attacks.
|
||||
func withFile(dir, rel string, f func(*os.File) error) (err error) {
|
||||
r, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
file, err := r.Open(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Record error, in case f writes.
|
||||
defer func() { err = errors.Join(err, file.Close()) }()
|
||||
return f(file)
|
||||
}
|
||||
|
||||
// fileRoots transforms the Roots obtained from the client into absolute paths on
|
||||
// the local filesystem.
|
||||
// TODO(jba): expose this functionality to user ResourceHandlers,
|
||||
// so they don't have to repeat it.
|
||||
func fileRoots(rawRoots []*Root) ([]string, error) {
|
||||
var fileRoots []string
|
||||
for _, r := range rawRoots {
|
||||
fr, err := fileRoot(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileRoots = append(fileRoots, fr)
|
||||
}
|
||||
return fileRoots, nil
|
||||
}
|
||||
|
||||
// fileRoot returns the absolute path for Root.
|
||||
func fileRoot(root *Root) (_ string, err error) {
|
||||
defer util.Wrapf(&err, "root %q", root.URI)
|
||||
|
||||
// Convert to absolute file path.
|
||||
rurl, err := url.Parse(root.URI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rurl.Scheme != "file" {
|
||||
return "", errors.New("not a file URI")
|
||||
}
|
||||
if rurl.Path == "" {
|
||||
// A more specific error than the one below, to catch the
|
||||
// common mistake "file://foo".
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
// We don't want Localize here: we want an absolute path, which is not local.
|
||||
fileRoot := filepath.Clean(filepath.FromSlash(rurl.Path))
|
||||
if !filepath.IsAbs(fileRoot) {
|
||||
return "", errors.New("not an absolute path")
|
||||
}
|
||||
return fileRoot, nil
|
||||
}
|
||||
|
||||
// Matches reports whether the receiver's uri template matches the uri.
|
||||
func (sr *serverResourceTemplate) Matches(uri string) bool {
|
||||
tmpl, err := uritemplate.New(sr.resourceTemplate.URITemplate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return tmpl.Regexp().MatchString(uri)
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
)
|
||||
|
||||
// A SchemaCache caches JSON schemas to avoid repeated reflection and resolution.
|
||||
//
|
||||
// This is useful for stateless server deployments (one [Server] per request)
|
||||
// where tools are re-registered on every request. Without caching, each
|
||||
// [AddTool] call triggers expensive reflection-based schema generation.
|
||||
//
|
||||
// A SchemaCache is safe for concurrent use by multiple goroutines.
|
||||
//
|
||||
// # Trade-offs
|
||||
//
|
||||
// The cache is unbounded: it stores one entry per unique Go type or schema
|
||||
// pointer. For typical MCP servers with a fixed set of tools, memory usage
|
||||
// is negligible. However, if tool input types are generated dynamically,
|
||||
// the cache will grow without bound.
|
||||
//
|
||||
// The cache uses pointer identity for pre-defined schemas. If a schema's
|
||||
// contents change but the pointer remains the same, stale resolved schemas
|
||||
// may be returned. In practice, this is not an issue because tool schemas
|
||||
// are typically defined once at startup.
|
||||
type SchemaCache struct {
|
||||
byType sync.Map // reflect.Type -> *cachedSchema
|
||||
bySchema sync.Map // *jsonschema.Schema -> *jsonschema.Resolved
|
||||
}
|
||||
|
||||
type cachedSchema struct {
|
||||
schema *jsonschema.Schema
|
||||
resolved *jsonschema.Resolved
|
||||
}
|
||||
|
||||
// NewSchemaCache creates a new [SchemaCache].
|
||||
func NewSchemaCache() *SchemaCache {
|
||||
return &SchemaCache{}
|
||||
}
|
||||
|
||||
func (c *SchemaCache) getByType(t reflect.Type) (*jsonschema.Schema, *jsonschema.Resolved, bool) {
|
||||
if v, ok := c.byType.Load(t); ok {
|
||||
cs := v.(*cachedSchema)
|
||||
return cs.schema, cs.resolved, true
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func (c *SchemaCache) setByType(t reflect.Type, schema *jsonschema.Schema, resolved *jsonschema.Resolved) {
|
||||
c.byType.Store(t, &cachedSchema{schema: schema, resolved: resolved})
|
||||
}
|
||||
|
||||
func (c *SchemaCache) getBySchema(schema *jsonschema.Schema) (*jsonschema.Resolved, bool) {
|
||||
if v, ok := c.bySchema.Load(schema); ok {
|
||||
return v.(*jsonschema.Resolved), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (c *SchemaCache) setBySchema(schema *jsonschema.Schema, resolved *jsonschema.Resolved) {
|
||||
c.bySchema.Store(schema, resolved)
|
||||
}
|
||||
+2173
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
// hasSessionID is the interface which, if implemented by connections, informs
|
||||
// the session about their session ID.
|
||||
//
|
||||
// TODO(rfindley): remove SessionID methods from connections, when it doesn't
|
||||
// make sense. Or remove it from the Sessions entirely: why does it even need
|
||||
// to be exposed?
|
||||
type hasSessionID interface {
|
||||
SessionID() string
|
||||
}
|
||||
|
||||
// ServerSessionState is the state of a session.
|
||||
type ServerSessionState struct {
|
||||
// InitializeParams are the parameters from 'initialize'.
|
||||
InitializeParams *InitializeParams `json:"initializeParams"`
|
||||
|
||||
// InitializedParams are the parameters from 'notifications/initialized'.
|
||||
InitializedParams *InitializedParams `json:"initializedParams"`
|
||||
|
||||
// LogLevel is the logging level for the session.
|
||||
LogLevel LoggingLevel `json:"logLevel"`
|
||||
|
||||
// TODO: resource subscriptions
|
||||
}
|
||||
+894
@@ -0,0 +1,894 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file contains code shared between client and server, including
|
||||
// method handler and middleware definitions.
|
||||
//
|
||||
// Much of this is here so that we can factor out commonalities using
|
||||
// generics. If this becomes unwieldy, it can perhaps be simplified with
|
||||
// reflection.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
// nowrapinvalidparams is a compatibility parameter that restores the previous
|
||||
// behavior of [methodInfo.unmarshalParams]. When unset (the default), a
|
||||
// params-decoding failure is wrapped with [jsonrpc2.ErrInvalidParams] so the
|
||||
// wire response carries error code -32602 ("invalid params") rather than the
|
||||
// zero-value code 0. See:
|
||||
// https://github.com/modelcontextprotocol/go-sdk/issues/976#issuecomment-4829124838.
|
||||
//
|
||||
// See the documentation for the mcpgodebug package for instructions how to enable it.
|
||||
// The option will be removed in a future version of the SDK.
|
||||
var nowrapinvalidparams = mcpgodebug.Value("nowrapinvalidparams")
|
||||
|
||||
const (
|
||||
// latestProtocolVersion is the latest protocol version that this version of
|
||||
// the SDK supports.
|
||||
//
|
||||
// It is the version that the client sends in the initialization request, and
|
||||
// the default version used by the server.
|
||||
latestProtocolVersion = protocolVersion20260728
|
||||
protocolVersion20260728 = "2026-07-28"
|
||||
protocolVersion20251125 = "2025-11-25"
|
||||
protocolVersion20250618 = "2025-06-18"
|
||||
protocolVersion20250326 = "2025-03-26"
|
||||
protocolVersion20241105 = "2024-11-05"
|
||||
)
|
||||
|
||||
var supportedProtocolVersions = []string{
|
||||
protocolVersion20260728,
|
||||
protocolVersion20251125,
|
||||
protocolVersion20250618,
|
||||
protocolVersion20250326,
|
||||
protocolVersion20241105,
|
||||
}
|
||||
|
||||
// negotiatedVersion returns the effective protocol version to use, given a
|
||||
// client version.
|
||||
func negotiatedVersion(clientVersion string) string {
|
||||
// In general, prefer to use the clientVersion, but if we don't support the
|
||||
// client's version, use the latest version.
|
||||
//
|
||||
// Cap the supported versions at the legacy protocolVersion20251125, as this
|
||||
// method is used by the initialize method which is deprecated in
|
||||
// version protocolVersion20260728.
|
||||
if slices.Contains(supportedProtocolVersions, clientVersion) && clientVersion < protocolVersion20260728 {
|
||||
return clientVersion
|
||||
}
|
||||
return protocolVersion20251125
|
||||
}
|
||||
|
||||
// negotiateMutuallySupportedVersion returns a protocol version that is supported
|
||||
// by both the client and the server.
|
||||
func negotiateMutuallySupportedVersion(supported []string) string {
|
||||
for _, ver := range supportedProtocolVersions {
|
||||
if slices.Contains(supported, ver) {
|
||||
return ver
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// A MethodHandler handles MCP messages.
|
||||
// For methods, exactly one of the return values must be nil.
|
||||
// For notifications, both must be nil.
|
||||
type MethodHandler func(ctx context.Context, method string, req Request) (result Result, err error)
|
||||
|
||||
// A Session is either a [ClientSession] or a [ServerSession].
|
||||
type Session interface {
|
||||
// ID returns the session ID, or the empty string if there is none.
|
||||
ID() string
|
||||
|
||||
sendingMethodInfos() map[string]methodInfo
|
||||
receivingMethodInfos() map[string]methodInfo
|
||||
sendingMethodHandler() MethodHandler
|
||||
receivingMethodHandler() MethodHandler
|
||||
getConn() *jsonrpc2.Connection
|
||||
}
|
||||
|
||||
// Middleware is a function from [MethodHandler] to [MethodHandler].
|
||||
type Middleware func(MethodHandler) MethodHandler
|
||||
|
||||
// addMiddleware wraps the handler in the middleware functions.
|
||||
func addMiddleware(handlerp *MethodHandler, middleware []Middleware) {
|
||||
for _, m := range slices.Backward(middleware) {
|
||||
*handlerp = m(*handlerp)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSendingMethodHandler(ctx context.Context, method string, req Request) (Result, error) {
|
||||
info, ok := req.GetSession().sendingMethodInfos()[method]
|
||||
if !ok {
|
||||
// This can be called from user code, with an arbitrary value for method.
|
||||
return nil, jsonrpc2.ErrNotHandled
|
||||
}
|
||||
params := req.GetParams()
|
||||
if initParams, ok := params.(*InitializeParams); ok {
|
||||
// Fix the marshaling of initialize params, to work around #607.
|
||||
//
|
||||
// The initialize params we produce should never be nil, nor have nil
|
||||
// capabilities, so any panic here is a bug.
|
||||
params = initParams.toV2()
|
||||
}
|
||||
// Notifications don't have results.
|
||||
if strings.HasPrefix(method, "notifications/") {
|
||||
return nil, req.GetSession().getConn().Notify(ctx, method, params)
|
||||
}
|
||||
// Create the result to unmarshal into.
|
||||
// The concrete type of the result is the return type of the receiving function.
|
||||
res := info.newResult()
|
||||
if method == methodSubscriptionsListen {
|
||||
callSubscriptionsListen(ctx, req.GetSession().getConn(), method, params)
|
||||
} else {
|
||||
if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Helper method to avoid typed nil.
|
||||
func orZero[T any, P *U, U any](p P) T {
|
||||
if p == nil {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
return any(p).(T)
|
||||
}
|
||||
|
||||
func handleNotify(ctx context.Context, method string, req Request) error {
|
||||
mh := req.GetSession().sendingMethodHandler()
|
||||
_, err := mh(ctx, method, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func handleSend[R Result](ctx context.Context, method string, req Request) (R, error) {
|
||||
mh := req.GetSession().sendingMethodHandler()
|
||||
// mh might be user code, so ensure that it returns the right values for the jsonrpc2 protocol.
|
||||
res, err := mh(ctx, method, req)
|
||||
if err != nil {
|
||||
var z R
|
||||
return z, err
|
||||
}
|
||||
return res.(R), nil
|
||||
}
|
||||
|
||||
// defaultReceivingMethodHandler is the initial MethodHandler for servers and clients, before being wrapped by middleware.
|
||||
func defaultReceivingMethodHandler[S Session](ctx context.Context, method string, req Request) (Result, error) {
|
||||
info, ok := req.GetSession().receivingMethodInfos()[method]
|
||||
if !ok {
|
||||
// This can be called from user code, with an arbitrary value for method.
|
||||
return nil, jsonrpc2.ErrNotHandled
|
||||
}
|
||||
return info.handleMethod(ctx, method, req)
|
||||
}
|
||||
|
||||
func handleReceive[S Session](ctx context.Context, session S, jreq *jsonrpc.Request) (Result, error) {
|
||||
info, err := checkRequest(jreq, session.receivingMethodInfos())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params, err := info.unmarshalParams(jreq.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("handling '%s': %w", jreq.Method, err)
|
||||
}
|
||||
|
||||
mh := session.receivingMethodHandler()
|
||||
re, _ := jreq.Extra.(*RequestExtra)
|
||||
req := info.newRequest(session, params, re)
|
||||
// mh might be user code, so ensure that it returns the right values for the jsonrpc2 protocol.
|
||||
res, err := mh(ctx, jreq.Method, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// checkRequest checks the given request against the provided method info, to
|
||||
// ensure it is a valid MCP request.
|
||||
//
|
||||
// If valid, the relevant method info is returned. Otherwise, a non-nil error
|
||||
// is returned describing why the request is invalid.
|
||||
//
|
||||
// This is extracted from request handling so that it can be called in the
|
||||
// transport layer to preemptively reject bad requests.
|
||||
func checkRequest(req *jsonrpc.Request, infos map[string]methodInfo) (methodInfo, error) {
|
||||
info, ok := infos[req.Method]
|
||||
if !ok {
|
||||
return methodInfo{}, fmt.Errorf("%w: %q unsupported", jsonrpc2.ErrNotHandled, req.Method)
|
||||
}
|
||||
if info.flags¬ification != 0 && req.IsCall() {
|
||||
return methodInfo{}, fmt.Errorf("%w: unexpected id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
|
||||
}
|
||||
if info.flags¬ification == 0 && !req.IsCall() {
|
||||
return methodInfo{}, fmt.Errorf("%w: missing id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
|
||||
}
|
||||
// missingParamsOK is checked here to catch the common case where "params" is
|
||||
// missing entirely.
|
||||
//
|
||||
// However, it's checked again after unmarshalling to catch the rare but
|
||||
// possible case where "params" is JSON null (see https://go.dev/issue/33835).
|
||||
if info.flags&missingParamsOK == 0 && len(req.Params) == 0 {
|
||||
return methodInfo{}, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// methodInfo is information about sending and receiving a method.
|
||||
type methodInfo struct {
|
||||
// flags is a collection of flags controlling how the JSONRPC method is
|
||||
// handled. See individual flag values for documentation.
|
||||
flags methodFlags
|
||||
// Unmarshal params from the wire into a Params struct.
|
||||
// Used on the receive side.
|
||||
unmarshalParams func(json.RawMessage) (Params, error)
|
||||
newRequest func(Session, Params, *RequestExtra) Request
|
||||
// Run the code when a call to the method is received.
|
||||
// Used on the receive side.
|
||||
handleMethod MethodHandler
|
||||
// Create a pointer to a Result struct.
|
||||
// Used on the send side.
|
||||
newResult func() Result
|
||||
}
|
||||
|
||||
// The following definitions support converting from typed to untyped method handlers.
|
||||
// Type parameter meanings:
|
||||
// - S: sessions
|
||||
// - P: params
|
||||
// - R: results
|
||||
|
||||
// A typedMethodHandler is like a MethodHandler, but with type information.
|
||||
type (
|
||||
typedClientMethodHandler[P Params, R Result] func(context.Context, *ClientRequest[P]) (R, error)
|
||||
typedServerMethodHandler[P Params, R Result] func(context.Context, *ServerRequest[P]) (R, error)
|
||||
)
|
||||
|
||||
type paramsPtr[T any] interface {
|
||||
*T
|
||||
Params
|
||||
}
|
||||
|
||||
type methodFlags int
|
||||
|
||||
const (
|
||||
notification methodFlags = 1 << iota // method is a notification, not request
|
||||
missingParamsOK // params may be missing or null
|
||||
)
|
||||
|
||||
func newClientMethodInfo[P paramsPtr[T], R Result, T any](d typedClientMethodHandler[P, R], flags methodFlags) methodInfo {
|
||||
mi := newMethodInfo[P, R](flags)
|
||||
mi.newRequest = func(s Session, p Params, _ *RequestExtra) Request {
|
||||
r := &ClientRequest[P]{Session: s.(*ClientSession)}
|
||||
if p != nil {
|
||||
r.Params = p.(P)
|
||||
}
|
||||
return r
|
||||
}
|
||||
mi.handleMethod = MethodHandler(func(ctx context.Context, _ string, req Request) (Result, error) {
|
||||
return d(ctx, req.(*ClientRequest[P]))
|
||||
})
|
||||
return mi
|
||||
}
|
||||
|
||||
func newServerMethodInfo[P paramsPtr[T], R Result, T any](d typedServerMethodHandler[P, R], flags methodFlags) methodInfo {
|
||||
mi := newMethodInfo[P, R](flags)
|
||||
mi.newRequest = func(s Session, p Params, re *RequestExtra) Request {
|
||||
r := &ServerRequest[P]{Session: s.(*ServerSession), Extra: re}
|
||||
if p != nil {
|
||||
r.Params = p.(P)
|
||||
}
|
||||
return r
|
||||
}
|
||||
mi.handleMethod = MethodHandler(func(ctx context.Context, _ string, req Request) (Result, error) {
|
||||
return d(ctx, req.(*ServerRequest[P]))
|
||||
})
|
||||
return mi
|
||||
}
|
||||
|
||||
// newMethodInfo creates a methodInfo from a typedMethodHandler.
|
||||
//
|
||||
// If isRequest is set, the method is treated as a request rather than a
|
||||
// notification.
|
||||
func newMethodInfo[P paramsPtr[T], R Result, T any](flags methodFlags) methodInfo {
|
||||
return methodInfo{
|
||||
flags: flags,
|
||||
unmarshalParams: func(m json.RawMessage) (Params, error) {
|
||||
var p P
|
||||
if m != nil {
|
||||
if err := internaljson.Unmarshal(m, &p); err != nil {
|
||||
// Legacy behavior: pre-fix versions surfaced this as a
|
||||
// plain wrapped error, which caused the wire response to
|
||||
// carry code 0 instead of -32602. Restore via
|
||||
// MCPGODEBUG=nowrapinvalidparams=1.
|
||||
if nowrapinvalidparams == "1" {
|
||||
return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err)
|
||||
}
|
||||
// Wrap jsonrpc2.ErrInvalidParams so toWireError surfaces
|
||||
// code -32602 ("invalid params") while preserving the
|
||||
// descriptive message.
|
||||
return nil, fmt.Errorf("%w: unmarshaling %q into a %T: %w", jsonrpc2.ErrInvalidParams, m, p, err)
|
||||
}
|
||||
}
|
||||
// We must check missingParamsOK here, in addition to checkRequest, to
|
||||
// catch the edge cases where "params" is set to JSON null.
|
||||
// See also https://go.dev/issue/33835.
|
||||
//
|
||||
// We need to ensure that p is non-null to guard against crashes, as our
|
||||
// internal code or externally provided handlers may assume that params
|
||||
// is non-null.
|
||||
if flags&missingParamsOK == 0 && p == nil {
|
||||
return nil, fmt.Errorf("%w: missing required \"params\"", jsonrpc2.ErrInvalidRequest)
|
||||
}
|
||||
return orZero[Params](p), nil
|
||||
},
|
||||
// newResult is used on the send side, to construct the value to unmarshal the result into.
|
||||
// R is a pointer to a result struct. There is no way to "unpointer" it without reflection.
|
||||
// TODO(jba): explore generic approaches to this, perhaps by treating R in
|
||||
// the signature as the unpointered type.
|
||||
newResult: func() Result { return reflect.New(reflect.TypeFor[R]().Elem()).Interface().(R) },
|
||||
}
|
||||
}
|
||||
|
||||
// serverMethod is glue for creating a typedMethodHandler from a method on Server.
|
||||
func serverMethod[P Params, R Result](
|
||||
f func(*Server, context.Context, *ServerRequest[P]) (R, error),
|
||||
) typedServerMethodHandler[P, R] {
|
||||
return func(ctx context.Context, req *ServerRequest[P]) (R, error) {
|
||||
return f(req.Session.server, ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
// clientMethod is glue for creating a typedMethodHandler from a method on Client.
|
||||
func clientMethod[P Params, R Result](
|
||||
f func(*Client, context.Context, *ClientRequest[P]) (R, error),
|
||||
) typedClientMethodHandler[P, R] {
|
||||
return func(ctx context.Context, req *ClientRequest[P]) (R, error) {
|
||||
return f(req.Session.client, ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
// serverSessionMethod is glue for creating a typedServerMethodHandler from a method on ServerSession.
|
||||
func serverSessionMethod[P Params, R Result](f func(*ServerSession, context.Context, P) (R, error)) typedServerMethodHandler[P, R] {
|
||||
return func(ctx context.Context, req *ServerRequest[P]) (R, error) {
|
||||
return f(req.GetSession().(*ServerSession), ctx, req.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// clientSessionMethod is glue for creating a typedMethodHandler from a method on ServerSession.
|
||||
func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Context, P) (R, error)) typedClientMethodHandler[P, R] {
|
||||
return func(ctx context.Context, req *ClientRequest[P]) (R, error) {
|
||||
return f(req.GetSession().(*ClientSession), ctx, req.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// MCP-specific error codes.
|
||||
const (
|
||||
// CodeHeaderMismatch indicates that HTTP headers do not match the corresponding values
|
||||
// in the request body, or that required headers are missing or malformed.
|
||||
CodeHeaderMismatch = -32020
|
||||
// CodeMissingRequiredClientCapabilities is the JSON-RPC error code defined by
|
||||
// SEP-2575 for MissingRequiredClientCapabilitiesError.
|
||||
CodeMissingRequiredClientCapabilities = -32021
|
||||
// CodeUnsupportedProtocolVersion is the JSON-RPC error code defined by
|
||||
// SEP-2575 for UnsupportedProtocolVersionError.
|
||||
CodeUnsupportedProtocolVersion = -32022
|
||||
// CodeURLElicitationRequired indicates that the server requires URL elicitation
|
||||
// before processing the request. The client should execute the elicitation handler
|
||||
// with the elicitations provided in the error data.
|
||||
CodeURLElicitationRequired = -32042
|
||||
)
|
||||
|
||||
// CodeResourceNotFound indicates that a requested resource could not be found.
|
||||
//
|
||||
// By default, the value is -32602 (Invalid Params), as specified in the
|
||||
// MCP specification (SEP-2164). To restore the pre-1.7.0 release behavior where the
|
||||
// error code was -32002, set MCPGODEBUG=customresnotfounderrcode=1.
|
||||
//
|
||||
// Deprecated: Use [jsonrpc.CodeInvalidParams] directly. This variable will be
|
||||
// removed in a future version.
|
||||
var CodeResourceNotFound int64 = jsonrpc.CodeInvalidParams
|
||||
|
||||
// URLElicitationRequiredError returns an error indicating that URL elicitation is required
|
||||
// before the request can be processed. The elicitations parameter should contain the
|
||||
// elicitation requests that must be completed.
|
||||
func URLElicitationRequiredError(elicitations []*ElicitParams) error {
|
||||
// Validate that all elicitations are URL mode
|
||||
for _, elicit := range elicitations {
|
||||
mode := elicit.Mode
|
||||
if mode == "" {
|
||||
mode = "form" // default mode
|
||||
}
|
||||
if mode != "url" {
|
||||
panic(fmt.Sprintf("URLElicitationRequiredError requires all elicitations to be URL mode, got %q", mode))
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(map[string]any{
|
||||
"elicitations": elicitations,
|
||||
})
|
||||
if err != nil {
|
||||
// This should never happen with valid ElicitParams
|
||||
panic(fmt.Sprintf("failed to marshal elicitations: %v", err))
|
||||
}
|
||||
return &jsonrpc.Error{
|
||||
Code: CodeURLElicitationRequired,
|
||||
Message: "URL elicitation required",
|
||||
Data: json.RawMessage(data),
|
||||
}
|
||||
}
|
||||
|
||||
// Internal error codes
|
||||
const (
|
||||
// The error code if the method exists and was called properly, but the peer does not support it.
|
||||
//
|
||||
// TODO(rfindley): this code is wrong, and we should fix it to be
|
||||
// consistent with other SDKs.
|
||||
codeUnsupportedMethod = -31001
|
||||
)
|
||||
|
||||
// notifySessions calls Notify on all the sessions.
|
||||
// Should be called on a copy of the peer sessions.
|
||||
// The logger must be non-nil.
|
||||
func notifySessions[S Session, P Params](sessions []S, method string, params P, logger *slog.Logger) {
|
||||
if sessions == nil {
|
||||
return
|
||||
}
|
||||
// Notify with the background context, so the messages are sent on the
|
||||
// standalone stream.
|
||||
// TODO: make this timeout configurable, or call handleNotify asynchronously.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// TODO: there's a potential spec violation here, when the feature list
|
||||
// changes before the session (client or server) is initialized.
|
||||
for _, s := range sessions {
|
||||
req := newRequest(s, params)
|
||||
if err := handleNotify(ctx, method, req); err != nil {
|
||||
logger.Warn(fmt.Sprintf("calling %s: %v", method, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newRequest[S Session, P Params](s S, p P) Request {
|
||||
switch s := any(s).(type) {
|
||||
case *ClientSession:
|
||||
return &ClientRequest[P]{Session: s, Params: p}
|
||||
case *ServerSession:
|
||||
return &ServerRequest[P]{Session: s, Params: p}
|
||||
default:
|
||||
panic("bad session")
|
||||
}
|
||||
}
|
||||
|
||||
// Meta is additional metadata for requests, responses and other types.
|
||||
type Meta map[string]any
|
||||
|
||||
// GetMeta returns metadata from a value.
|
||||
func (m Meta) GetMeta() map[string]any { return m }
|
||||
|
||||
// SetMeta sets the metadata on a value.
|
||||
func (m *Meta) SetMeta(x map[string]any) { *m = x }
|
||||
|
||||
const progressTokenKey = "progressToken"
|
||||
|
||||
func getProgressToken(p Params) any {
|
||||
return p.GetMeta()[progressTokenKey]
|
||||
}
|
||||
|
||||
func setProgressToken(p Params, pt any) {
|
||||
switch pt.(type) {
|
||||
// Support int32 and int64 for atomic.IntNN.
|
||||
case int, int32, int64, string:
|
||||
default:
|
||||
panic(fmt.Sprintf("progress token %v is of type %[1]T, not int or string", pt))
|
||||
}
|
||||
m := p.GetMeta()
|
||||
if m == nil {
|
||||
m = map[string]any{}
|
||||
p.SetMeta(m)
|
||||
}
|
||||
m[progressTokenKey] = pt
|
||||
}
|
||||
|
||||
// extractRequestMeta performs a lightweight partial unmarshal of the `_meta`
|
||||
// field from a JSON-RPC request's raw params.
|
||||
func extractRequestMeta(rawParams json.RawMessage) Meta {
|
||||
if len(rawParams) == 0 {
|
||||
return nil
|
||||
}
|
||||
var meta struct {
|
||||
Meta Meta `json:"_meta"`
|
||||
}
|
||||
if err := internaljson.Unmarshal(rawParams, &meta); err != nil {
|
||||
return nil
|
||||
}
|
||||
return meta.Meta
|
||||
}
|
||||
|
||||
type validatedMeta struct {
|
||||
usesNewProtocol bool
|
||||
initializeParams *InitializeParams
|
||||
logLevel LoggingLevel
|
||||
}
|
||||
|
||||
// validateRequestMeta inspects a JSON-RPC request to detect whether it follows
|
||||
// the >= 2026-07-28 protocol via the `_meta` field.
|
||||
// If the request has no _meta, or no protocolVersion in _meta, it returns a non-nil
|
||||
// validatedMeta with usesNewProtocol set to false, and a nil error.
|
||||
// If the request has a protocolVersion in _meta it validates the presence of
|
||||
// clientCapabilities in _meta. If it is missing or invalid, it returns nil and
|
||||
// a non-nil error. clientInfo is optional; if present but invalid, an error is
|
||||
// returned. Otherwise, it returns usesNewProtocol set to true and the populated
|
||||
// initializeParams.
|
||||
func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) {
|
||||
meta := extractRequestMeta(req.Params)
|
||||
if meta == nil {
|
||||
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
|
||||
}
|
||||
protocolVersion, ok := meta[MetaKeyProtocolVersion].(string)
|
||||
if !ok || protocolVersion < protocolVersion20260728 {
|
||||
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
|
||||
}
|
||||
var clientInfo *Implementation
|
||||
if _, present := meta[MetaKeyClientInfo]; present {
|
||||
var ok bool
|
||||
clientInfo, ok = decodeMetaValue[*Implementation](meta, MetaKeyClientInfo)
|
||||
if !ok {
|
||||
return nil, &jsonrpc.Error{
|
||||
Code: jsonrpc.CodeInvalidParams,
|
||||
Message: fmt.Sprintf("invalid _meta field %q", MetaKeyClientInfo),
|
||||
}
|
||||
}
|
||||
}
|
||||
capabilities, ok := decodeMetaValue[*clientCapabilitiesV2](meta, MetaKeyClientCapabilities)
|
||||
if !ok {
|
||||
return nil, &jsonrpc.Error{
|
||||
Code: jsonrpc.CodeInvalidParams,
|
||||
Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientCapabilities),
|
||||
}
|
||||
}
|
||||
logLevel, _ := decodeMetaValue[LoggingLevel](meta, MetaKeyLogLevel)
|
||||
return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{
|
||||
ProtocolVersion: protocolVersion,
|
||||
Capabilities: capabilities.toV1(),
|
||||
ClientInfo: clientInfo,
|
||||
}, logLevel: logLevel}, nil
|
||||
}
|
||||
|
||||
// A Request is a method request with parameters and additional information, such as the session.
|
||||
// Request is implemented by [*ClientRequest] and [*ServerRequest].
|
||||
type Request interface {
|
||||
isRequest()
|
||||
GetSession() Session
|
||||
GetParams() Params
|
||||
// GetExtra returns the Extra field for ServerRequests, and nil for ClientRequests.
|
||||
GetExtra() *RequestExtra
|
||||
}
|
||||
|
||||
// A ClientRequest is a request to a client.
|
||||
type ClientRequest[P Params] struct {
|
||||
Session *ClientSession
|
||||
Params P
|
||||
}
|
||||
|
||||
// A ServerRequest is a request to a server.
|
||||
type ServerRequest[P Params] struct {
|
||||
Session *ServerSession
|
||||
Params P
|
||||
Extra *RequestExtra
|
||||
}
|
||||
|
||||
// RequestExtra is extra information included in requests, typically from
|
||||
// the transport layer.
|
||||
type RequestExtra struct {
|
||||
TokenInfo *auth.TokenInfo // bearer token info (e.g. from OAuth) if any
|
||||
Header http.Header // header from HTTP request, if any
|
||||
|
||||
// If set, CloseSSEStream explicitly closes the current SSE request stream.
|
||||
//
|
||||
// [SEP-1699] introduced server-side SSE stream disconnection: for
|
||||
// long-running requests, servers may opt to close the SSE stream and
|
||||
// ask the client to retry at a later time. CloseSSEStream implements this
|
||||
// feature; if RetryAfter is set, an event is sent with a `retry:` field
|
||||
// to configure the reconnection delay.
|
||||
//
|
||||
// [SEP-1699]: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699
|
||||
// This mechanism is deprecated in protocol version 2026-07-28 as the resumability
|
||||
// feature is removed.
|
||||
CloseSSEStream func(CloseSSEStreamArgs)
|
||||
}
|
||||
|
||||
// CloseSSEStreamArgs are arguments for [RequestExtra.CloseSSEStream].
|
||||
type CloseSSEStreamArgs struct {
|
||||
// RetryAfter configures the reconnection delay sent to the client via the
|
||||
// SSE retry field. If zero, no retry field is sent.
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (*ClientRequest[P]) isRequest() {}
|
||||
func (*ServerRequest[P]) isRequest() {}
|
||||
|
||||
func (r *ClientRequest[P]) GetSession() Session { return r.Session }
|
||||
func (r *ServerRequest[P]) GetSession() Session { return r.Session }
|
||||
|
||||
func (r *ClientRequest[P]) GetParams() Params { return r.Params }
|
||||
func (r *ServerRequest[P]) GetParams() Params { return r.Params }
|
||||
|
||||
func (r *ClientRequest[P]) GetExtra() *RequestExtra { return nil }
|
||||
func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra }
|
||||
|
||||
// ProtocolVersion returns the protocol version negotiated for this request.
|
||||
//
|
||||
// For requests following the >= 2026-07-28 protocol, the value is read from
|
||||
// the per-request `_meta` field. For older protocol requests, the value falls
|
||||
// back to the session-level [InitializeParams] established during the
|
||||
// initialize handshake.
|
||||
func (r *ServerRequest[P]) ProtocolVersion() string {
|
||||
if m := getRequestMeta(r); m != nil {
|
||||
if v, ok := m[MetaKeyProtocolVersion].(string); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if r.Session != nil {
|
||||
if p := r.Session.InitializeParams(); p != nil {
|
||||
return p.ProtocolVersion
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ClientInfo returns the [Implementation] identifying the calling client.
|
||||
//
|
||||
// For requests following the >= 2026-07-28 protocol, the value is read from
|
||||
// the per-request `_meta` field. For older protocol requests, the value falls
|
||||
// back to the session-level [InitializeParams].
|
||||
func (r *ServerRequest[P]) ClientInfo() *Implementation {
|
||||
if m := getRequestMeta(r); m != nil {
|
||||
if v, ok := decodeMetaValue[*Implementation](m, MetaKeyClientInfo); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if r.Session != nil {
|
||||
if p := r.Session.InitializeParams(); p != nil {
|
||||
return p.ClientInfo
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClientCapabilities returns the [ClientCapabilities] of the calling client.
|
||||
//
|
||||
// For requests following the >= 2026-07-28 protocol, the value is read from
|
||||
// the per-request `_meta` field. For older protocol requests, the value falls
|
||||
// back to the session-level [InitializeParams].
|
||||
func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities {
|
||||
if m := getRequestMeta(r); m != nil {
|
||||
if v, ok := decodeMetaValue[*clientCapabilitiesV2](m, MetaKeyClientCapabilities); ok {
|
||||
return v.toV1()
|
||||
}
|
||||
}
|
||||
if r.Session != nil {
|
||||
if p := r.Session.InitializeParams(); p != nil {
|
||||
return p.Capabilities
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRequestMeta returns the raw `_meta` map from the request's params, or
|
||||
// nil if the params are absent.
|
||||
func getRequestMeta[P Params](r *ServerRequest[P]) map[string]any {
|
||||
// In practice P is a pointer type implementing Params.
|
||||
if any(r.Params) == nil || r.Params.isNil() {
|
||||
return nil
|
||||
}
|
||||
return r.Params.GetMeta()
|
||||
}
|
||||
|
||||
// decodeMetaValue decodes a typed value out of a `_meta` map. Values may
|
||||
// arrive either as the typed Go value (when constructed in-process) or as
|
||||
// the generic JSON map produced by encoding/json after wire transit. In the
|
||||
// latter case, the value is re-encoded and decoded into the target type.
|
||||
func decodeMetaValue[T any](m map[string]any, key string) (T, bool) {
|
||||
var zero T
|
||||
raw, ok := m[key]
|
||||
if !ok || raw == nil {
|
||||
return zero, false
|
||||
}
|
||||
if v, ok := raw.(T); ok {
|
||||
return v, true
|
||||
}
|
||||
var v T
|
||||
if err := remarshal(raw, &v); err != nil {
|
||||
return zero, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func serverRequestFor[P Params](s *ServerSession, p P) *ServerRequest[P] {
|
||||
return &ServerRequest[P]{Session: s, Params: p}
|
||||
}
|
||||
|
||||
func clientRequestFor[P Params](s *ClientSession, p P) *ClientRequest[P] {
|
||||
return &ClientRequest[P]{Session: s, Params: p}
|
||||
}
|
||||
|
||||
// Params is a parameter (input) type for an MCP call or notification.
|
||||
type Params interface {
|
||||
// GetMeta returns metadata from a value.
|
||||
GetMeta() map[string]any
|
||||
// SetMeta sets the metadata on a value.
|
||||
SetMeta(map[string]any)
|
||||
|
||||
// isParams discourages implementation of Params outside of this package.
|
||||
isParams()
|
||||
|
||||
// isNil returns true if the underlying value is nil.
|
||||
isNil() bool
|
||||
}
|
||||
|
||||
// ParamsBase can be embedded in custom parameter structs to satisfy the
|
||||
// [Params] interface. It provides the required [Meta] field and the unexported
|
||||
// isParams marker method.
|
||||
//
|
||||
// type SearchParams struct {
|
||||
// mcp.ParamsBase
|
||||
// Query string `json:"query"`
|
||||
// }
|
||||
type ParamsBase struct {
|
||||
Meta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
func (*ParamsBase) isParams() {}
|
||||
|
||||
func (p *ParamsBase) isNil() bool { return p == nil }
|
||||
|
||||
// RequestParams is a parameter (input) type for an MCP request.
|
||||
type RequestParams interface {
|
||||
Params
|
||||
|
||||
// GetProgressToken returns the progress token from the params' Meta field, or nil
|
||||
// if there is none.
|
||||
GetProgressToken() any
|
||||
|
||||
// SetProgressToken sets the given progress token into the params' Meta field.
|
||||
// It panics if its argument is not an int or a string.
|
||||
SetProgressToken(any)
|
||||
}
|
||||
|
||||
// Result is a result of an MCP call.
|
||||
type Result interface {
|
||||
// isResult discourages implementation of Result outside of this package.
|
||||
isResult()
|
||||
|
||||
// GetMeta returns metadata from a value.
|
||||
GetMeta() map[string]any
|
||||
// SetMeta sets the metadata on a value.
|
||||
SetMeta(map[string]any)
|
||||
}
|
||||
|
||||
// ResultBase can be embedded in custom result structs to satisfy the
|
||||
// [Result] interface. It provides the required [Meta] field and the unexported
|
||||
// isResult marker method.
|
||||
//
|
||||
// type SearchResult struct {
|
||||
// mcp.ResultBase
|
||||
// Hits []string `json:"hits"`
|
||||
// }
|
||||
type ResultBase struct {
|
||||
Meta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
func (*ResultBase) isResult() {}
|
||||
|
||||
// emptyResult is returned by methods that have no result, like ping.
|
||||
// Those methods cannot return nil, because jsonrpc2 cannot handle nils.
|
||||
type emptyResult struct{}
|
||||
|
||||
func (*emptyResult) isResult() {}
|
||||
func (*emptyResult) GetMeta() map[string]any { panic("should never be called") }
|
||||
func (*emptyResult) SetMeta(map[string]any) { panic("should never be called") }
|
||||
|
||||
type listParams interface {
|
||||
// Returns a pointer to the param's Cursor field.
|
||||
cursorPtr() *string
|
||||
}
|
||||
|
||||
type listResult[T any] interface {
|
||||
// Returns a pointer to the param's NextCursor field.
|
||||
nextCursorPtr() *string
|
||||
}
|
||||
|
||||
// keepaliveSession represents a session that supports keepalive functionality.
|
||||
type keepaliveSession interface {
|
||||
Ping(ctx context.Context, params *PingParams) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// startKeepalive starts the keepalive mechanism for a session.
|
||||
// It assigns the cancel function to the provided cancelPtr and starts a goroutine
|
||||
// that sends ping messages at the specified interval.
|
||||
//
|
||||
// failureThreshold is the number of consecutive ping failures tolerated before
|
||||
// the session is closed; a value below 1 is treated as 1 (close on the first
|
||||
// failure). A successful ping resets the counter. This mirrors the spec's
|
||||
// "multiple failed pings MAY trigger a connection reset" language, letting a
|
||||
// transient miss pass without tearing down an otherwise live session.
|
||||
//
|
||||
// logger must be non-nil; ping failures (both the tolerated ones and the final
|
||||
// one that closes the session) are reported via logger so they are not silently
|
||||
// dropped.
|
||||
func startKeepalive(session keepaliveSession, interval time.Duration, failureThreshold int, cancelPtr *context.CancelFunc, logger *slog.Logger) {
|
||||
if failureThreshold < 1 {
|
||||
failureThreshold = 1
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Assign cancel function before starting goroutine to avoid race condition.
|
||||
// We cannot return it because the caller may need to cancel during the
|
||||
// window between goroutine scheduling and function return.
|
||||
*cancelPtr = cancel
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
consecutiveFailures := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2)
|
||||
err := session.Ping(pingCtx, nil)
|
||||
pingCancel()
|
||||
if err == nil {
|
||||
consecutiveFailures = 0
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, jsonrpc2.ErrMethodNotFound) {
|
||||
// Peer doesn't support ping, stop the keepalive process.
|
||||
return
|
||||
}
|
||||
consecutiveFailures++
|
||||
if consecutiveFailures < failureThreshold {
|
||||
// Tolerate transient failures below the threshold; log so
|
||||
// the misses are still observable to operators. See #218.
|
||||
logger.Warn("keepalive ping failed; tolerating below threshold",
|
||||
"error", err,
|
||||
"consecutiveFailures", consecutiveFailures,
|
||||
"failureThreshold", failureThreshold)
|
||||
continue
|
||||
}
|
||||
// Threshold reached; log before closing the session so the
|
||||
// failure is observable to operators. See #218.
|
||||
logger.Error("keepalive ping failed; closing session",
|
||||
"error", err,
|
||||
"consecutiveFailures", consecutiveFailures,
|
||||
"failureThreshold", failureThreshold)
|
||||
_ = session.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
// This file implements support for SSE (HTTP with server-sent events)
|
||||
// transport server and client.
|
||||
// https://modelcontextprotocol.io/specification/2024-11-05/basic/transports
|
||||
//
|
||||
// The transport is simple, at least relative to the new streamable transport
|
||||
// introduced in the 2025-03-26 version of the spec. In short:
|
||||
//
|
||||
// 1. Sessions are initiated via a hanging GET request, which streams
|
||||
// server->client messages as SSE 'message' events.
|
||||
// 2. The first event in the SSE stream must be an 'endpoint' event that
|
||||
// informs the client of the session endpoint.
|
||||
// 3. The client POSTs client->server messages to the session endpoint.
|
||||
//
|
||||
// Therefore, the each new GET request hands off its responsewriter to an
|
||||
// [SSEServerTransport] type that abstracts the transport as follows:
|
||||
// - Write writes a new event to the responseWriter, or fails if the GET has
|
||||
// exited.
|
||||
// - Read reads off a message queue that is pushed to via POST requests.
|
||||
// - Close causes the hanging GET to exit.
|
||||
|
||||
// SSEHandler is an http.Handler that serves SSE-based MCP sessions as defined by
|
||||
// the [2024-11-05 version] of the MCP spec.
|
||||
//
|
||||
// [2024-11-05 version]: https://modelcontextprotocol.io/specification/2024-11-05/basic/transports
|
||||
type SSEHandler struct {
|
||||
getServer func(request *http.Request) *Server
|
||||
opts SSEOptions
|
||||
onConnection func(*ServerSession) // for testing; must not block
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*SSEServerTransport
|
||||
}
|
||||
|
||||
// SSEOptions specifies options for an [SSEHandler].
|
||||
type SSEOptions struct {
|
||||
// DisableLocalhostProtection disables automatic DNS rebinding protection.
|
||||
// By default, requests arriving via a localhost address (127.0.0.1, [::1])
|
||||
// that have a non-localhost Host header are rejected with 403 Forbidden.
|
||||
// This protects against DNS rebinding attacks regardless of whether the
|
||||
// server is listening on localhost specifically or on 0.0.0.0.
|
||||
//
|
||||
// Only disable this if you understand the security implications.
|
||||
// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
|
||||
DisableLocalhostProtection bool
|
||||
}
|
||||
|
||||
// NewSSEHandler returns a new [SSEHandler] that creates and manages MCP
|
||||
// sessions created via incoming HTTP requests.
|
||||
//
|
||||
// Sessions are created when the client issues a GET request to the server,
|
||||
// which must accept text/event-stream responses (server-sent events).
|
||||
// For each such request, a new [SSEServerTransport] is created with a distinct
|
||||
// messages endpoint, and connected to the server returned by getServer.
|
||||
// The SSEHandler also handles requests to the message endpoints, by
|
||||
// delegating them to the relevant server transport.
|
||||
//
|
||||
// The getServer function may return a distinct [Server] for each new
|
||||
// request, or reuse an existing server. If it returns nil, the handler
|
||||
// will return a 400 Bad Request.
|
||||
func NewSSEHandler(getServer func(request *http.Request) *Server, opts *SSEOptions) *SSEHandler {
|
||||
s := &SSEHandler{
|
||||
getServer: getServer,
|
||||
sessions: make(map[string]*SSEServerTransport),
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
s.opts = *opts
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// A SSEServerTransport is a logical SSE session created through a hanging GET
|
||||
// request.
|
||||
//
|
||||
// Use [SSEServerTransport.Connect] to initiate the flow of messages.
|
||||
//
|
||||
// When connected, it returns the following [Connection] implementation:
|
||||
// - Writes are SSE 'message' events to the GET response.
|
||||
// - Reads are received from POSTs to the session endpoint, via
|
||||
// [SSEServerTransport.ServeHTTP].
|
||||
// - Close terminates the hanging GET.
|
||||
//
|
||||
// The transport is itself an [http.Handler]. It is the caller's responsibility
|
||||
// to ensure that the resulting transport serves HTTP requests on the given
|
||||
// session endpoint.
|
||||
//
|
||||
// Each SSEServerTransport may be connected (via [Server.Connect]) at most
|
||||
// once, since [SSEServerTransport.ServeHTTP] serves messages to the connected
|
||||
// session.
|
||||
//
|
||||
// Most callers should instead use an [SSEHandler], which transparently handles
|
||||
// the delegation to SSEServerTransports.
|
||||
type SSEServerTransport struct {
|
||||
// Endpoint is the endpoint for this session, where the client can POST
|
||||
// messages.
|
||||
Endpoint string
|
||||
|
||||
// Response is the hanging response body to the incoming GET request.
|
||||
Response http.ResponseWriter
|
||||
|
||||
// incoming is the queue of incoming messages.
|
||||
// It is never closed, and by convention, incoming is non-nil if and only if
|
||||
// the transport is connected.
|
||||
incoming chan jsonrpc.Message
|
||||
|
||||
// We must guard both pushes to the incoming queue and writes to the response
|
||||
// writer, because incoming POST requests are arbitrarily concurrent and we
|
||||
// need to ensure we don't write push to the queue, or write to the
|
||||
// ResponseWriter, after the session GET request exits.
|
||||
mu sync.Mutex // also guards writes to Response
|
||||
closed bool // set when the stream is closed
|
||||
done chan struct{} // closed when the connection is closed
|
||||
}
|
||||
|
||||
// ServeHTTP handles POST requests to the transport endpoint.
|
||||
func (t *SSEServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if t.incoming == nil {
|
||||
http.Error(w, "session not connected", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Read and parse the message.
|
||||
data, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Optionally, we could just push the data onto a channel, and let the
|
||||
// message fail to parse when it is read. This failure seems a bit more
|
||||
// useful
|
||||
msg, err := jsonrpc2.DecodeMessage(data)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to parse body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req, ok := msg.(*jsonrpc.Request); ok {
|
||||
if _, err := checkRequest(req, serverMethodInfos); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
select {
|
||||
case t.incoming <- msg:
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case <-t.done:
|
||||
http.Error(w, "session closed", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect sends the 'endpoint' event to the client.
|
||||
// See [SSEServerTransport] for more details on the [Connection] implementation.
|
||||
func (t *SSEServerTransport) Connect(context.Context) (Connection, error) {
|
||||
if t.incoming != nil {
|
||||
return nil, fmt.Errorf("already connected")
|
||||
}
|
||||
t.incoming = make(chan jsonrpc.Message, 100)
|
||||
t.done = make(chan struct{})
|
||||
_, err := writeEvent(t.Response, Event{
|
||||
Name: "endpoint",
|
||||
Data: []byte(t.Endpoint),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sseServerConn{t: t}, nil
|
||||
}
|
||||
|
||||
func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// DNS rebinding protection: auto-enabled for localhost servers.
|
||||
// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
|
||||
if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" {
|
||||
if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil {
|
||||
if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) {
|
||||
http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 'Content-Type' header.
|
||||
if disablecontenttypecheck != "1" && req.Method == http.MethodPost {
|
||||
mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "application/json" {
|
||||
http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sessionID := req.URL.Query().Get("sessionid")
|
||||
|
||||
// For POST requests, the message body is a message to send to a session.
|
||||
if req.Method == http.MethodPost {
|
||||
// Look up the session.
|
||||
if sessionID == "" {
|
||||
http.Error(w, "sessionid must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
session := h.sessions[sessionID]
|
||||
h.mu.Unlock()
|
||||
if session == nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
session.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// GET requests create a new session, and serve messages over SSE.
|
||||
|
||||
// TODO: it's not entirely documented whether we should check Accept here.
|
||||
// Let's again be lax and assume the client will accept SSE.
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
sessionID = rand.Text()
|
||||
endpoint, err := req.URL.Parse("?sessionid=" + sessionID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error: failed to create endpoint", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
transport := &SSEServerTransport{Endpoint: endpoint.RequestURI(), Response: w}
|
||||
|
||||
// The session is terminated when the request exits.
|
||||
h.mu.Lock()
|
||||
h.sessions[sessionID] = transport
|
||||
h.mu.Unlock()
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.sessions, sessionID)
|
||||
h.mu.Unlock()
|
||||
}()
|
||||
|
||||
server := h.getServer(req)
|
||||
if server == nil {
|
||||
// The getServer argument to NewSSEHandler returned nil.
|
||||
http.Error(w, "no server available", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ss, err := server.Connect(req.Context(), transport, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "connection failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.onConnection != nil {
|
||||
h.onConnection(ss)
|
||||
}
|
||||
defer ss.Close() // close the transport when the GET exits
|
||||
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
case <-transport.done:
|
||||
}
|
||||
}
|
||||
|
||||
// sseServerConn implements the [Connection] interface for a single [SSEServerTransport].
|
||||
// It hides the Connection interface from the SSEServerTransport API.
|
||||
type sseServerConn struct {
|
||||
t *SSEServerTransport
|
||||
}
|
||||
|
||||
// TODO(jba): get the session ID. (Not urgent because SSE transports have been removed from the spec.)
|
||||
func (s *sseServerConn) SessionID() string { return "" }
|
||||
|
||||
// Read implements jsonrpc2.Reader.
|
||||
func (s *sseServerConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case msg := <-s.t.incoming:
|
||||
return msg, nil
|
||||
case <-s.t.done:
|
||||
return nil, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements jsonrpc2.Writer.
|
||||
func (s *sseServerConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
data, err := jsonrpc2.EncodeMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.t.mu.Lock()
|
||||
defer s.t.mu.Unlock()
|
||||
|
||||
// Note that it is invalid to write to a ResponseWriter after ServeHTTP has
|
||||
// exited, and so we must lock around this write and check isDone, which is
|
||||
// set before the hanging GET exits.
|
||||
if s.t.closed {
|
||||
return io.EOF
|
||||
}
|
||||
|
||||
_, err = writeEvent(s.t.Response, Event{Name: "message", Data: data})
|
||||
return err
|
||||
}
|
||||
|
||||
// Close implements io.Closer, and closes the session.
|
||||
//
|
||||
// It must be safe to call Close more than once, as the close may
|
||||
// asynchronously be initiated by either the server closing its connection, or
|
||||
// by the hanging GET exiting.
|
||||
func (s *sseServerConn) Close() error {
|
||||
s.t.mu.Lock()
|
||||
defer s.t.mu.Unlock()
|
||||
if !s.t.closed {
|
||||
s.t.closed = true
|
||||
close(s.t.done)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// An SSEClientTransport is a [Transport] that can communicate with an MCP
|
||||
// endpoint serving the SSE transport defined by the 2024-11-05 version of the
|
||||
// spec.
|
||||
//
|
||||
// https://modelcontextprotocol.io/specification/2024-11-05/basic/transports
|
||||
type SSEClientTransport struct {
|
||||
// Endpoint is the SSE endpoint to connect to.
|
||||
Endpoint string
|
||||
|
||||
// HTTPClient is the client to use for making HTTP requests. If nil,
|
||||
// http.DefaultClient is used.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Connect connects through the client endpoint.
|
||||
func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) {
|
||||
parsedURL, err := url.Parse(c.Endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid endpoint: %v", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.Endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := c.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check HTTP status code before attempting to parse SSE events.
|
||||
// This ensures proper error reporting for authentication failures (401),
|
||||
// authorization failures (403), and other HTTP errors.
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("failed to connect: %s", http.StatusText(resp.StatusCode))
|
||||
}
|
||||
|
||||
msgEndpoint, err := func() (*url.URL, error) {
|
||||
var evt Event
|
||||
for evt, err = range scanEvents(resp.Body) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if evt.Name != "endpoint" {
|
||||
return nil, fmt.Errorf("first event is %q, want %q", evt.Name, "endpoint")
|
||||
}
|
||||
raw := string(evt.Data)
|
||||
return parsedURL.Parse(raw)
|
||||
}()
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("missing endpoint: %v", err)
|
||||
}
|
||||
|
||||
// From here on, the stream takes ownership of resp.Body.
|
||||
s := &sseClientConn{
|
||||
client: httpClient,
|
||||
msgEndpoint: msgEndpoint,
|
||||
incoming: make(chan []byte, 100),
|
||||
body: resp.Body,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer s.Close() // close the transport when the GET exits
|
||||
|
||||
for evt, err := range scanEvents(resp.Body) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.incoming <- evt.Data:
|
||||
case <-s.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// An sseClientConn is a logical jsonrpc2 connection that implements the client
|
||||
// half of the SSE protocol:
|
||||
// - Writes are POSTS to the session endpoint.
|
||||
// - Reads are SSE 'message' events, and pushes them onto a buffered channel.
|
||||
// - Close terminates the GET request.
|
||||
type sseClientConn struct {
|
||||
client *http.Client // HTTP client to use for requests
|
||||
msgEndpoint *url.URL // session endpoint for POSTs
|
||||
incoming chan []byte // queue of incoming messages
|
||||
|
||||
mu sync.Mutex
|
||||
body io.ReadCloser // body of the hanging GET
|
||||
closed bool // set when the stream is closed
|
||||
done chan struct{} // closed when the stream is closed
|
||||
}
|
||||
|
||||
// TODO(jba): get the session ID. (Not urgent because SSE transports have been removed from the spec.)
|
||||
func (c *sseClientConn) SessionID() string { return "" }
|
||||
|
||||
func (c *sseClientConn) isDone() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.closed
|
||||
}
|
||||
|
||||
func (c *sseClientConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
|
||||
case <-c.done:
|
||||
return nil, io.EOF
|
||||
|
||||
case data := <-c.incoming:
|
||||
// TODO(rfindley): do we really need to check this? We receive from c.done above.
|
||||
if c.isDone() {
|
||||
return nil, io.EOF
|
||||
}
|
||||
msg, err := jsonrpc2.DecodeMessage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *sseClientConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
||||
data, err := jsonrpc2.EncodeMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.isDone() {
|
||||
return io.EOF
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.msgEndpoint.String(), bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("failed to write: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *sseClientConn) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.closed {
|
||||
c.closed = true
|
||||
_ = c.body.Close()
|
||||
close(c.done)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+2779
File diff suppressed because it is too large
Load Diff
+231
@@ -0,0 +1,231 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// TODO: move client-side streamable HTTP logic from streamable.go to this file.
|
||||
|
||||
package mcp
|
||||
|
||||
/*
|
||||
Streamable HTTP Client Design
|
||||
|
||||
This document describes the client-side implementation of the MCP streamable
|
||||
HTTP transport, as defined by the MCP spec:
|
||||
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
|
||||
|
||||
# Overview
|
||||
|
||||
The client-side streamable transport allows an MCP client to communicate with a
|
||||
server over HTTP, sending messages via POST and receiving responses via either
|
||||
JSON or server-sent events (SSE). The implementation consists of two main
|
||||
components:
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [StreamableClientTransport] │
|
||||
│ Transport configuration; creates connections via Connect() │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [streamableClientConn] │
|
||||
│ Connection implementation; handles HTTP request/response │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├──────────────────────────────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────┐ ┌────────────────────────────────────┐
|
||||
│ POST request handlers │ │ Standalone SSE stream │
|
||||
│ (one per outgoing message/call) │ │ (server-initiated messages) │
|
||||
└─────────────────────────────────────────┘ └────────────────────────────────────┘
|
||||
|
||||
# Sessions
|
||||
|
||||
The client optionally maintains a session with the server, identified by a
|
||||
session ID (Mcp-Session-Id header). Sessionless servers (those that never
|
||||
send Mcp-Session-Id) are fully supported; the client simply omits the
|
||||
header from subsequent requests and skips the DELETE on close.
|
||||
|
||||
When a session is present:
|
||||
|
||||
- Session ID is received from the server after initialization
|
||||
- Client includes the session ID in all subsequent requests
|
||||
- Session ends when the client calls Close() (sends DELETE) or server returns 404
|
||||
|
||||
[streamableClientConn] stores the session state:
|
||||
- [streamableClientConn.sessionID]: Server-assigned session identifier
|
||||
- [streamableClientConn.initializedResult]: Protocol version and server capabilities
|
||||
|
||||
# Connection Lifecycle
|
||||
|
||||
1. Connect: [StreamableClientTransport.Connect] creates a [streamableClientConn]
|
||||
with a detached context for the connection's lifetime. The context is detached
|
||||
to prevent the standalone SSE stream from being cancelled when the original
|
||||
Connect context times out.
|
||||
|
||||
2. Initialize: The MCP client sends initialize/initialized messages. Upon
|
||||
receiving [InitializeResult], the connection:
|
||||
- Stores the negotiated protocol version for the Mcp-Protocol-Version header
|
||||
- Captures the session ID from the Mcp-Session-Id response header
|
||||
- Starts the standalone SSE stream via [streamableClientConn.connectStandaloneSSE]
|
||||
|
||||
3. Operation: Messages are sent via POST, responses received via JSON or SSE.
|
||||
|
||||
4. Close: [streamableClientConn.Close] sends a DELETE request to terminate
|
||||
the session (unless the session is already gone), then cancels the connection
|
||||
context to clean up the standalone SSE stream.
|
||||
|
||||
# Sending Messages (Write)
|
||||
|
||||
[streamableClientConn.Write] sends all outgoing messages via HTTP POST:
|
||||
|
||||
POST /endpoint
|
||||
Content-Type: application/json
|
||||
Accept: application/json, text/event-stream
|
||||
Mcp-Protocol-Version: <negotiated version>
|
||||
Mcp-Session-Id: <session ID, if established>
|
||||
|
||||
<JSON-RPC message>
|
||||
|
||||
The server may respond with:
|
||||
- 202 Accepted: Message received, no response body (notifications/responses)
|
||||
- 200 OK with application/json: Single JSON-RPC response
|
||||
- 200 OK with text/event-stream: SSE stream of responses
|
||||
|
||||
# Receiving Messages (Read)
|
||||
|
||||
[streamableClientConn.Read] returns messages from the [streamableClientConn.incoming]
|
||||
channel, which is populated by multiple concurrent goroutines:
|
||||
|
||||
1. POST response handlers ([streamableClientConn.handleJSON] and
|
||||
[streamableClientConn.handleSSE]): Process responses from POST requests
|
||||
|
||||
2. Standalone SSE stream: Receives server-initiated requests and notifications
|
||||
|
||||
The client handles both response formats:
|
||||
- JSON: [streamableClientConn.handleJSON] reads body, decodes message
|
||||
- SSE: [streamableClientConn.handleSSE] scans events, decodes each message
|
||||
|
||||
# Standalone SSE Stream
|
||||
|
||||
After initialization, [streamableClientConn.sessionUpdated] triggers
|
||||
[streamableClientConn.connectStandaloneSSE] to open a GET request for
|
||||
server-initiated messages:
|
||||
|
||||
GET /endpoint
|
||||
Accept: text/event-stream
|
||||
Mcp-Session-Id: <session ID>
|
||||
|
||||
Stream behavior:
|
||||
- Optional: Server may return 405 Method Not Allowed (spec-compliant) or
|
||||
other 4xx errors (tolerated in non-strict mode for compatibility)
|
||||
- Persistent: Runs for the connection lifetime in a background goroutine
|
||||
- Resumable: Uses Last-Event-ID header on reconnection if server provides event IDs
|
||||
- Reconnects: Automatic reconnection with exponential backoff on interruption
|
||||
|
||||
# Stream Resumption
|
||||
|
||||
When an SSE stream (standalone or POST response) is interrupted, the client
|
||||
attempts to reconnect using [streamableClientConn.connectSSE]:
|
||||
|
||||
Event ID tracking:
|
||||
- [streamableClientConn.processStream] tracks the last received event ID
|
||||
- On reconnection, the Last-Event-ID header is set to resume from that point
|
||||
- Server replays missed events if it has an [EventStore] configured
|
||||
|
||||
See [calculateReconnectDelay] for the reconnect delay details.
|
||||
|
||||
Server-initiated reconnection (SEP-1699)
|
||||
- SSE retry field: Sets the delay for the next reconnect attempt
|
||||
- If server doesn't provide event IDs, non-standalone streams don't reconnect
|
||||
|
||||
# Response Formats
|
||||
|
||||
The client must handle two response formats from POST requests:
|
||||
|
||||
1. application/json: Single JSON-RPC response
|
||||
- Body contains one JSON-RPC message
|
||||
- Handled by [streamableClientConn.handleJSON]
|
||||
- Simpler but doesn't support streaming or server-initiated messages
|
||||
|
||||
2. text/event-stream: SSE stream of messages
|
||||
- Body contains SSE events with JSON-RPC messages
|
||||
- Handled by [streamableClientConn.handleSSE]
|
||||
- Supports multiple messages and server-initiated communication
|
||||
- Stream completes when the response to the originating call is received
|
||||
|
||||
# HTTP Methods
|
||||
|
||||
- POST: Send JSON-RPC messages (requests, responses, notifications)
|
||||
- Used by [streamableClientConn.Write]
|
||||
- Response may be JSON or SSE
|
||||
|
||||
- GET: Open or resume SSE stream for server-initiated messages
|
||||
- Used by [streamableClientConn.connectSSE]
|
||||
- Always expects text/event-stream response (or 405)
|
||||
|
||||
- DELETE: Terminate the session
|
||||
- Used by [streamableClientConn.Close]
|
||||
- Skipped if session is already known to be gone ([ErrSessionMissing])
|
||||
or if no session was established (sessionless server)
|
||||
|
||||
# Error Handling
|
||||
|
||||
Errors are categorized and handled differently:
|
||||
|
||||
1. Transient (recoverable via reconnection):
|
||||
- Network interruption during SSE streaming
|
||||
- Connection reset or timeout
|
||||
- Triggers reconnection in [streamableClientConn.handleSSE]
|
||||
|
||||
2. Terminal (breaks the connection):
|
||||
- 404 Not Found: Session terminated by server ([ErrSessionMissing])
|
||||
- Message decode errors: Protocol violation
|
||||
- Context cancellation: Client closed connection
|
||||
- Mismatched session IDs: Protocol error (only relevant for servers that use sessions)
|
||||
- See issue #683: our terminal errors are too strict.
|
||||
|
||||
Terminal errors are stored via [streamableClientConn.fail] and returned by
|
||||
subsequent [streamableClientConn.Read] calls. The [streamableClientConn.failed]
|
||||
channel signals that the connection is broken.
|
||||
|
||||
Special case: [ErrSessionMissing] indicates the server has terminated the session,
|
||||
so [streamableClientConn.Close] skips the DELETE request.
|
||||
|
||||
# Protocol Version Header
|
||||
|
||||
After initialization, all requests include:
|
||||
|
||||
Mcp-Protocol-Version: <negotiated version>
|
||||
|
||||
This header (set by [streamableClientConn.setMCPHeaders]):
|
||||
- Allows the server to handle requests per the negotiated protocol
|
||||
- Is omitted before initialization completes
|
||||
- Uses the version from [streamableClientConn.initializedResult]
|
||||
|
||||
# Key Implementation Details
|
||||
|
||||
[StreamableClientTransport] configuration:
|
||||
- [StreamableClientTransport.Endpoint]: URL of the MCP server
|
||||
- [StreamableClientTransport.HTTPClient]: Custom HTTP client (optional)
|
||||
- [StreamableClientTransport.MaxRetries]: Reconnection attempts (default 5)
|
||||
|
||||
[streamableClientConn] handles the [Connection] interface:
|
||||
- [streamableClientConn.Read]: Returns messages from incoming channel
|
||||
- [streamableClientConn.Write]: Sends messages via POST, starts response handlers
|
||||
- [streamableClientConn.Close]: Sends DELETE, cancels context, closes done channel
|
||||
|
||||
State management:
|
||||
- [streamableClientConn.incoming]: Buffered channel for received messages
|
||||
- [streamableClientConn.sessionID]: Server-assigned session identifier (empty for sessionless servers)
|
||||
- [streamableClientConn.initializedResult]: Cached for protocol version header
|
||||
- [streamableClientConn.failed]: Channel closed on terminal error
|
||||
- [streamableClientConn.done]: Channel closed on graceful shutdown
|
||||
- [streamableClientConn.ctx]: Detached context for connection lifetime
|
||||
- [streamableClientConn.cancel]: Cancels ctx to terminate SSE streams
|
||||
|
||||
Context handling:
|
||||
- Connection context is detached from [StreamableClientTransport.Connect] context
|
||||
using [xcontext.Detach] to preserve context values (for auth middleware) while
|
||||
preventing premature cancellation of the standalone SSE stream
|
||||
- Individual POST requests use caller-provided contexts for cancellation
|
||||
*/
|
||||
+529
@@ -0,0 +1,529 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
protocolVersionHeader = "Mcp-Protocol-Version"
|
||||
sessionIDHeader = "Mcp-Session-Id"
|
||||
lastEventIDHeader = "Last-Event-ID"
|
||||
methodHeader = "Mcp-Method"
|
||||
nameHeader = "Mcp-Name"
|
||||
paramHeaderPrefix = "Mcp-Param-"
|
||||
minVersionForStandardHeaders = protocolVersion20260728
|
||||
base64Prefix = "=?base64?"
|
||||
base64Suffix = "?="
|
||||
)
|
||||
|
||||
func extractName(method string, params json.RawMessage) (string, bool) {
|
||||
switch method {
|
||||
case "tools/call":
|
||||
var p CallToolParams
|
||||
if err := internaljson.Unmarshal(params, &p); err == nil {
|
||||
return p.Name, true
|
||||
}
|
||||
case "prompts/get":
|
||||
var p GetPromptParams
|
||||
if err := internaljson.Unmarshal(params, &p); err == nil {
|
||||
return p.Name, true
|
||||
}
|
||||
case "resources/read":
|
||||
var p ReadResourceParams
|
||||
if err := internaljson.Unmarshal(params, &p); err == nil {
|
||||
return p.URI, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// headerSchemaProperty captures the fields needed for x-mcp-header processing.
|
||||
type headerSchemaProperty struct {
|
||||
Type string `json:"type"`
|
||||
XMCPHeader json.RawMessage `json:"x-mcp-header,omitempty"`
|
||||
Properties map[string]headerSchemaProperty `json:"properties,omitempty"`
|
||||
}
|
||||
|
||||
// unmarshalSchemaProperties normalizes any InputSchema type
|
||||
// (*jsonschema.Schema, map[string]any, or json.RawMessage) into a common
|
||||
// representation by marshaling to JSON and unmarshaling only the fields we need.
|
||||
func unmarshalSchemaProperties(schema any) map[string]headerSchemaProperty {
|
||||
var s headerSchemaProperty
|
||||
if err := remarshal(schema, &s); err != nil {
|
||||
return nil
|
||||
}
|
||||
return s.Properties
|
||||
}
|
||||
|
||||
// paramHeaderBinding maps a (possibly nested) input-schema property to the
|
||||
// HTTP header it carries.
|
||||
type paramHeaderBinding struct {
|
||||
Path []string
|
||||
Header string
|
||||
}
|
||||
|
||||
// extractParamHeaderAnnotations returns the bindings for every property in
|
||||
// the tool's InputSchema that has an x-mcp-header annotation
|
||||
func extractParamHeaderAnnotations(tool *Tool) []paramHeaderBinding {
|
||||
props := unmarshalSchemaProperties(tool.InputSchema)
|
||||
if len(props) == 0 {
|
||||
return nil
|
||||
}
|
||||
var result []paramHeaderBinding
|
||||
result = collectParamHeaderAnnotations(props, nil, result)
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// collectParamHeaderAnnotations walks the schema properties and records every
|
||||
// x-mcp-header annotation it finds, keyed by the property-name path.
|
||||
func collectParamHeaderAnnotations(props map[string]headerSchemaProperty, prefix []string, out []paramHeaderBinding) []paramHeaderBinding {
|
||||
for propName, prop := range props {
|
||||
path := make([]string, len(prefix)+1)
|
||||
copy(path, prefix)
|
||||
path[len(prefix)] = propName
|
||||
|
||||
var headerName string
|
||||
if err := json.Unmarshal(prop.XMCPHeader, &headerName); err == nil && headerName != "" {
|
||||
out = append(out, paramHeaderBinding{Path: path, Header: headerName})
|
||||
}
|
||||
if len(prop.Properties) > 0 {
|
||||
out = collectParamHeaderAnnotations(prop.Properties, path, out)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lookupArgument navigates the arguments object using the given property-name
|
||||
// path and returns the raw JSON value at that location. It reports whether
|
||||
// the value was found.
|
||||
func lookupArgument(args map[string]json.RawMessage, path []string) (json.RawMessage, bool) {
|
||||
if len(path) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
cur, ok := args[path[0]]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
for _, part := range path[1:] {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := internaljson.Unmarshal(cur, &obj); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
cur, ok = obj[part]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cur, true
|
||||
}
|
||||
|
||||
// maxSafeInteger and minSafeInteger bound the integer values that can be
|
||||
// faithfully represented as IEEE-754 double-precision floats.
|
||||
const (
|
||||
maxSafeInteger = 1<<53 - 1 // 2^53 - 1 = 9007199254740991
|
||||
minSafeInteger = -(1<<53 - 1) // -(2^53 - 1) = -9007199254740991
|
||||
)
|
||||
|
||||
// unmarshalPrimitive unmarshals a JSON value into the Go representation used
|
||||
// for x-mcp-header processing per SEP-2243:
|
||||
//
|
||||
// - JSON string -> string
|
||||
// - JSON boolean -> bool
|
||||
// - JSON integer (within the JavaScript safe-integer range) -> int64
|
||||
//
|
||||
// JSON numbers that are non-integers (have a fractional part, NaN, or ±Inf)
|
||||
// or integers outside the safe range are rejected because the `number` type
|
||||
// is not permitted for x-mcp-header parameters; only integer, string, boolean
|
||||
// are allowed.
|
||||
func unmarshalPrimitive(raw json.RawMessage) any {
|
||||
var val any
|
||||
if err := internaljson.Unmarshal(raw, &val); err != nil {
|
||||
return nil
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case string, bool:
|
||||
return v
|
||||
case float64:
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) {
|
||||
return nil
|
||||
}
|
||||
if v < minSafeInteger || v > maxSafeInteger {
|
||||
return nil
|
||||
}
|
||||
return int64(v)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// primitiveToString formats an x-mcp-header value (as produced by
|
||||
// [unmarshalPrimitive]) to its canonical header string representation per
|
||||
// SEP-2243. Returns false if value is not one of the permitted primitive
|
||||
// types (string, bool, int64).
|
||||
func primitiveToString(value any) (string, bool) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v, true
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", v), true
|
||||
case int64:
|
||||
return strconv.FormatInt(v, 10), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// setStandardHeaders populates standard MCP headers.
|
||||
// It requires the protocol version header to be set.
|
||||
func setStandardHeaders(ctx context.Context, header http.Header, msg jsonrpc.Message) {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
if header.Get(protocolVersionHeader) == "" || header.Get(protocolVersionHeader) < minVersionForStandardHeaders {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case *jsonrpc.Request:
|
||||
header.Set(methodHeader, msg.Method)
|
||||
if name, ok := extractName(msg.Method, msg.Params); ok {
|
||||
header.Set(nameHeader, name)
|
||||
}
|
||||
if msg.Method == "tools/call" {
|
||||
if tool, ok := ctx.Value(toolContextKey).(*Tool); ok && tool != nil {
|
||||
for k, v := range generateParamHeaders(tool, msg.Params) {
|
||||
header.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateParamHeaders reads x-mcp-header annotations from the tool's InputSchema
|
||||
// and returns the Mcp-Param-{Name} headers to be set on the HTTP request.
|
||||
func generateParamHeaders(tool *Tool, params json.RawMessage) map[string]string {
|
||||
paramHeaders := extractParamHeaderAnnotations(tool)
|
||||
if len(paramHeaders) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Arguments map[string]json.RawMessage `json:"arguments"`
|
||||
}
|
||||
if err := internaljson.Unmarshal(params, &raw); err != nil || raw.Arguments == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
res := make(map[string]string)
|
||||
for _, b := range paramHeaders {
|
||||
argRaw, ok := lookupArgument(raw.Arguments, b.Path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if string(argRaw) == "null" {
|
||||
continue
|
||||
}
|
||||
val := unmarshalPrimitive(argRaw)
|
||||
if val == nil {
|
||||
continue
|
||||
}
|
||||
encoded, ok := encodeHeaderValue(val)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
res[paramHeaderPrefix+b.Header] = encoded
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// filterValidTools returns only tools that have valid
|
||||
// x-mcp-header annotations. Invalid tools are logged and excluded.
|
||||
func filterValidTools(logger *slog.Logger, tools []*Tool) []*Tool {
|
||||
logger = ensureLogger(logger)
|
||||
result := make([]*Tool, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
if err := validateParamHeaderAnnotations(tool); err != nil {
|
||||
logger.Error("excluding tool from tools/list", "tool", tool.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
result = append(result, tool)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// validateParamHeaderAnnotations checks that a tool's x-mcp-header annotations
|
||||
// are valid. Annotations may appear on properties at any nesting
|
||||
// depth within the inputSchema and must be unique across all of them.
|
||||
func validateParamHeaderAnnotations(tool *Tool) error {
|
||||
props := unmarshalSchemaProperties(tool.InputSchema)
|
||||
if len(props) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
return validateParamHeadersIn(props, "", seen)
|
||||
}
|
||||
|
||||
func validateParamHeadersIn(props map[string]headerSchemaProperty, prefix string, seen map[string]bool) error {
|
||||
for propName, prop := range props {
|
||||
path := propName
|
||||
if prefix != "" {
|
||||
path = prefix + "." + propName
|
||||
}
|
||||
if prop.XMCPHeader != nil {
|
||||
if prop.Type != "string" && prop.Type != "integer" && prop.Type != "boolean" {
|
||||
return fmt.Errorf("property %q: x-mcp-header can only be applied to primitive types (integer, string, boolean), got %q", path, prop.Type)
|
||||
}
|
||||
var headerName string
|
||||
if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" {
|
||||
return fmt.Errorf("property %q: x-mcp-header must be a non-empty string", path)
|
||||
}
|
||||
if err := validateHeaderName(headerName); err != nil {
|
||||
return fmt.Errorf("property %q: %w", path, err)
|
||||
}
|
||||
lower := strings.ToLower(headerName)
|
||||
if seen[lower] {
|
||||
return fmt.Errorf("property %q: duplicate x-mcp-header value %q (case-insensitive)", path, headerName)
|
||||
}
|
||||
seen[lower] = true
|
||||
}
|
||||
if len(prop.Properties) > 0 {
|
||||
if err := validateParamHeadersIn(prop.Properties, path, seen); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHeaderName checks that a header name matches the HTTP field-name
|
||||
// token syntax (1*tchar).
|
||||
func validateHeaderName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("x-mcp-header value must be a non-empty string")
|
||||
}
|
||||
for _, c := range name {
|
||||
if !isTChar(c) {
|
||||
return fmt.Errorf("x-mcp-header value %q contains invalid character %q", name, c)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTChar reports whether c is a valid HTTP token character (tchar)
|
||||
//
|
||||
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
|
||||
// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
|
||||
func isTChar(c rune) bool {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return true
|
||||
case c >= 'A' && c <= 'Z':
|
||||
return true
|
||||
case c >= 'a' && c <= 'z':
|
||||
return true
|
||||
}
|
||||
switch c {
|
||||
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.',
|
||||
'^', '_', '`', '|', '~':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateMcpHeaders(header http.Header, msg jsonrpc.Message, toolLookup func(string) (*serverTool, bool)) error {
|
||||
protocolVersion := header.Get(protocolVersionHeader)
|
||||
if protocolVersion == "" || protocolVersion < minVersionForStandardHeaders {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case *jsonrpc.Request:
|
||||
methodInHeader := header.Get(methodHeader)
|
||||
if methodInHeader == "" {
|
||||
return errors.New("missing required Mcp-Method header")
|
||||
}
|
||||
if methodInHeader != msg.Method {
|
||||
return fmt.Errorf("header mismatch: Mcp-Method header value '%s' does not match body value '%s'", methodInHeader, msg.Method)
|
||||
}
|
||||
|
||||
var nameInBody string
|
||||
if msg.Method == "tools/call" || msg.Method == "resources/read" || msg.Method == "prompts/get" {
|
||||
nameInHeader := header.Get(nameHeader)
|
||||
if nameInHeader == "" {
|
||||
return fmt.Errorf("missing required Mcp-Name header for method %q", msg.Method)
|
||||
}
|
||||
var ok bool
|
||||
nameInBody, ok = extractName(msg.Method, msg.Params)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to extract name from parameters for method %q", msg.Method)
|
||||
}
|
||||
if nameInHeader != nameInBody {
|
||||
return fmt.Errorf("header mismatch: Mcp-Name header value '%s' does not match body value '%s'", nameInHeader, nameInBody)
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Method == "tools/call" && toolLookup != nil {
|
||||
if st, ok := toolLookup(nameInBody); ok && st != nil {
|
||||
if err := validateParamHeaders(header, msg, st.tool); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) error {
|
||||
paramHeaders := extractParamHeaderAnnotations(tool)
|
||||
if len(paramHeaders) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Arguments map[string]json.RawMessage `json:"arguments"`
|
||||
}
|
||||
if err := internaljson.Unmarshal(msg.Params, &raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, b := range paramHeaders {
|
||||
fullHeader := paramHeaderPrefix + b.Header
|
||||
headerVal := header.Get(fullHeader)
|
||||
argRaw, argExists := lookupArgument(raw.Arguments, b.Path)
|
||||
|
||||
if !argExists || string(argRaw) == "null" {
|
||||
if headerVal != "" {
|
||||
return fmt.Errorf("header mismatch: unexpected %s header for absent or null parameter %q", fullHeader, strings.Join(b.Path, "."))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if headerVal == "" {
|
||||
return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, strings.Join(b.Path, "."))
|
||||
}
|
||||
|
||||
decoded, ok := decodeHeaderValue(headerVal)
|
||||
if !ok {
|
||||
return fmt.Errorf("header mismatch: %s header contains invalid Base64 encoding", fullHeader)
|
||||
}
|
||||
|
||||
bodyVal := unmarshalPrimitive(argRaw)
|
||||
if bodyVal == nil {
|
||||
return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, strings.Join(b.Path, "."))
|
||||
}
|
||||
|
||||
if !primitiveEqual(decoded, bodyVal) {
|
||||
return fmt.Errorf("header mismatch: %s header value '%s' does not match body value", fullHeader, headerVal)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// primitiveEqual reports whether the (decoded) header string equals the
|
||||
// JSON-derived body value.
|
||||
func primitiveEqual(headerStr string, bodyVal any) bool {
|
||||
if bodyInt, ok := bodyVal.(int64); ok {
|
||||
headerNum, err := strconv.ParseFloat(headerStr, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if math.IsNaN(headerNum) || math.IsInf(headerNum, 0) || headerNum != math.Trunc(headerNum) {
|
||||
return false
|
||||
}
|
||||
if headerNum < minSafeInteger || headerNum > maxSafeInteger {
|
||||
return false
|
||||
}
|
||||
return int64(headerNum) == bodyInt
|
||||
}
|
||||
expected, ok := primitiveToString(bodyVal)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return headerStr == expected
|
||||
}
|
||||
|
||||
// encodeHeaderValue converts a parameter value to an HTTP header-safe string
|
||||
// per the SEP-2243 encoding rules:
|
||||
// - string: used as-is if safe ASCII, otherwise Base64 encoded
|
||||
// - int64: decimal string representation
|
||||
// - bool: lowercase "true" or "false"
|
||||
//
|
||||
// Values that contain non-ASCII characters, control characters, or
|
||||
// leading/trailing whitespace are Base64-encoded with the =?base64?...?= wrapper.
|
||||
//
|
||||
// The second return value is false if the value is not a supported primitive type.
|
||||
func encodeHeaderValue(value any) (string, bool) {
|
||||
s, ok := primitiveToString(value)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if requiresBase64Encoding(s) {
|
||||
return encodeBase64(s), true
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// decodeHeaderValue decodes a header value that may be Base64-encoded
|
||||
// with the =?base64?...?= wrapper.
|
||||
//
|
||||
// The second return value is false if the header value is not a valid Base64 encoded value.
|
||||
func decodeHeaderValue(headerValue string) (string, bool) {
|
||||
if len(headerValue) == 0 {
|
||||
return headerValue, true
|
||||
}
|
||||
|
||||
if encoded, ok := strings.CutPrefix(headerValue, base64Prefix); ok {
|
||||
if encoded, ok = strings.CutSuffix(encoded, base64Suffix); ok {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(decoded), true
|
||||
}
|
||||
}
|
||||
return headerValue, true
|
||||
}
|
||||
|
||||
func requiresBase64Encoding(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
if s[0] == ' ' || s[0] == '\t' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t' {
|
||||
return true
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < 0x20 || c > 0x7E {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Per SEP-2243, plain-ASCII values that match the base64 sentinel pattern
|
||||
// must also be base64-encoded to avoid ambiguity with already-encoded values.
|
||||
if strings.HasPrefix(s, base64Prefix) && strings.HasSuffix(s, base64Suffix) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func encodeBase64(s string) string {
|
||||
return base64Prefix + base64.StdEncoding.EncodeToString([]byte(s)) + base64Suffix
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// TODO: move server-side streamable HTTP logic from streamable.go to this file.
|
||||
|
||||
package mcp
|
||||
|
||||
/*
|
||||
Streamable HTTP Server Design
|
||||
|
||||
This document describes the server-side implementation of the MCP streamable
|
||||
HTTP transport, as defined by the MCP spec:
|
||||
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http
|
||||
|
||||
# Overview
|
||||
|
||||
The streamable HTTP transport enables MCP communication over HTTP, with
|
||||
server-sent events (SSE) for server-to-client messages. The implementation
|
||||
consists of several layered components:
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [StreamableHTTPHandler] │
|
||||
│ http.Handler that manages sessions and routes HTTP requests │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [StreamableServerTransport] │
|
||||
│ transport implementation, one per session; exposes ServeHTTP │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [streamableServerConn] │
|
||||
│ Connection implementation, handles message routing │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [stream] │
|
||||
│ Logical message channel within a session, may be resumed │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# Sessions
|
||||
|
||||
As with other transports, a session represents a logical MCP connection between
|
||||
a client and server. In the streamable transport, sessions are identified by a
|
||||
unique session ID (Mcp-Session-Id header) and persist across multiple HTTP
|
||||
requests.
|
||||
|
||||
[StreamableHTTPHandler] maintains a map of active sessions ([sessionInfo]),
|
||||
each containing:
|
||||
- The [ServerSession] (MCP-level session state)
|
||||
- The [StreamableServerTransport] (for message I/O)
|
||||
- Optional timeout management for idle session cleanup
|
||||
|
||||
Sessions are created on the first POST request (typically containing the
|
||||
initialize request) and destroyed either by:
|
||||
- Client sending a DELETE request
|
||||
- Session timeout due to inactivity
|
||||
- Server explicitly closing the session
|
||||
|
||||
# Streams
|
||||
|
||||
Within a session, there can be multiple concurrent "streams" - logical channels
|
||||
for message delivery. This is distinct from HTTP streams; a single [stream] may
|
||||
span multiple HTTP request/response cycles (via resumption).
|
||||
|
||||
There are two types of streams:
|
||||
|
||||
1. Optional standalone SSE stream (id = ""):
|
||||
- Created when client sends a GET request to the endpoint
|
||||
- Used for server-initiated messages (requests/notifications to client)
|
||||
- Persists for the lifetime of the session
|
||||
- Only one standalone stream per session
|
||||
|
||||
2. Request streams (id = random string):
|
||||
- Created for each POST request containing JSON-RPC calls
|
||||
- Used to route responses back to the originating HTTP request
|
||||
- Completed when all responses have been sent
|
||||
- Can be resumed via GET with Last-Event-ID if interrupted
|
||||
|
||||
# Message Routing
|
||||
|
||||
When the server writes a message, it must be routed to the correct [stream]:
|
||||
|
||||
- Responses: Routed to the stream that originated the request
|
||||
- Requests/Notifications made during request handling: Routed to the same
|
||||
stream as the triggering request (via context)
|
||||
- Requests/Notifications made outside request handling: Routed to the
|
||||
standalone SSE stream
|
||||
|
||||
This routing is implemented using:
|
||||
- [streamableServerConn.requestStreams] maps request IDs to stream IDs
|
||||
- [idContextKey] is used to store the originating request ID in Context
|
||||
- [streamableServerConn.streams] maps stream IDs to [stream] objects
|
||||
|
||||
# Stream Resumption
|
||||
|
||||
If an HTTP connection is interrupted (network issues, etc.), clients can
|
||||
resume a stream by sending a GET request with the Last-Event-ID header.
|
||||
This requires an [EventStore] to be configured on the server.
|
||||
|
||||
- [EventStore.Open] is called when a new stream is created
|
||||
- [EventStore.Append] is called for each message written to the stream
|
||||
- [EventStore.After] is called to replay messages after a given index
|
||||
- [EventStore.SessionClosed] is called when the session ends
|
||||
|
||||
Event IDs are formatted as "<streamID>_<index>" to identify both the
|
||||
stream and position within that stream (see [formatEventID] and [parseEventID]).
|
||||
|
||||
# Stateless Mode
|
||||
|
||||
For simpler deployments, the handler supports "stateless" mode
|
||||
([StreamableHTTPOptions.Stateless]) where:
|
||||
- No session ID validation is performed
|
||||
- Each request creates a temporary session that's closed after the request
|
||||
- Server-to-client requests are not supported (no way to receive response)
|
||||
|
||||
This mode is useful for simple tool servers that don't need bidirectional
|
||||
communication.
|
||||
|
||||
# Response Formats
|
||||
|
||||
The server can respond to POST requests in two formats:
|
||||
|
||||
1. text/event-stream (default): Messages sent as SSE events, supports
|
||||
streaming multiple messages and server-initiated communication during
|
||||
request handling.
|
||||
|
||||
2. application/json ([StreamableHTTPOptions.JSONResponse]): Single JSON
|
||||
response, simpler but doesn't support streaming. Server-initiated messages
|
||||
during request handling go to the standalone SSE stream instead.
|
||||
|
||||
# HTTP Methods
|
||||
|
||||
- POST: Send JSON-RPC messages (requests, responses, notifications)
|
||||
- GET: Open standalone SSE stream or resume an interrupted stream
|
||||
- DELETE: Terminate the session
|
||||
|
||||
# Key Implementation Details
|
||||
|
||||
The [stream] struct manages delivery of messages to HTTP responses.
|
||||
|
||||
Fields:
|
||||
- [stream.w] is the ResponseWriter for the current HTTP response (non-nil indicates claimed)
|
||||
- [stream.done] is closed to release the hanging HTTP request
|
||||
- [stream.requests] tracks pending request IDs (stream completes when empty)
|
||||
|
||||
Methods:
|
||||
- [stream.deliverLocked] delivers a message to the stream
|
||||
- [stream.close] sends a close event and releases the stream
|
||||
- [stream.release] releases the stream from the HTTP request, allowing resumption
|
||||
|
||||
[streamableServerConn] handles the [Connection] interface:
|
||||
- [streamableServerConn.Read] receives messages from the incoming channel (fed by POST handlers)
|
||||
- [streamableServerConn.Write] routes messages to appropriate streams
|
||||
- [streamableServerConn.Close] terminates the session and notifies the [EventStore]
|
||||
*/
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
)
|
||||
|
||||
// A ToolHandler handles a call to tools/call.
|
||||
//
|
||||
// This is a low-level API, for use with [Server.AddTool]. It does not do any
|
||||
// pre- or post-processing of the request or result: the params contain raw
|
||||
// arguments, no input validation is performed, and the result is returned to
|
||||
// the user as-is, without any validation of the output.
|
||||
//
|
||||
// Most users will write a [ToolHandlerFor] and install it with the generic
|
||||
// [AddTool] function.
|
||||
//
|
||||
// If ToolHandler returns an error, it is treated as a protocol error. By
|
||||
// contrast, [ToolHandlerFor] automatically populates [CallToolResult.IsError]
|
||||
// and [CallToolResult.Content] accordingly.
|
||||
type ToolHandler func(context.Context, *CallToolRequest) (*CallToolResult, error)
|
||||
|
||||
// A ToolHandlerFor handles a call to tools/call with typed arguments and results.
|
||||
//
|
||||
// Use [AddTool] to add a ToolHandlerFor to a server.
|
||||
//
|
||||
// Unlike [ToolHandler], [ToolHandlerFor] provides significant functionality
|
||||
// out of the box, and enforces that the tool conforms to the MCP spec:
|
||||
// - The In type provides a default input schema for the tool, though it may
|
||||
// be overridden in [AddTool].
|
||||
// - The input value is automatically unmarshaled from req.Params.Arguments.
|
||||
// - The input value is automatically validated against its input schema.
|
||||
// Invalid input is rejected before getting to the handler.
|
||||
// - If the Out type is not the empty interface [any], it provides the
|
||||
// default output schema for the tool (which again may be overridden in
|
||||
// [AddTool]).
|
||||
// - The Out value is used to populate result.StructuredOutput.
|
||||
// - If [CallToolResult.Content] is unset, it is populated with the JSON
|
||||
// content of the output.
|
||||
// - An error result is treated as a tool error, rather than a protocol
|
||||
// error, and is therefore packed into CallToolResult.Content, with
|
||||
// [IsError] set.
|
||||
//
|
||||
// For these reasons, most users can ignore the [CallToolRequest] argument and
|
||||
// [CallToolResult] return values entirely. In fact, it is permissible to
|
||||
// return a nil CallToolResult, if you only care about returning a output value
|
||||
// or error. The effective result will be populated as described above.
|
||||
type ToolHandlerFor[In, Out any] func(_ context.Context, request *CallToolRequest, input In) (result *CallToolResult, output Out, _ error)
|
||||
|
||||
// A serverTool is a tool definition that is bound to a tool handler.
|
||||
type serverTool struct {
|
||||
tool *Tool
|
||||
handler ToolHandler
|
||||
}
|
||||
|
||||
// applySchema validates whether data is valid JSON according to the provided
|
||||
// schema, after applying schema defaults.
|
||||
//
|
||||
// If forOutput is false, the data is treated as tool input: the schema's root
|
||||
// type must be "object" and the value is unmarshaled into a map.
|
||||
//
|
||||
// If forOutput is true, the data is treated as tool output: the schema's root
|
||||
// may be of any type (object, array, primitive, composition).
|
||||
//
|
||||
// Returns the JSON value, augmented with defaults where applicable.
|
||||
func applySchema(data json.RawMessage, resolved *jsonschema.Resolved, forOutput bool) (json.RawMessage, error) {
|
||||
// TODO: use reflection to create the struct type to unmarshal into.
|
||||
// Separate validation from assignment.
|
||||
|
||||
// Use default JSON marshalling for validation.
|
||||
//
|
||||
// This avoids inconsistent representation due to custom marshallers, such as
|
||||
// time.Time (issue #449).
|
||||
//
|
||||
// For input, unmarshalling into a map ensures that the resulting JSON is
|
||||
// at least {}, even if data is empty. For example, arguments is technically
|
||||
// an optional property of callToolParams, and we still want to apply the
|
||||
// defaults in this case.
|
||||
//
|
||||
// TODO(rfindley): in which cases can resolved be nil?
|
||||
if resolved == nil {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var unmarshaled any
|
||||
if !forOutput {
|
||||
v := make(map[string]any)
|
||||
if len(data) > 0 {
|
||||
if err := internaljson.Unmarshal(data, &v); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling arguments: %w", err)
|
||||
}
|
||||
}
|
||||
unmarshaled = v
|
||||
} else {
|
||||
if len(data) > 0 {
|
||||
if err := internaljson.Unmarshal(data, &unmarshaled); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling output: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply defaults only when the value is a map: jsonschema.Resolved.ApplyDefaults
|
||||
// only operates on object properties. For object-rooted output schemas,
|
||||
// coerce a nil result (from "null" or empty data) into {} so handlers that
|
||||
// return a typed-nil map still validate.
|
||||
appliedDefaults := false
|
||||
if _, ok := unmarshaled.(map[string]any); ok {
|
||||
if err := resolved.ApplyDefaults(&unmarshaled); err != nil {
|
||||
return nil, fmt.Errorf("applying schema defaults:\n%w", err)
|
||||
}
|
||||
appliedDefaults = true
|
||||
} else if forOutput && unmarshaled == nil && resolved.Schema().Type == "object" {
|
||||
unmarshaled = make(map[string]any)
|
||||
if err := resolved.ApplyDefaults(&unmarshaled); err != nil {
|
||||
return nil, fmt.Errorf("applying schema defaults:\n%w", err)
|
||||
}
|
||||
appliedDefaults = true
|
||||
}
|
||||
|
||||
if err := resolved.Validate(&unmarshaled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Re-marshal only when defaults may have changed the value.
|
||||
if !appliedDefaults {
|
||||
return data, nil
|
||||
}
|
||||
out, err := json.Marshal(unmarshaled)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshalling with defaults: %v", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isObjectJSON reports whether data is a JSON object (i.e., starts with '{'
|
||||
// after any leading whitespace). Returns false for arrays, primitives, null,
|
||||
// or empty input.
|
||||
func isObjectJSON(data json.RawMessage) bool {
|
||||
for _, b := range data {
|
||||
switch b {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
continue
|
||||
case '{':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateToolName checks whether name is a valid tool name, reporting a
|
||||
// non-nil error if not.
|
||||
func validateToolName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("tool name cannot be empty")
|
||||
}
|
||||
if len(name) > 128 {
|
||||
return fmt.Errorf("tool name exceeds maximum length of 128 characters (current: %d)", len(name))
|
||||
}
|
||||
// For consistency with other SDKs, report characters in the order the appear
|
||||
// in the name.
|
||||
var invalidChars []string
|
||||
seen := make(map[rune]bool)
|
||||
for _, r := range name {
|
||||
if !validToolNameRune(r) {
|
||||
if !seen[r] {
|
||||
invalidChars = append(invalidChars, fmt.Sprintf("%q", string(r)))
|
||||
seen[r] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(invalidChars) > 0 {
|
||||
return fmt.Errorf("tool name contains invalid characters: %s", strings.Join(invalidChars, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validToolNameRune reports whether r is valid within tool names.
|
||||
func validToolNameRune(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') ||
|
||||
(r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') ||
|
||||
r == '_' || r == '-' || r == '.'
|
||||
}
|
||||
+742
@@ -0,0 +1,742 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
// notifyCancellationTimeout bounds the cancellation notification we send to
|
||||
// the peer when the caller's context is cancelled. The notification is
|
||||
// best-effort: a degraded connection (e.g. an OAuth flow that has been
|
||||
// abandoned) must not be able to block the caller's return path or
|
||||
// re-trigger expensive recovery on its behalf. See issue #882.
|
||||
const notifyCancellationTimeout = 5 * time.Second
|
||||
|
||||
// ErrConnectionClosed is returned when sending a message to a connection that
|
||||
// is closed or in the process of closing.
|
||||
var ErrConnectionClosed = errors.New("connection closed")
|
||||
|
||||
// ErrSessionMissing is returned when the session is known to not be present on
|
||||
// the server.
|
||||
var ErrSessionMissing = errors.New("session not found")
|
||||
|
||||
// A Transport is used to create a bidirectional connection between MCP client
|
||||
// and server.
|
||||
//
|
||||
// Transports should be used for at most one call to [Server.Connect] or
|
||||
// [Client.Connect].
|
||||
type Transport interface {
|
||||
// Connect returns the logical JSON-RPC connection..
|
||||
//
|
||||
// It is called exactly once by [Server.Connect] or [Client.Connect].
|
||||
Connect(ctx context.Context) (Connection, error)
|
||||
}
|
||||
|
||||
// ProtocolVersionSupporter is an optional capability that a [Transport] may
|
||||
// implement to declare which MCP protocol versions it can serve.
|
||||
//
|
||||
// [Server.Connect] consults this interface to filter the
|
||||
// list of versions advertised in server/discover responses. Transports that
|
||||
// do not implement this interface are assumed to support every protocol
|
||||
// version known to the SDK.
|
||||
type ProtocolVersionSupporter interface {
|
||||
// SupportsProtocolVersion reports whether the transport can serve
|
||||
// requests using the given protocol version.
|
||||
SupportsProtocolVersion(version string) bool
|
||||
}
|
||||
|
||||
// A Connection is a logical bidirectional JSON-RPC connection.
|
||||
type Connection interface {
|
||||
// Read reads the next message to process off the connection.
|
||||
//
|
||||
// Connections must allow Read to be called concurrently with Close. In
|
||||
// particular, calling Close should unblock a Read waiting for input.
|
||||
Read(context.Context) (jsonrpc.Message, error)
|
||||
|
||||
// Write writes a new message to the connection.
|
||||
//
|
||||
// Write may be called concurrently, as calls or responses may occur
|
||||
// concurrently in user code.
|
||||
Write(context.Context, jsonrpc.Message) error
|
||||
|
||||
// Close closes the connection. It is implicitly called whenever a Read or
|
||||
// Write fails.
|
||||
//
|
||||
// Close may be called multiple times, potentially concurrently.
|
||||
Close() error
|
||||
|
||||
// TODO(#148): remove SessionID from this interface.
|
||||
SessionID() string
|
||||
}
|
||||
|
||||
// A ClientConnection is a [Connection] that is specific to the MCP client.
|
||||
//
|
||||
// If client connections implement this interface, they may receive information
|
||||
// about changes to the client session.
|
||||
//
|
||||
// TODO: should this interface be exported?
|
||||
type clientConnection interface {
|
||||
Connection
|
||||
|
||||
// sessionUpdated is called whenever the client session state changes.
|
||||
sessionUpdated(clientSessionState)
|
||||
}
|
||||
|
||||
// A serverConnection is a Connection that is specific to the MCP server.
|
||||
//
|
||||
// If server connections implement this interface, they receive information
|
||||
// about changes to the server session.
|
||||
//
|
||||
// TODO: should this interface be exported?
|
||||
type serverConnection interface {
|
||||
Connection
|
||||
sessionUpdated(ServerSessionState)
|
||||
}
|
||||
|
||||
// A StdioTransport is a [Transport] that communicates over stdin/stdout using
|
||||
// newline-delimited JSON.
|
||||
type StdioTransport struct{}
|
||||
|
||||
// Connect implements the [Transport] interface.
|
||||
func (*StdioTransport) Connect(context.Context) (Connection, error) {
|
||||
return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil
|
||||
}
|
||||
|
||||
// nopCloserWriter is an io.WriteCloser with a trivial Close method.
|
||||
type nopCloserWriter struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (nopCloserWriter) Close() error { return nil }
|
||||
|
||||
// An IOTransport is a [Transport] that communicates over separate
|
||||
// io.ReadCloser and io.WriteCloser using newline-delimited JSON.
|
||||
type IOTransport struct {
|
||||
Reader io.ReadCloser
|
||||
Writer io.WriteCloser
|
||||
}
|
||||
|
||||
// Connect implements the [Transport] interface.
|
||||
func (t *IOTransport) Connect(context.Context) (Connection, error) {
|
||||
return newIOConn(rwc{t.Reader, t.Writer}), nil
|
||||
}
|
||||
|
||||
// An InMemoryTransport is a [Transport] that communicates over an in-memory
|
||||
// network connection, using newline-delimited JSON.
|
||||
//
|
||||
// InMemoryTransports should be constructed using [NewInMemoryTransports],
|
||||
// which returns two transports connected to each other.
|
||||
type InMemoryTransport struct {
|
||||
rwc io.ReadWriteCloser
|
||||
}
|
||||
|
||||
// Connect implements the [Transport] interface.
|
||||
func (t *InMemoryTransport) Connect(context.Context) (Connection, error) {
|
||||
return newIOConn(t.rwc), nil
|
||||
}
|
||||
|
||||
// NewInMemoryTransports returns two [InMemoryTransport] objects that connect
|
||||
// to each other.
|
||||
//
|
||||
// The resulting transports are symmetrical: use either to connect to a server,
|
||||
// and then the other to connect to a client. Servers must be connected before
|
||||
// clients, as the client initializes the MCP session during connection.
|
||||
func NewInMemoryTransports() (*InMemoryTransport, *InMemoryTransport) {
|
||||
c1, c2 := net.Pipe()
|
||||
return &InMemoryTransport{c1}, &InMemoryTransport{c2}
|
||||
}
|
||||
|
||||
type binder[T handler, State any] interface {
|
||||
// TODO(rfindley): the bind API has gotten too complicated. Simplify.
|
||||
bind(Connection, *jsonrpc2.Connection, State, func()) T
|
||||
disconnect(T)
|
||||
}
|
||||
|
||||
type handler interface {
|
||||
handle(ctx context.Context, req *jsonrpc.Request) (any, error)
|
||||
}
|
||||
|
||||
// connect wires a transport to a binder. logger must be non-nil; it receives
|
||||
// jsonrpc2 internal errors that would otherwise be dropped (see #218).
|
||||
func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, State], s State, onClose func(), logger *slog.Logger) (H, error) {
|
||||
var zero H
|
||||
mcpConn, err := t.Connect(ctx)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
// If logging is configured, write message logs.
|
||||
reader, writer := jsonrpc2.Reader(mcpConn), jsonrpc2.Writer(mcpConn)
|
||||
var (
|
||||
h H
|
||||
preempter canceller
|
||||
)
|
||||
bind := func(conn *jsonrpc2.Connection) jsonrpc2.Handler {
|
||||
h = b.bind(mcpConn, conn, s, onClose)
|
||||
preempter.conn = conn
|
||||
return jsonrpc2.HandlerFunc(h.handle)
|
||||
}
|
||||
// Transports may opt in to propagating cancellation of ctx into request
|
||||
// handler contexts when their own lifecycle IS the cancellation signal
|
||||
// (e.g., a connection bound to a single HTTP request).
|
||||
var propagateCancellation bool
|
||||
if cp, ok := mcpConn.(cancellationPropagator); ok {
|
||||
propagateCancellation = cp.propagateCancellation()
|
||||
}
|
||||
_ = jsonrpc2.NewConnection(ctx, jsonrpc2.ConnectionConfig{
|
||||
Reader: reader,
|
||||
Writer: writer,
|
||||
Closer: mcpConn,
|
||||
Bind: bind,
|
||||
Preempter: &preempter,
|
||||
OnDone: func() {
|
||||
b.disconnect(h)
|
||||
},
|
||||
OnInternalError: func(err error) {
|
||||
logger.Error("jsonrpc2 internal error", "error", err)
|
||||
},
|
||||
PropagateCancellation: propagateCancellation,
|
||||
})
|
||||
assert(preempter.conn != nil, "unbound preempter")
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// cancellationPropagator is an optional interface implemented by a
|
||||
// [Connection] whose own lifecycle should propagate cancellation into request
|
||||
// handler contexts. The default jsonrpc2 behavior is to suppress propagation;
|
||||
// transports that bind a connection to a single short-lived carrier (such as
|
||||
// a one-shot HTTP request) should return true here so that handlers unwind
|
||||
// when the carrier observes the peer going away.
|
||||
type cancellationPropagator interface {
|
||||
propagateCancellation() bool
|
||||
}
|
||||
|
||||
// A canceller is a jsonrpc2.Preempter that cancels in-flight requests on MCP
|
||||
// cancelled notifications.
|
||||
type canceller struct {
|
||||
conn *jsonrpc2.Connection
|
||||
}
|
||||
|
||||
// Preempt implements [jsonrpc2.Preempter].
|
||||
func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result any, err error) {
|
||||
if req.Method == notificationCancelled {
|
||||
var params CancelledParams
|
||||
if err := internaljson.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := jsonrpc2.MakeID(params.RequestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go c.conn.Cancel(id)
|
||||
}
|
||||
return nil, jsonrpc2.ErrNotHandled
|
||||
}
|
||||
|
||||
// callSubscriptionsListen issues a "subscriptions/listen" call (SEP-2575)
|
||||
// without awaiting its JSON-RPC response. The call's logical lifetime is the
|
||||
// stream of notifications that follow on the same channel — the empty
|
||||
// response, if ever delivered, only marks subscription teardown — so the
|
||||
// caller has nothing useful to block on.
|
||||
//
|
||||
// Cancellation is driven by ctx: when it is cancelled, a background goroutine
|
||||
// sends a "notifications/cancelled" notification referencing the listen's
|
||||
// request ID and retires the call from the connection's outgoing-calls map.
|
||||
func callSubscriptionsListen(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params) {
|
||||
call := conn.Call(ctx, method, params)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = cancelCall(ctx, conn, call)
|
||||
}()
|
||||
}
|
||||
|
||||
// call executes and awaits a jsonrpc2 call on the given connection,
|
||||
// translating errors into the mcp domain.
|
||||
func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params, result Result) error {
|
||||
// The "%w"s in this function expose jsonrpc.Error as part of the API.
|
||||
call := conn.Call(ctx, method, params)
|
||||
err := call.Await(ctx, result)
|
||||
switch {
|
||||
case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing):
|
||||
return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err)
|
||||
case ctx.Err() != nil:
|
||||
err := cancelCall(ctx, conn, call)
|
||||
return errors.Join(ctx.Err(), err)
|
||||
case err != nil:
|
||||
return fmt.Errorf("calling %q: %w", method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelCall sends a "notifications/cancelled" notification for call and eagerly
|
||||
// retires it from conn.
|
||||
//
|
||||
// By default, the jsonrpc2 library waits for graceful shutdown when the
|
||||
// connection is closed, meaning it expects all outgoing and incoming requests
|
||||
// to complete. However, for MCP this expectation is unrealistic, and can lead
|
||||
// to hanging shutdown. For example, if a streamable client is killed, the
|
||||
// server will not be able to detect this event, except via keepalive pings (if
|
||||
// they are configured), and so outgoing calls may hang indefinitely.
|
||||
//
|
||||
// Therefore, we choose to eagerly retire calls, removing them from the
|
||||
// outgoingCalls map, when the caller context is cancelled: if the caller will
|
||||
// never receive the response, there's no need to track it.
|
||||
func cancelCall(ctx context.Context, conn *jsonrpc2.Connection, call *jsonrpc2.AsyncCall) error {
|
||||
notifyCtx, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout)
|
||||
defer cancelNotify()
|
||||
err := conn.Notify(notifyCtx, notificationCancelled, &CancelledParams{
|
||||
Reason: ctx.Err().Error(),
|
||||
RequestID: call.ID().Raw(),
|
||||
})
|
||||
conn.Retire(call, ctx.Err())
|
||||
return err
|
||||
}
|
||||
|
||||
// A LoggingTransport is a [Transport] that delegates to another transport,
|
||||
// writing RPC logs to an io.Writer.
|
||||
type LoggingTransport struct {
|
||||
Transport Transport
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
// Connect connects the underlying transport, returning a [Connection] that writes
|
||||
// logs to the configured destination.
|
||||
func (t *LoggingTransport) Connect(ctx context.Context) (Connection, error) {
|
||||
delegate, err := t.Transport.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &loggingConn{delegate: delegate, w: t.Writer}, nil
|
||||
}
|
||||
|
||||
type loggingConn struct {
|
||||
delegate Connection
|
||||
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func (c *loggingConn) SessionID() string { return c.delegate.SessionID() }
|
||||
|
||||
// Read is a stream middleware that logs incoming messages.
|
||||
func (s *loggingConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
||||
msg, err := s.delegate.Read(ctx)
|
||||
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
fmt.Fprintf(s.w, "read error: %v\n", err)
|
||||
s.mu.Unlock()
|
||||
} else {
|
||||
data, err := jsonrpc2.EncodeMessage(msg)
|
||||
s.mu.Lock()
|
||||
if err != nil {
|
||||
fmt.Fprintf(s.w, "LoggingTransport: failed to marshal: %v", err)
|
||||
}
|
||||
fmt.Fprintf(s.w, "read: %s\n", string(data))
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
return msg, err
|
||||
}
|
||||
|
||||
// Write is a stream middleware that logs outgoing messages.
|
||||
func (s *loggingConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
||||
err := s.delegate.Write(ctx, msg)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
fmt.Fprintf(s.w, "write error: %v\n", err)
|
||||
s.mu.Unlock()
|
||||
} else {
|
||||
data, err := jsonrpc2.EncodeMessage(msg)
|
||||
s.mu.Lock()
|
||||
if err != nil {
|
||||
fmt.Fprintf(s.w, "LoggingTransport: failed to marshal: %v", err)
|
||||
}
|
||||
fmt.Fprintf(s.w, "write: %s\n", string(data))
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *loggingConn) Close() error {
|
||||
return s.delegate.Close()
|
||||
}
|
||||
|
||||
// A rwc binds an io.ReadCloser and io.WriteCloser together to create an
|
||||
// io.ReadWriteCloser.
|
||||
type rwc struct {
|
||||
rc io.ReadCloser
|
||||
wc io.WriteCloser
|
||||
}
|
||||
|
||||
func (r rwc) Read(p []byte) (n int, err error) {
|
||||
return r.rc.Read(p)
|
||||
}
|
||||
|
||||
func (r rwc) Write(p []byte) (n int, err error) {
|
||||
return r.wc.Write(p)
|
||||
}
|
||||
|
||||
func (r rwc) Close() error {
|
||||
rcErr := r.rc.Close()
|
||||
|
||||
var wcErr error
|
||||
if r.wc != nil { // we only allow a nil writer in unit tests
|
||||
wcErr = r.wc.Close()
|
||||
}
|
||||
|
||||
return errors.Join(rcErr, wcErr)
|
||||
}
|
||||
|
||||
// An ioConn is a transport that delimits messages with newlines across
|
||||
// a bidirectional stream, and supports jsonrpc.2 message batching.
|
||||
//
|
||||
// See https://github.com/ndjson/ndjson-spec for discussion of newline
|
||||
// delimited JSON.
|
||||
//
|
||||
// See [msgBatch] for more discussion of message batching.
|
||||
type ioConn struct {
|
||||
// protocolVersion is the negotiated version of the protocol,
|
||||
// set during session initialization.
|
||||
// Since writes may be concurrent to reads, we need to guard this with a mutex.
|
||||
sessionMu sync.Mutex
|
||||
protocolVersion string
|
||||
|
||||
writeMu sync.Mutex // guards Write, which must be concurrency safe.
|
||||
rwc io.ReadWriteCloser // the underlying stream
|
||||
|
||||
// incoming receives messages from the read loop started in [newIOConn].
|
||||
incoming <-chan msgOrErr
|
||||
|
||||
// If outgoiBatch has a positive capacity, it will be used to batch requests
|
||||
// and notifications before sending.
|
||||
outgoingBatch []jsonrpc.Message
|
||||
|
||||
// Unread messages in the last batch. Since reads are serialized, there is no
|
||||
// need to guard here.
|
||||
queue []jsonrpc.Message
|
||||
|
||||
// batches correlate incoming requests to the batch in which they arrived.
|
||||
// Since writes may be concurrent to reads, we need to guard this with a mutex.
|
||||
batchMu sync.Mutex
|
||||
batches map[jsonrpc2.ID]*msgBatch // lazily allocated
|
||||
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
closeErr error
|
||||
}
|
||||
|
||||
type msgOrErr struct {
|
||||
msg json.RawMessage
|
||||
err error
|
||||
}
|
||||
|
||||
func newIOConn(rwc io.ReadWriteCloser) *ioConn {
|
||||
var (
|
||||
incoming = make(chan msgOrErr)
|
||||
closed = make(chan struct{})
|
||||
)
|
||||
// Start a goroutine for reads, so that we can select on the incoming channel
|
||||
// in [ioConn.Read] and unblock the read as soon as Close is called (see #224).
|
||||
//
|
||||
// This leaks a goroutine if rwc.Read does not unblock after it is closed,
|
||||
// but that is unavoidable since AFAIK there is no (easy and portable) way to
|
||||
// guarantee that reads of stdin are unblocked when closed.
|
||||
go func() {
|
||||
dec := json.NewDecoder(rwc)
|
||||
for {
|
||||
var raw json.RawMessage
|
||||
err := dec.Decode(&raw)
|
||||
// If decoding was successful, check for trailing data at the end of the stream.
|
||||
if err == nil {
|
||||
// Read the next byte to check if there is trailing data.
|
||||
var tr [1]byte
|
||||
if n, readErr := dec.Buffered().Read(tr[:]); n > 0 {
|
||||
// If read byte is not a newline, it is an error.
|
||||
// Support both Unix (\n) and Windows (\r\n) line endings.
|
||||
if tr[0] != '\n' && tr[0] != '\r' {
|
||||
err = fmt.Errorf("invalid trailing data at the end of stream")
|
||||
}
|
||||
} else if readErr != nil && readErr != io.EOF {
|
||||
err = readErr
|
||||
}
|
||||
}
|
||||
select {
|
||||
case incoming <- msgOrErr{msg: raw, err: err}:
|
||||
case <-closed:
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return &ioConn{
|
||||
rwc: rwc,
|
||||
incoming: incoming,
|
||||
closed: closed,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ioConn) SessionID() string { return "" }
|
||||
|
||||
func (c *ioConn) sessionUpdated(state ServerSessionState) {
|
||||
protocolVersion := ""
|
||||
if state.InitializeParams != nil {
|
||||
protocolVersion = state.InitializeParams.ProtocolVersion
|
||||
}
|
||||
if protocolVersion == "" {
|
||||
// 2025-03-26 is used, because it's the last spec version
|
||||
// where specifying the protocol version in the HTTP header
|
||||
// was not required.
|
||||
protocolVersion = protocolVersion20250326
|
||||
}
|
||||
protocolVersion = negotiatedVersion(protocolVersion)
|
||||
c.sessionMu.Lock()
|
||||
c.protocolVersion = protocolVersion
|
||||
c.sessionMu.Unlock()
|
||||
}
|
||||
|
||||
// addBatch records a msgBatch for an incoming batch payload.
|
||||
// It returns an error if batch is malformed, containing previously seen IDs.
|
||||
//
|
||||
// See [msgBatch] for more.
|
||||
func (t *ioConn) addBatch(batch *msgBatch) error {
|
||||
t.batchMu.Lock()
|
||||
defer t.batchMu.Unlock()
|
||||
for id := range batch.unresolved {
|
||||
if _, ok := t.batches[id]; ok {
|
||||
return fmt.Errorf("%w: batch contains previously seen request %v", jsonrpc2.ErrInvalidRequest, id.Raw())
|
||||
}
|
||||
}
|
||||
for id := range batch.unresolved {
|
||||
if t.batches == nil {
|
||||
t.batches = make(map[jsonrpc2.ID]*msgBatch)
|
||||
}
|
||||
t.batches[id] = batch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateBatch records a response in the message batch tracking the
|
||||
// corresponding incoming call, if any.
|
||||
//
|
||||
// The second result reports whether resp was part of a batch. If this is true,
|
||||
// the first result is nil if the batch is still incomplete, or the full set of
|
||||
// batch responses if resp completed the batch.
|
||||
func (t *ioConn) updateBatch(resp *jsonrpc.Response) ([]*jsonrpc.Response, bool) {
|
||||
t.batchMu.Lock()
|
||||
defer t.batchMu.Unlock()
|
||||
|
||||
if batch, ok := t.batches[resp.ID]; ok {
|
||||
idx, ok := batch.unresolved[resp.ID]
|
||||
if !ok {
|
||||
panic("internal error: inconsistent batches")
|
||||
}
|
||||
batch.responses[idx] = resp
|
||||
delete(batch.unresolved, resp.ID)
|
||||
delete(t.batches, resp.ID)
|
||||
if len(batch.unresolved) == 0 {
|
||||
return batch.responses, true
|
||||
}
|
||||
return nil, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// A msgBatch records information about an incoming batch of jsonrpc.2 calls.
|
||||
//
|
||||
// The jsonrpc.2 spec (https://www.jsonrpc.org/specification#batch) says:
|
||||
//
|
||||
// "The Server should respond with an Array containing the corresponding
|
||||
// Response objects, after all of the batch Request objects have been
|
||||
// processed. A Response object SHOULD exist for each Request object, except
|
||||
// that there SHOULD NOT be any Response objects for notifications. The Server
|
||||
// MAY process a batch rpc call as a set of concurrent tasks, processing them
|
||||
// in any order and with any width of parallelism."
|
||||
//
|
||||
// Therefore, a msgBatch keeps track of outstanding calls and their responses.
|
||||
// When there are no unresolved calls, the response payload is sent.
|
||||
type msgBatch struct {
|
||||
unresolved map[jsonrpc2.ID]int
|
||||
responses []*jsonrpc.Response
|
||||
}
|
||||
|
||||
func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
||||
// As a matter of principle, enforce that reads on a closed context return an
|
||||
// error.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
if len(t.queue) > 0 {
|
||||
next := t.queue[0]
|
||||
t.queue = t.queue[1:]
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var raw json.RawMessage
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
|
||||
case v := <-t.incoming:
|
||||
if v.err != nil {
|
||||
return nil, v.err
|
||||
}
|
||||
raw = v.msg
|
||||
|
||||
case <-t.closed:
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
msgs, batch, err := readBatch(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var protocolVersion string
|
||||
t.sessionMu.Lock()
|
||||
protocolVersion = t.protocolVersion
|
||||
t.sessionMu.Unlock()
|
||||
if batch && protocolVersion >= protocolVersion20250618 {
|
||||
return nil, fmt.Errorf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, protocolVersion)
|
||||
}
|
||||
|
||||
t.queue = msgs[1:]
|
||||
|
||||
if batch {
|
||||
var respBatch *msgBatch // track incoming requests in the batch
|
||||
for _, msg := range msgs {
|
||||
if req, ok := msg.(*jsonrpc.Request); ok {
|
||||
if respBatch == nil {
|
||||
respBatch = &msgBatch{
|
||||
unresolved: make(map[jsonrpc2.ID]int),
|
||||
}
|
||||
}
|
||||
if _, ok := respBatch.unresolved[req.ID]; ok {
|
||||
return nil, fmt.Errorf("duplicate message ID %q", req.ID)
|
||||
}
|
||||
respBatch.unresolved[req.ID] = len(respBatch.responses)
|
||||
respBatch.responses = append(respBatch.responses, nil)
|
||||
}
|
||||
}
|
||||
if respBatch != nil {
|
||||
// The batch contains one or more incoming requests to track.
|
||||
if err := t.addBatch(respBatch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return msgs[0], err
|
||||
}
|
||||
|
||||
// readBatch reads batch data, which may be either a single JSON-RPC message,
|
||||
// or an array of JSON-RPC messages.
|
||||
func readBatch(data []byte) (msgs []jsonrpc.Message, isBatch bool, _ error) {
|
||||
// Try to read an array of messages first.
|
||||
var rawBatch []json.RawMessage
|
||||
if err := internaljson.Unmarshal(data, &rawBatch); err == nil {
|
||||
if len(rawBatch) == 0 {
|
||||
return nil, true, fmt.Errorf("empty batch")
|
||||
}
|
||||
for _, raw := range rawBatch {
|
||||
msg, err := jsonrpc2.DecodeMessage(raw)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
return msgs, true, nil
|
||||
}
|
||||
// Try again with a single message.
|
||||
msg, err := jsonrpc2.DecodeMessage(data)
|
||||
return []jsonrpc.Message{msg}, false, err
|
||||
}
|
||||
|
||||
func (t *ioConn) Write(ctx context.Context, msg jsonrpc.Message) error {
|
||||
// As in [ioConn.Read], enforce that Writes on a closed context are an error.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
t.writeMu.Lock()
|
||||
defer t.writeMu.Unlock()
|
||||
|
||||
// Batching support: if msg is a Response, it may have completed a batch, so
|
||||
// check that first. Otherwise, it is a request or notification, and we may
|
||||
// want to collect it into a batch before sending, if we're configured to use
|
||||
// outgoing batches.
|
||||
if resp, ok := msg.(*jsonrpc.Response); ok {
|
||||
if batch, ok := t.updateBatch(resp); ok {
|
||||
if len(batch) > 0 {
|
||||
data, err := marshalMessages(batch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
_, err = t.rwc.Write(data)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
} else if len(t.outgoingBatch) < cap(t.outgoingBatch) {
|
||||
t.outgoingBatch = append(t.outgoingBatch, msg)
|
||||
if len(t.outgoingBatch) == cap(t.outgoingBatch) {
|
||||
data, err := marshalMessages(t.outgoingBatch)
|
||||
t.outgoingBatch = t.outgoingBatch[:0]
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
_, err = t.rwc.Write(data)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
data, err := jsonrpc2.EncodeMessage(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling message: %v", err)
|
||||
}
|
||||
data = append(data, '\n') // newline delimited
|
||||
_, err = t.rwc.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *ioConn) Close() error {
|
||||
t.closeOnce.Do(func() {
|
||||
t.closeErr = t.rwc.Close()
|
||||
close(t.closed)
|
||||
})
|
||||
return t.closeErr
|
||||
}
|
||||
|
||||
func marshalMessages[T jsonrpc.Message](msgs []T) ([]byte, error) {
|
||||
var rawMsgs []json.RawMessage
|
||||
for _, msg := range msgs {
|
||||
raw, err := jsonrpc2.EncodeMessage(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encoding batch message: %w", err)
|
||||
}
|
||||
rawMsgs = append(rawMsgs, raw)
|
||||
}
|
||||
return json.Marshal(rawMsgs)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
)
|
||||
|
||||
func assert(cond bool, msg string) {
|
||||
if !cond {
|
||||
panic(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// remarshal marshals from to JSON, and then unmarshals into to, which must be
|
||||
// a pointer type.
|
||||
func remarshal(from, to any) error {
|
||||
data, err := json.Marshal(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := internaljson.Unmarshal(data, to); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package oauthex
|
||||
|
||||
import "strings"
|
||||
|
||||
// MatchesResource reports whether any of claims matches resource under
|
||||
// RFC 3986 §6.2.3 scheme-based normalization, narrowed to the empty-path
|
||||
// case: a URI with an empty path is treated as equivalent to one with a
|
||||
// path of "/". All other URI components (scheme, host case, port, query,
|
||||
// fragment) must match exactly — this is intentionally narrower than the
|
||||
// full §6.2.3 rung, which would also fold scheme/host case and default
|
||||
// ports.
|
||||
//
|
||||
// RFC 9728 §3.3 normatively requires "simple string comparison" per
|
||||
// RFC 3986 §6.2.1 (byte-equal). Callers that need strict byte-equal
|
||||
// semantics should compare claims to resource directly:
|
||||
//
|
||||
// for _, c := range claims { if c == resource { return true } }
|
||||
//
|
||||
// Background: RFC 9728 §3.3 canonicalises the protected-resource
|
||||
// identifier with a trailing slash, but RFC 8707 resource indicators
|
||||
// sometimes omit it, and upstream IdPs vary in which form they emit in
|
||||
// `aud` claims (Google trims, Auth0 retains, claude.ai round-trips
|
||||
// whichever it received). Strict byte equality therefore fails routinely
|
||||
// on legitimate setups; this helper is the most common pragmatic relaxation
|
||||
// while keeping path/scheme/host strict so token confusion across distinct
|
||||
// resources still fails closed.
|
||||
//
|
||||
// Returns false when claims is empty.
|
||||
func MatchesResource(claims []string, resource string) bool {
|
||||
expected := strings.TrimSuffix(resource, "/")
|
||||
for _, c := range claims {
|
||||
if c == resource {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSuffix(c, "/") == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file implements Authorization Server Metadata.
|
||||
// See https://www.rfc-editor.org/rfc/rfc8414.html.
|
||||
|
||||
package oauthex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
||||
)
|
||||
|
||||
// AuthServerMeta represents the metadata for an OAuth 2.0 authorization server,
|
||||
// as defined in [RFC 8414].
|
||||
//
|
||||
// Not supported:
|
||||
// - signed metadata
|
||||
//
|
||||
// Note: URL fields in this struct are validated by validateAuthServerMetaURLs to
|
||||
// prevent XSS attacks. If you add a new URL field, you must also add it to that
|
||||
// function.
|
||||
//
|
||||
// [RFC 8414]: https://tools.ietf.org/html/rfc8414)
|
||||
type AuthServerMeta struct {
|
||||
// Issuer is the REQUIRED URL identifying the authorization server.
|
||||
Issuer string `json:"issuer"`
|
||||
|
||||
// AuthorizationEndpoint is the REQUIRED URL of the server's OAuth 2.0 authorization endpoint.
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
|
||||
// TokenEndpoint is the REQUIRED URL of the server's OAuth 2.0 token endpoint.
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
|
||||
// JWKSURI is the REQUIRED URL of the server's JSON Web Key Set [JWK] document.
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
|
||||
// RegistrationEndpoint is the RECOMMENDED URL of the server's OAuth 2.0 Dynamic Client Registration endpoint.
|
||||
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
|
||||
|
||||
// ScopesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0
|
||||
// "scope" values that this server supports.
|
||||
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
||||
|
||||
// ResponseTypesSupported is a REQUIRED JSON array of strings containing a list of the OAuth 2.0
|
||||
// "response_type" values that this server supports.
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
|
||||
// ResponseModesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0
|
||||
// "response_mode" values that this server supports.
|
||||
ResponseModesSupported []string `json:"response_modes_supported,omitempty"`
|
||||
|
||||
// GrantTypesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0
|
||||
// grant type values that this server supports.
|
||||
GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
|
||||
|
||||
// TokenEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing a list of
|
||||
// client authentication methods supported by this token endpoint.
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
|
||||
|
||||
// TokenEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings containing
|
||||
// a list of the JWS signing algorithms ("alg" values) supported by the token endpoint for
|
||||
// the signature on the JWT used to authenticate the client.
|
||||
TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"`
|
||||
|
||||
// ServiceDocumentation is a RECOMMENDED URL of a page containing human-readable documentation
|
||||
// for the service.
|
||||
ServiceDocumentation string `json:"service_documentation,omitempty"`
|
||||
|
||||
// UILocalesSupported is a RECOMMENDED JSON array of strings representing supported
|
||||
// BCP47 [RFC5646] language tag values for display in the user interface.
|
||||
UILocalesSupported []string `json:"ui_locales_supported,omitempty"`
|
||||
|
||||
// OpPolicyURI is a RECOMMENDED URL that the server provides to the person registering
|
||||
// the client to read about the server's operator policies.
|
||||
OpPolicyURI string `json:"op_policy_uri,omitempty"`
|
||||
|
||||
// OpTOSURI is a RECOMMENDED URL that the server provides to the person registering the
|
||||
// client to read about the server's terms of service.
|
||||
OpTOSURI string `json:"op_tos_uri,omitempty"`
|
||||
|
||||
// RevocationEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 revocation endpoint.
|
||||
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
|
||||
|
||||
// RevocationEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing
|
||||
// a list of client authentication methods supported by this revocation endpoint.
|
||||
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"`
|
||||
|
||||
// RevocationEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings
|
||||
// containing a list of the JWS signing algorithms ("alg" values) supported by the revocation
|
||||
// endpoint for the signature on the JWT used to authenticate the client.
|
||||
RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"`
|
||||
|
||||
// IntrospectionEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 introspection endpoint.
|
||||
IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"`
|
||||
|
||||
// IntrospectionEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing
|
||||
// a list of client authentication methods supported by this introspection endpoint.
|
||||
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"`
|
||||
|
||||
// IntrospectionEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings
|
||||
// containing a list of the JWS signing algorithms ("alg" values) supported by the introspection
|
||||
// endpoint for the signature on the JWT used to authenticate the client.
|
||||
IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"`
|
||||
|
||||
// CodeChallengeMethodsSupported is a RECOMMENDED JSON array of strings containing a list of
|
||||
// PKCE code challenge methods supported by this authorization server.
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
|
||||
|
||||
// ClientIDMetadataDocumentSupported is a boolean indicating whether the authorization server
|
||||
// supports client ID metadata documents.
|
||||
ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported,omitempty"`
|
||||
|
||||
// AuthorizationResponseIssParameterSupported indicates whether the authorization server
|
||||
// provides the "iss" parameter in authorization responses per [RFC 9207].
|
||||
// When true, clients must verify the "iss" parameter is present and matches the Issuer field.
|
||||
//
|
||||
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
|
||||
AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"`
|
||||
}
|
||||
|
||||
// GetAuthServerMeta issues a GET request to retrieve authorization server metadata
|
||||
// from an OAuth authorization server with the given metadataURL.
|
||||
//
|
||||
// It follows [RFC 8414]:
|
||||
// - The metadataURL must use HTTPS or be a local address.
|
||||
// - The Issuer field is checked against metadataURL.Issuer.
|
||||
//
|
||||
// It also verifies that the authorization server supports PKCE and that the URLs
|
||||
// in the metadata don't use dangerous schemes.
|
||||
//
|
||||
// It returns an error if the request fails with a non-4xx status code or the fetched
|
||||
// metadata doesn't pass security validations.
|
||||
// It returns nil if the request fails with a 4xx status code.
|
||||
//
|
||||
// [RFC 8414]: https://tools.ietf.org/html/rfc8414
|
||||
func GetAuthServerMeta(ctx context.Context, metadataURL, issuer string, c *http.Client) (*AuthServerMeta, error) {
|
||||
// Only allow HTTP for local addresses (testing or development purposes).
|
||||
if err := checkHTTPSOrLoopback(metadataURL); err != nil {
|
||||
return nil, fmt.Errorf("metadataURL: %v", err)
|
||||
}
|
||||
asm, err := getJSON[AuthServerMeta](ctx, c, metadataURL, 1<<20)
|
||||
if err != nil {
|
||||
var httpErr *httpStatusError
|
||||
if errors.As(err, &httpErr) {
|
||||
if 400 <= httpErr.StatusCode && httpErr.StatusCode < 500 {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%v", err) // Do not expose error types.
|
||||
}
|
||||
if !authutil.IssuersEqual(asm.Issuer, issuer) {
|
||||
return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuer)
|
||||
}
|
||||
|
||||
if len(asm.CodeChallengeMethodsSupported) == 0 {
|
||||
return nil, fmt.Errorf("authorization server at %s does not implement PKCE", issuer)
|
||||
}
|
||||
|
||||
// Validate endpoint URLs to prevent XSS attacks (see #526).
|
||||
if err := validateAuthServerMetaURLs(asm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return asm, nil
|
||||
}
|
||||
|
||||
// validateAuthServerMetaURLs validates all URL fields in AuthServerMeta
|
||||
// to ensure they don't use dangerous schemes that could enable XSS attacks.
|
||||
// It also validates that URLs likely to be called by the client use
|
||||
// HTTPS or are loopback addresses.
|
||||
func validateAuthServerMetaURLs(asm *AuthServerMeta) error {
|
||||
urls := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"authorization_endpoint", asm.AuthorizationEndpoint},
|
||||
{"token_endpoint", asm.TokenEndpoint},
|
||||
{"jwks_uri", asm.JWKSURI},
|
||||
{"registration_endpoint", asm.RegistrationEndpoint},
|
||||
{"service_documentation", asm.ServiceDocumentation},
|
||||
{"op_policy_uri", asm.OpPolicyURI},
|
||||
{"op_tos_uri", asm.OpTOSURI},
|
||||
{"revocation_endpoint", asm.RevocationEndpoint},
|
||||
{"introspection_endpoint", asm.IntrospectionEndpoint},
|
||||
}
|
||||
|
||||
for _, u := range urls {
|
||||
if err := checkURLScheme(u.value); err != nil {
|
||||
return fmt.Errorf("%s: %w", u.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
urls = []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"authorization_endpoint", asm.AuthorizationEndpoint},
|
||||
{"token_endpoint", asm.TokenEndpoint},
|
||||
{"registration_endpoint", asm.RegistrationEndpoint},
|
||||
{"introspection_endpoint", asm.IntrospectionEndpoint},
|
||||
}
|
||||
|
||||
for _, u := range urls {
|
||||
if err := checkHTTPSOrLoopback(u.value); err != nil {
|
||||
return fmt.Errorf("%s: %w", u.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package oauthex
|
||||
|
||||
import "errors"
|
||||
|
||||
// ClientCredentials holds client authentication credentials for OAuth token requests.
|
||||
// It supports multiple authentication methods, but only one method should be set at a time.
|
||||
// Use the Validate method to ensure proper configuration.
|
||||
type ClientCredentials struct {
|
||||
// ClientID is the OAuth2 client identifier.
|
||||
// REQUIRED for all authentication methods.
|
||||
ClientID string
|
||||
|
||||
// ClientSecretAuth configures client authentication using a client secret.
|
||||
// This is the most common authentication method for confidential clients.
|
||||
// OPTIONAL. If not provided, the client is treated as a public client.
|
||||
ClientSecretAuth *ClientSecretAuth
|
||||
|
||||
// Issuer is the issuer identifier of the authorization server these
|
||||
// credentials are registered with. Pre-registered credentials are bound
|
||||
// to a specific authorization server; when set, an error is returned if
|
||||
// the discovered authorization server does not match, per SEP-2352.
|
||||
// The comparison ignores a single trailing slash, matching the
|
||||
// tolerance applied during RFC 8414 Section 3.3 metadata validation.
|
||||
// OPTIONAL.
|
||||
Issuer string
|
||||
}
|
||||
|
||||
// ClientSecretAuth holds client secret authentication credentials.
|
||||
// This authentication method supports both "client_secret_basic" and "client_secret_post"
|
||||
// methods as defined in RFC 6749 Section 2.3.1.
|
||||
type ClientSecretAuth struct {
|
||||
// ClientSecret is the OAuth2 client secret for confidential clients.
|
||||
// REQUIRED when using ClientSecretAuth.
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
// Validate checks that the ClientCredentials are properly configured.
|
||||
// It ensures that:
|
||||
// - ClientID is not empty.
|
||||
// - At most one authentication method is configured.
|
||||
// - If ClientSecretAuth is set, ClientSecret is not empty.
|
||||
func (c *ClientCredentials) Validate() error {
|
||||
if c.ClientID == "" {
|
||||
return errors.New("ClientID is required")
|
||||
}
|
||||
|
||||
// Count how many auth methods are configured.
|
||||
authMethodCount := 0
|
||||
if c.ClientSecretAuth != nil {
|
||||
authMethodCount++
|
||||
if c.ClientSecretAuth.ClientSecret == "" {
|
||||
return errors.New("ClientSecret is required when using ClientSecretAuth")
|
||||
}
|
||||
}
|
||||
|
||||
// Allow zero auth methods (public client) or exactly one auth method.
|
||||
if authMethodCount > 1 {
|
||||
return errors.New("only one client authentication method can be configured")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file implements Authorization Server Metadata.
|
||||
// See https://www.rfc-editor.org/rfc/rfc8414.html.
|
||||
|
||||
package oauthex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
internaljson "github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
)
|
||||
|
||||
// ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591).
|
||||
//
|
||||
// Note: URL fields in this struct are validated by validateClientRegistrationURLs
|
||||
// to prevent XSS attacks. If you add a new URL field, you must also add it to
|
||||
// that function.
|
||||
type ClientRegistrationMetadata struct {
|
||||
// RedirectURIs is a REQUIRED JSON array of redirection URI strings for use in
|
||||
// redirect-based flows (such as the authorization code grant).
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
|
||||
// TokenEndpointAuthMethod is an OPTIONAL string indicator of the requested
|
||||
// authentication method for the token endpoint.
|
||||
// If omitted, the default is "client_secret_basic".
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
|
||||
// GrantTypes is an OPTIONAL JSON array of OAuth 2.0 grant type strings
|
||||
// that the client will restrict itself to using.
|
||||
// If omitted, the default is ["authorization_code"].
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
|
||||
// ResponseTypes is an OPTIONAL JSON array of OAuth 2.0 response type strings
|
||||
// that the client will restrict itself to using.
|
||||
// If omitted, the default is ["code"].
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
|
||||
// ClientName is a RECOMMENDED human-readable name of the client to be presented
|
||||
// to the end-user.
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
|
||||
// ClientURI is a RECOMMENDED URL of a web page providing information about the client.
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
|
||||
// LogoURI is an OPTIONAL URL of a logo for the client, which may be displayed
|
||||
// to the end-user.
|
||||
LogoURI string `json:"logo_uri,omitempty"`
|
||||
|
||||
// Scope is an OPTIONAL string containing a space-separated list of scope values
|
||||
// that the client will restrict itself to using.
|
||||
Scope string `json:"scope,omitempty"`
|
||||
|
||||
// Contacts is an OPTIONAL JSON array of strings representing ways to contact
|
||||
// people responsible for this client (e.g., email addresses).
|
||||
Contacts []string `json:"contacts,omitempty"`
|
||||
|
||||
// TOSURI is an OPTIONAL URL that the client provides to the end-user
|
||||
// to read about the client's terms of service.
|
||||
TOSURI string `json:"tos_uri,omitempty"`
|
||||
|
||||
// PolicyURI is an OPTIONAL URL that the client provides to the end-user
|
||||
// to read about the client's privacy policy.
|
||||
PolicyURI string `json:"policy_uri,omitempty"`
|
||||
|
||||
// JWKSURI is an OPTIONAL URL for the client's JSON Web Key Set [JWK] document.
|
||||
// This is preferred over the 'jwks' parameter.
|
||||
JWKSURI string `json:"jwks_uri,omitempty"`
|
||||
|
||||
// JWKS is an OPTIONAL client's JSON Web Key Set [JWK] document, passed by value.
|
||||
// This is an alternative to providing a JWKSURI.
|
||||
JWKS string `json:"jwks,omitempty"`
|
||||
|
||||
// SoftwareID is an OPTIONAL unique identifier string for the client software,
|
||||
// constant across all instances and versions.
|
||||
SoftwareID string `json:"software_id,omitempty"`
|
||||
|
||||
// SoftwareVersion is an OPTIONAL version identifier string for the client software.
|
||||
SoftwareVersion string `json:"software_version,omitempty"`
|
||||
|
||||
// SoftwareStatement is an OPTIONAL JWT that asserts client metadata values.
|
||||
// Values in the software statement take precedence over other metadata values.
|
||||
SoftwareStatement string `json:"software_statement,omitempty"`
|
||||
|
||||
// ApplicationType is an OPTIONAL string that indicates the type of application.
|
||||
// Valid values are "native" and "web".
|
||||
// If omitted, OIDC-compliant authorization servers default to "web".
|
||||
ApplicationType string `json:"application_type,omitempty"`
|
||||
}
|
||||
|
||||
// ClientRegistrationResponse represents the fields returned by the Authorization Server
|
||||
// (RFC 7591, Section 3.2.1 and 3.2.2).
|
||||
type ClientRegistrationResponse struct {
|
||||
// ClientRegistrationMetadata contains all registered client metadata, returned by the
|
||||
// server on success, potentially with modified or defaulted values.
|
||||
ClientRegistrationMetadata
|
||||
|
||||
// ClientID is the REQUIRED newly issued OAuth 2.0 client identifier.
|
||||
ClientID string `json:"client_id"`
|
||||
|
||||
// ClientSecret is an OPTIONAL client secret string.
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
|
||||
// ClientIDIssuedAt is an OPTIONAL Unix timestamp when the ClientID was issued.
|
||||
ClientIDIssuedAt time.Time `json:"client_id_issued_at,omitempty"`
|
||||
|
||||
// ClientSecretExpiresAt is the REQUIRED (if client_secret is issued) Unix
|
||||
// timestamp when the secret expires, or 0 if it never expires.
|
||||
ClientSecretExpiresAt time.Time `json:"client_secret_expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func (r *ClientRegistrationResponse) MarshalJSON() ([]byte, error) {
|
||||
type alias ClientRegistrationResponse
|
||||
var clientIDIssuedAt int64
|
||||
var clientSecretExpiresAt int64
|
||||
|
||||
if !r.ClientIDIssuedAt.IsZero() {
|
||||
clientIDIssuedAt = r.ClientIDIssuedAt.Unix()
|
||||
}
|
||||
if !r.ClientSecretExpiresAt.IsZero() {
|
||||
clientSecretExpiresAt = r.ClientSecretExpiresAt.Unix()
|
||||
}
|
||||
|
||||
return json.Marshal(&struct {
|
||||
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
|
||||
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
|
||||
*alias
|
||||
}{
|
||||
ClientIDIssuedAt: clientIDIssuedAt,
|
||||
ClientSecretExpiresAt: clientSecretExpiresAt,
|
||||
alias: (*alias)(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ClientRegistrationResponse) UnmarshalJSON(data []byte) error {
|
||||
type alias ClientRegistrationResponse
|
||||
aux := &struct {
|
||||
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
|
||||
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
|
||||
*alias
|
||||
}{
|
||||
alias: (*alias)(r),
|
||||
}
|
||||
if err := internaljson.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
if aux.ClientIDIssuedAt != 0 {
|
||||
r.ClientIDIssuedAt = time.Unix(aux.ClientIDIssuedAt, 0)
|
||||
}
|
||||
if aux.ClientSecretExpiresAt != 0 {
|
||||
r.ClientSecretExpiresAt = time.Unix(aux.ClientSecretExpiresAt, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClientRegistrationError is the error response from the Authorization Server
|
||||
// for a failed registration attempt (RFC 7591, Section 3.2.2).
|
||||
type ClientRegistrationError struct {
|
||||
// ErrorCode is the REQUIRED error code if registration failed (RFC 7591, 3.2.2).
|
||||
ErrorCode string `json:"error"`
|
||||
|
||||
// ErrorDescription is an OPTIONAL human-readable error message.
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
func (e *ClientRegistrationError) Error() string {
|
||||
return fmt.Sprintf("registration failed: %s (%s)", e.ErrorCode, e.ErrorDescription)
|
||||
}
|
||||
|
||||
// RegisterClient performs Dynamic Client Registration according to RFC 7591.
|
||||
func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta *ClientRegistrationMetadata, c *http.Client) (*ClientRegistrationResponse, error) {
|
||||
if registrationEndpoint == "" {
|
||||
return nil, fmt.Errorf("registration_endpoint is required")
|
||||
}
|
||||
|
||||
if c == nil {
|
||||
c = http.DefaultClient
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(clientMeta)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal client metadata: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", registrationEndpoint, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create registration request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("registration request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read registration response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
||||
var regResponse ClientRegistrationResponse
|
||||
if err := internaljson.Unmarshal(body, ®Response); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body))
|
||||
}
|
||||
if regResponse.ClientID == "" {
|
||||
return nil, fmt.Errorf("registration response is missing required 'client_id' field")
|
||||
}
|
||||
// Validate URL fields to prevent XSS attacks (see #526).
|
||||
if err := validateClientRegistrationURLs(®Response.ClientRegistrationMetadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ®Response, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
var regError ClientRegistrationError
|
||||
if err := internaljson.Unmarshal(body, ®Error); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body))
|
||||
}
|
||||
return nil, ®Error
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("registration failed with status %s: %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
// validateClientRegistrationURLs validates all URL fields in ClientRegistrationMetadata
|
||||
// to ensure they don't use dangerous schemes that could enable XSS attacks.
|
||||
func validateClientRegistrationURLs(meta *ClientRegistrationMetadata) error {
|
||||
// Validate redirect URIs
|
||||
for i, uri := range meta.RedirectURIs {
|
||||
if err := checkURLScheme(uri); err != nil {
|
||||
return fmt.Errorf("redirect_uris[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate other URL fields
|
||||
urls := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"client_uri", meta.ClientURI},
|
||||
{"logo_uri", meta.LogoURI},
|
||||
{"tos_uri", meta.TOSURI},
|
||||
{"policy_uri", meta.PolicyURI},
|
||||
{"jwks_uri", meta.JWKSURI},
|
||||
}
|
||||
|
||||
for _, u := range urls {
|
||||
if err := checkURLScheme(u.value); err != nil {
|
||||
return fmt.Errorf("%s: %w", u.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package oauthex implements extensions to OAuth2.
|
||||
|
||||
package oauthex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/json"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
)
|
||||
|
||||
type httpStatusError struct {
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (e *httpStatusError) Error() string {
|
||||
return fmt.Sprintf("bad status %d", e.StatusCode)
|
||||
}
|
||||
|
||||
// getJSON retrieves JSON and unmarshals JSON from the URL, as specified in both
|
||||
// RFC 9728 and RFC 8414.
|
||||
// It will not read more than limit bytes from the body.
|
||||
func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64) (*T, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c == nil {
|
||||
c = http.DefaultClient
|
||||
}
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, &httpStatusError{StatusCode: res.StatusCode}
|
||||
}
|
||||
ct := res.Header.Get("Content-Type")
|
||||
mediaType, _, err := mime.ParseMediaType(ct)
|
||||
if err != nil || mediaType != "application/json" {
|
||||
return nil, fmt.Errorf("bad content type %q", ct)
|
||||
}
|
||||
|
||||
var t T
|
||||
dec := json.NewDecoder(io.LimitReader(res.Body, limit))
|
||||
if err := dec.Decode(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// checkURLScheme ensures that its argument is a valid URL with a scheme
|
||||
// that prevents XSS attacks.
|
||||
// See #526.
|
||||
// Note: a copy of this function exists in auth/extauth/oidc_login.go; keep these in sync.
|
||||
func checkURLScheme(u string) error {
|
||||
if u == "" {
|
||||
return nil
|
||||
}
|
||||
uu, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scheme := strings.ToLower(uu.Scheme)
|
||||
if scheme == "javascript" || scheme == "data" || scheme == "vbscript" {
|
||||
return fmt.Errorf("URL has disallowed scheme %q", scheme)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkHTTPSOrLoopback(addr string) error {
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !util.IsLoopback(u.Host) && u.Scheme != "https" {
|
||||
return fmt.Errorf("URL %q does not use HTTPS or is not a loopback address", addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package oauthex implements extensions to OAuth2.
|
||||
package oauthex
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file implements Protected Resource Metadata.
|
||||
// See https://www.rfc-editor.org/rfc/rfc9728.html.
|
||||
|
||||
package oauthex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
)
|
||||
|
||||
// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource,
|
||||
// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html.
|
||||
//
|
||||
// The following features are not supported:
|
||||
// - additional keys (§2, last sentence)
|
||||
// - human-readable metadata (§2.1)
|
||||
// - signed metadata (§2.2)
|
||||
type ProtectedResourceMetadata struct {
|
||||
// Resource (resource) is the protected resource's resource identifier.
|
||||
// Required.
|
||||
Resource string `json:"resource"`
|
||||
|
||||
// AuthorizationServers (authorization_servers) is an optional slice containing a list of
|
||||
// OAuth authorization server issuer identifiers (as defined in RFC 8414) that can be
|
||||
// used with this protected resource.
|
||||
AuthorizationServers []string `json:"authorization_servers,omitempty"`
|
||||
|
||||
// JWKSURI (jwks_uri) is an optional URL of the protected resource's JSON Web Key (JWK) Set
|
||||
// document. This contains public keys belonging to the protected resource, such as
|
||||
// signing key(s) that the resource server uses to sign resource responses.
|
||||
JWKSURI string `json:"jwks_uri,omitempty"`
|
||||
|
||||
// ScopesSupported (scopes_supported) is a recommended slice containing a list of scope
|
||||
// values (as defined in RFC 6749) used in authorization requests to request access
|
||||
// to this protected resource.
|
||||
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
||||
|
||||
// BearerMethodsSupported (bearer_methods_supported) is an optional slice containing
|
||||
// a list of the supported methods of sending an OAuth 2.0 bearer token to the
|
||||
// protected resource. Defined values are "header", "body", and "query".
|
||||
BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"`
|
||||
|
||||
// ResourceSigningAlgValuesSupported (resource_signing_alg_values_supported) is an optional
|
||||
// slice of JWS signing algorithms (alg values) supported by the protected
|
||||
// resource for signing resource responses.
|
||||
ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"`
|
||||
|
||||
// ResourceName (resource_name) is a human-readable name of the protected resource
|
||||
// intended for display to the end user. It is RECOMMENDED that this field be included.
|
||||
// This value may be internationalized.
|
||||
ResourceName string `json:"resource_name,omitempty"`
|
||||
|
||||
// ResourceDocumentation (resource_documentation) is an optional URL of a page containing
|
||||
// human-readable information for developers using the protected resource.
|
||||
// This value may be internationalized.
|
||||
ResourceDocumentation string `json:"resource_documentation,omitempty"`
|
||||
|
||||
// ResourcePolicyURI (resource_policy_uri) is an optional URL of a page containing
|
||||
// human-readable policy information on how a client can use the data provided.
|
||||
// This value may be internationalized.
|
||||
ResourcePolicyURI string `json:"resource_policy_uri,omitempty"`
|
||||
|
||||
// ResourceTOSURI (resource_tos_uri) is an optional URL of a page containing the protected
|
||||
// resource's human-readable terms of service. This value may be internationalized.
|
||||
ResourceTOSURI string `json:"resource_tos_uri,omitempty"`
|
||||
|
||||
// TLSClientCertificateBoundAccessTokens (tls_client_certificate_bound_access_tokens) is an
|
||||
// optional boolean indicating support for mutual-TLS client certificate-bound
|
||||
// access tokens (RFC 8705). Defaults to false if omitted.
|
||||
TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"`
|
||||
|
||||
// AuthorizationDetailsTypesSupported (authorization_details_types_supported) is an optional
|
||||
// slice of 'type' values supported by the resource server for the
|
||||
// 'authorization_details' parameter (RFC 9396).
|
||||
AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"`
|
||||
|
||||
// DPOPSigningAlgValuesSupported (dpop_signing_alg_values_supported) is an optional
|
||||
// slice of JWS signing algorithms supported by the resource server for validating
|
||||
// DPoP proof JWTs (RFC 9449).
|
||||
DPOPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"`
|
||||
|
||||
// DPOPBoundAccessTokensRequired (dpop_bound_access_tokens_required) is an optional boolean
|
||||
// specifying whether the protected resource always requires the use of DPoP-bound
|
||||
// access tokens (RFC 9449). Defaults to false if omitted.
|
||||
DPOPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"`
|
||||
|
||||
// SignedMetadata (signed_metadata) is an optional JWT containing metadata parameters
|
||||
// about the protected resource as claims. If present, these values take precedence
|
||||
// over values conveyed in plain JSON.
|
||||
// TODO:implement.
|
||||
// Note that §2.2 says it's okay to ignore this.
|
||||
// SignedMetadata string `json:"signed_metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Challenge represents a single authentication challenge from a WWW-Authenticate header.
|
||||
// As per RFC 9110, Section 11.6.1, a challenge consists of a scheme and optional parameters.
|
||||
type Challenge struct {
|
||||
// Scheme is the authentication scheme (e.g., "Bearer", "Basic").
|
||||
// It is case-insensitive. A parsed value will always be lower-case.
|
||||
Scheme string
|
||||
// Params is a map of authentication parameters.
|
||||
// Keys are case-insensitive. Parsed keys are always lower-case.
|
||||
Params map[string]string
|
||||
}
|
||||
|
||||
// GetProtectedResourceMetadata issues a GET request to retrieve protected resource
|
||||
// metadata from a resource server.
|
||||
// The metadataURL is typically a URL with a host:port and possibly a path.
|
||||
// The resourceURL is the resource URI the metadataURL is for.
|
||||
// The following checks are performed:
|
||||
// - The metadataURL must use HTTPS or be a local address.
|
||||
// - The resource field of the resulting metadata must match the resourceURL.
|
||||
// - The authorization_servers field of the resulting metadata is checked for dangerous URL schemes.
|
||||
func GetProtectedResourceMetadata(ctx context.Context, metadataURL, resourceURL string, c *http.Client) (_ *ProtectedResourceMetadata, err error) {
|
||||
defer util.Wrapf(&err, "GetProtectedResourceMetadata(%q)", metadataURL)
|
||||
// Only allow HTTP for local addresses (testing or development purposes).
|
||||
if err := checkHTTPSOrLoopback(metadataURL); err != nil {
|
||||
return nil, fmt.Errorf("metadataURL: %v", err)
|
||||
}
|
||||
prm, err := getJSON[ProtectedResourceMetadata](ctx, c, metadataURL, 1<<20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Validate the Resource field (see RFC 9728, section 3.3).
|
||||
if prm.Resource != resourceURL {
|
||||
return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, resourceURL)
|
||||
}
|
||||
// Validate the authorization server URLs to prevent XSS attacks (see #526).
|
||||
for i, u := range prm.AuthorizationServers {
|
||||
if err := checkURLScheme(u); err != nil {
|
||||
return nil, fmt.Errorf("authorization_servers[%d]: %v", i, err)
|
||||
}
|
||||
if err := checkHTTPSOrLoopback(u); err != nil {
|
||||
return nil, fmt.Errorf("authorization_servers[%d]: %v", i, err)
|
||||
}
|
||||
}
|
||||
return prm, nil
|
||||
}
|
||||
|
||||
// ParseWWWAuthenticate parses a WWW-Authenticate header string.
|
||||
// The header format is defined in RFC 9110, Section 11.6.1, and can contain
|
||||
// one or more challenges, separated by commas.
|
||||
// It returns a slice of challenges or an error if one of the headers is malformed.
|
||||
func ParseWWWAuthenticate(headers []string) ([]Challenge, error) {
|
||||
var challenges []Challenge
|
||||
for _, h := range headers {
|
||||
challengeStrings, err := splitChallenges(h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, cs := range challengeStrings {
|
||||
if strings.TrimSpace(cs) == "" {
|
||||
continue
|
||||
}
|
||||
challenge, err := parseSingleChallenge(cs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse challenge %q: %w", cs, err)
|
||||
}
|
||||
challenges = append(challenges, challenge)
|
||||
}
|
||||
}
|
||||
return challenges, nil
|
||||
}
|
||||
|
||||
// splitChallenges splits a header value containing one or more challenges.
|
||||
// It correctly handles commas within quoted strings and distinguishes between
|
||||
// commas separating auth-params and commas separating challenges.
|
||||
func splitChallenges(header string) ([]string, error) {
|
||||
var challenges []string
|
||||
inQuotes := false
|
||||
start := 0
|
||||
for i, r := range header {
|
||||
if r == '"' {
|
||||
if i > 0 && header[i-1] != '\\' {
|
||||
inQuotes = !inQuotes
|
||||
} else if i == 0 {
|
||||
// A challenge begins with an auth-scheme, which is a token, which cannot contain
|
||||
// a quote.
|
||||
return nil, errors.New(`challenge begins with '"'`)
|
||||
}
|
||||
} else if r == ',' && !inQuotes {
|
||||
// This is a potential challenge separator.
|
||||
// A new challenge does not start with `key=value`.
|
||||
// We check if the part after the comma looks like a parameter.
|
||||
lookahead := strings.TrimSpace(header[i+1:])
|
||||
eqPos := strings.Index(lookahead, "=")
|
||||
|
||||
isParam := false
|
||||
if eqPos > 0 {
|
||||
// Check if the part before '=' is a single token (no spaces).
|
||||
token := lookahead[:eqPos]
|
||||
if strings.IndexFunc(token, unicode.IsSpace) == -1 {
|
||||
isParam = true
|
||||
}
|
||||
}
|
||||
|
||||
if !isParam {
|
||||
// The part after the comma does not look like a parameter,
|
||||
// so this comma separates challenges.
|
||||
challenges = append(challenges, header[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add the last (or only) challenge to the list.
|
||||
challenges = append(challenges, header[start:])
|
||||
return challenges, nil
|
||||
}
|
||||
|
||||
// parseSingleChallenge parses a string containing exactly one challenge.
|
||||
// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
|
||||
func parseSingleChallenge(s string) (Challenge, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return Challenge{}, errors.New("empty challenge string")
|
||||
}
|
||||
|
||||
scheme, paramsStr, found := strings.Cut(s, " ")
|
||||
c := Challenge{Scheme: strings.ToLower(scheme)}
|
||||
if !found {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
params := make(map[string]string)
|
||||
|
||||
// Parse the key-value parameters.
|
||||
for paramsStr != "" {
|
||||
// Find the end of the parameter key.
|
||||
keyEnd := strings.Index(paramsStr, "=")
|
||||
if keyEnd <= 0 {
|
||||
return Challenge{}, fmt.Errorf("malformed auth parameter: expected key=value, but got %q", paramsStr)
|
||||
}
|
||||
key := strings.TrimSpace(paramsStr[:keyEnd])
|
||||
|
||||
// Move the string past the key and the '='.
|
||||
paramsStr = strings.TrimSpace(paramsStr[keyEnd+1:])
|
||||
|
||||
var value string
|
||||
if strings.HasPrefix(paramsStr, "\"") {
|
||||
// The value is a quoted string.
|
||||
paramsStr = paramsStr[1:] // Consume the opening quote.
|
||||
var valBuilder strings.Builder
|
||||
i := 0
|
||||
for ; i < len(paramsStr); i++ {
|
||||
// Handle escaped characters.
|
||||
if paramsStr[i] == '\\' && i+1 < len(paramsStr) {
|
||||
valBuilder.WriteByte(paramsStr[i+1])
|
||||
i++ // We've consumed two characters.
|
||||
} else if paramsStr[i] == '"' {
|
||||
// End of the quoted string.
|
||||
break
|
||||
} else {
|
||||
valBuilder.WriteByte(paramsStr[i])
|
||||
}
|
||||
}
|
||||
|
||||
// A quoted string must be terminated.
|
||||
if i == len(paramsStr) {
|
||||
return Challenge{}, fmt.Errorf("unterminated quoted string in auth parameter")
|
||||
}
|
||||
|
||||
value = valBuilder.String()
|
||||
// Move the string past the value and the closing quote.
|
||||
paramsStr = strings.TrimSpace(paramsStr[i+1:])
|
||||
} else {
|
||||
// The value is a token. It ends at the next comma or the end of the string.
|
||||
commaPos := strings.Index(paramsStr, ",")
|
||||
if commaPos == -1 {
|
||||
value = paramsStr
|
||||
paramsStr = ""
|
||||
} else {
|
||||
value = strings.TrimSpace(paramsStr[:commaPos])
|
||||
paramsStr = strings.TrimSpace(paramsStr[commaPos:]) // Keep comma for next check
|
||||
}
|
||||
}
|
||||
if value == "" {
|
||||
return Challenge{}, fmt.Errorf("no value for auth param %q", key)
|
||||
}
|
||||
|
||||
// Per RFC 9110, parameter keys are case-insensitive.
|
||||
params[strings.ToLower(key)] = value
|
||||
|
||||
// If there is a comma, consume it and continue to the next parameter.
|
||||
if strings.HasPrefix(paramsStr, ",") {
|
||||
paramsStr = strings.TrimSpace(paramsStr[1:])
|
||||
} else if paramsStr != "" {
|
||||
// If there's content but it's not a new parameter, the format is wrong.
|
||||
return Challenge{}, fmt.Errorf("malformed auth parameter: expected comma after value, but got %q", paramsStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Per RFC 9110, the scheme is case-insensitive.
|
||||
return Challenge{Scheme: strings.ToLower(scheme), Params: params}, nil
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file implements Token Exchange (RFC 8693) for Enterprise Managed Authorization.
|
||||
// See https://datatracker.ietf.org/doc/html/rfc8693
|
||||
|
||||
package oauthex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Token type identifiers defined by RFC 8693 and SEP-990.
|
||||
const (
|
||||
// TokenTypeIDToken is the URN for OpenID Connect ID Tokens.
|
||||
TokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token"
|
||||
|
||||
// TokenTypeSAML2 is the URN for SAML 2.0 assertions.
|
||||
TokenTypeSAML2 = "urn:ietf:params:oauth:token-type:saml2"
|
||||
|
||||
// TokenTypeIDJAG is the URN for Identity Assertion JWT Authorization Grants.
|
||||
// This is the token type returned by IdP during token exchange for SEP-990.
|
||||
TokenTypeIDJAG = "urn:ietf:params:oauth:token-type:id-jag"
|
||||
|
||||
// GrantTypeTokenExchange is the grant type for RFC 8693 token exchange.
|
||||
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
)
|
||||
|
||||
// TokenExchangeRequest represents a Token Exchange request per RFC 8693.
|
||||
// This is used for Enterprise Managed Authorization (SEP-990) where an MCP Client
|
||||
// exchanges an ID Token from an enterprise IdP for an ID-JAG that can be used
|
||||
// to obtain an access token from an MCP Server's authorization server.
|
||||
type TokenExchangeRequest struct {
|
||||
// RequestedTokenType indicates the type of security token being requested.
|
||||
// For SEP-990, this MUST be TokenTypeIDJAG.
|
||||
RequestedTokenType string
|
||||
|
||||
// Audience is the logical name of the target service where the client
|
||||
// intends to use the requested token. For SEP-990, this MUST be the
|
||||
// Issuer URL of the MCP Server's authorization server.
|
||||
Audience string
|
||||
|
||||
// Resource is the physical location or identifier of the target resource.
|
||||
// For SEP-990, this MUST be the RFC9728 Resource Identifier of the MCP Server.
|
||||
Resource string
|
||||
|
||||
// Scope is a list of space-separated scopes for the requested token.
|
||||
// This is OPTIONAL per RFC 8693 but commonly used in SEP-990.
|
||||
Scope []string
|
||||
|
||||
// SubjectToken is the security token that represents the identity of the
|
||||
// party on behalf of whom the request is being made. For SEP-990, this is
|
||||
// typically an OpenID Connect ID Token.
|
||||
SubjectToken string
|
||||
|
||||
// SubjectTokenType is the type of the security token in SubjectToken.
|
||||
// For SEP-990 with OIDC, this MUST be TokenTypeIDToken.
|
||||
SubjectTokenType string
|
||||
}
|
||||
|
||||
// ExchangeToken performs a token exchange request per RFC 8693 for Enterprise
|
||||
// Managed Authorization (SEP-990). It exchanges an identity assertion (typically
|
||||
// an ID Token) for an Identity Assertion JWT Authorization Grant (ID-JAG) that
|
||||
// can be used to obtain an access token from an MCP Server.
|
||||
//
|
||||
// The tokenEndpoint parameter should be the IdP's token endpoint (typically
|
||||
// obtained from the IdP's authorization server metadata).
|
||||
//
|
||||
// Returns an oauth2.Token where:
|
||||
// - Extra("issued_token_type") contains the type of the issued token (e.g., TokenTypeIDJAG)
|
||||
// - AccessToken contains the ID-JAG JWT (despite the name, this is not an OAuth access token)
|
||||
// - TokenType is typically "N_A" for SEP-990
|
||||
// - Extra("scope") may contain the scope if different from the request
|
||||
// - Expiry is when the token expires
|
||||
func ExchangeToken(
|
||||
ctx context.Context,
|
||||
tokenEndpoint string,
|
||||
req *TokenExchangeRequest,
|
||||
clientCreds *ClientCredentials,
|
||||
httpClient *http.Client,
|
||||
) (*oauth2.Token, error) {
|
||||
if tokenEndpoint == "" {
|
||||
return nil, fmt.Errorf("token endpoint is required")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("token exchange request is required")
|
||||
}
|
||||
if clientCreds == nil {
|
||||
return nil, fmt.Errorf("client credentials are required")
|
||||
}
|
||||
if err := clientCreds.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid client credentials: %w", err)
|
||||
}
|
||||
|
||||
// Validate required fields per SEP-990 Section 4.
|
||||
if req.RequestedTokenType == "" {
|
||||
return nil, fmt.Errorf("requested_token_type is required")
|
||||
}
|
||||
if req.Audience == "" {
|
||||
return nil, fmt.Errorf("audience is required")
|
||||
}
|
||||
if req.Resource == "" {
|
||||
return nil, fmt.Errorf("resource is required")
|
||||
}
|
||||
if req.SubjectToken == "" {
|
||||
return nil, fmt.Errorf("subject_token is required")
|
||||
}
|
||||
if req.SubjectTokenType == "" {
|
||||
return nil, fmt.Errorf("subject_token_type is required")
|
||||
}
|
||||
|
||||
// Validate URL schemes to prevent XSS attacks (see #526).
|
||||
if err := checkURLScheme(tokenEndpoint); err != nil {
|
||||
return nil, fmt.Errorf("invalid token endpoint: %w", err)
|
||||
}
|
||||
if err := checkURLScheme(req.Audience); err != nil {
|
||||
return nil, fmt.Errorf("invalid audience: %w", err)
|
||||
}
|
||||
if err := checkURLScheme(req.Resource); err != nil {
|
||||
return nil, fmt.Errorf("invalid resource: %w", err)
|
||||
}
|
||||
|
||||
// Per RFC 6749 Section 3.2, parameters sent without a value (like the empty
|
||||
// "code" parameter) MUST be treated as if they were omitted from the request.
|
||||
// The oauth2 library's Exchange method sends an empty code, but compliant
|
||||
// servers should ignore it.
|
||||
cfg := &oauth2.Config{
|
||||
ClientID: clientCreds.ClientID,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
TokenURL: tokenEndpoint,
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
}
|
||||
// Set ClientSecret if ClientSecretAuth is configured.
|
||||
if clientCreds.ClientSecretAuth != nil {
|
||||
cfg.ClientSecret = clientCreds.ClientSecretAuth.ClientSecret
|
||||
}
|
||||
|
||||
// Use custom HTTP client if provided.
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
// Build token exchange parameters per RFC 8693.
|
||||
opts := []oauth2.AuthCodeOption{
|
||||
oauth2.SetAuthURLParam("grant_type", GrantTypeTokenExchange),
|
||||
oauth2.SetAuthURLParam("requested_token_type", req.RequestedTokenType),
|
||||
oauth2.SetAuthURLParam("audience", req.Audience),
|
||||
oauth2.SetAuthURLParam("resource", req.Resource),
|
||||
oauth2.SetAuthURLParam("subject_token", req.SubjectToken),
|
||||
oauth2.SetAuthURLParam("subject_token_type", req.SubjectTokenType),
|
||||
}
|
||||
if len(req.Scope) > 0 {
|
||||
opts = append(opts, oauth2.SetAuthURLParam("scope", strings.Join(req.Scope, " ")))
|
||||
}
|
||||
|
||||
// Exchange with token exchange grant type.
|
||||
// SetAuthURLParam overrides the default grant_type and adds all required parameters.
|
||||
token, err := cfg.Exchange(
|
||||
ctxWithClient,
|
||||
"", // empty code - per RFC 6749 Section 3.2, empty params should be ignored
|
||||
opts...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token exchange request failed: %w", err)
|
||||
}
|
||||
|
||||
// Validate that issued_token_type is present in the response.
|
||||
// The oauth2 library stores additional response fields in Extra.
|
||||
issuedTokenType, _ := token.Extra("issued_token_type").(string)
|
||||
if issuedTokenType == "" {
|
||||
return nil, fmt.Errorf("response missing required field: issued_token_type")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Segment
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package ascii
|
||||
|
||||
import _ "github.com/segmentio/asm/cpu"
|
||||
|
||||
// https://graphics.stanford.edu/~seander/bithacks.html#HasLessInWord
|
||||
const (
|
||||
hasLessConstL64 = (^uint64(0)) / 255
|
||||
hasLessConstR64 = hasLessConstL64 * 128
|
||||
|
||||
hasLessConstL32 = (^uint32(0)) / 255
|
||||
hasLessConstR32 = hasLessConstL32 * 128
|
||||
|
||||
hasMoreConstL64 = (^uint64(0)) / 255
|
||||
hasMoreConstR64 = hasMoreConstL64 * 128
|
||||
|
||||
hasMoreConstL32 = (^uint32(0)) / 255
|
||||
hasMoreConstR32 = hasMoreConstL32 * 128
|
||||
)
|
||||
|
||||
func hasLess64(x, n uint64) bool {
|
||||
return ((x - (hasLessConstL64 * n)) & ^x & hasLessConstR64) != 0
|
||||
}
|
||||
|
||||
func hasLess32(x, n uint32) bool {
|
||||
return ((x - (hasLessConstL32 * n)) & ^x & hasLessConstR32) != 0
|
||||
}
|
||||
|
||||
func hasMore64(x, n uint64) bool {
|
||||
return (((x + (hasMoreConstL64 * (127 - n))) | x) & hasMoreConstR64) != 0
|
||||
}
|
||||
|
||||
func hasMore32(x, n uint32) bool {
|
||||
return (((x + (hasMoreConstL32 * (127 - n))) | x) & hasMoreConstR32) != 0
|
||||
}
|
||||
|
||||
var lowerCase = [256]byte{
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
|
||||
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f,
|
||||
0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f,
|
||||
0x40, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f,
|
||||
0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f,
|
||||
0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f,
|
||||
0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
|
||||
0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f,
|
||||
0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f,
|
||||
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf,
|
||||
0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf,
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf,
|
||||
0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf,
|
||||
0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef,
|
||||
0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff,
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package ascii
|
||||
|
||||
import (
|
||||
"github.com/segmentio/asm/internal/unsafebytes"
|
||||
)
|
||||
|
||||
// EqualFold is a version of bytes.EqualFold designed to work on ASCII input
|
||||
// instead of UTF-8.
|
||||
//
|
||||
// When the program has guarantees that the input is composed of ASCII
|
||||
// characters only, it allows for greater optimizations.
|
||||
func EqualFold(a, b []byte) bool {
|
||||
return EqualFoldString(unsafebytes.String(a), unsafebytes.String(b))
|
||||
}
|
||||
|
||||
func HasPrefixFold(s, prefix []byte) bool {
|
||||
return len(s) >= len(prefix) && EqualFold(s[:len(prefix)], prefix)
|
||||
}
|
||||
|
||||
func HasSuffixFold(s, suffix []byte) bool {
|
||||
return len(s) >= len(suffix) && EqualFold(s[len(s)-len(suffix):], suffix)
|
||||
}
|
||||
|
||||
func HasPrefixFoldString(s, prefix string) bool {
|
||||
return len(s) >= len(prefix) && EqualFoldString(s[:len(prefix)], prefix)
|
||||
}
|
||||
|
||||
func HasSuffixFoldString(s, suffix string) bool {
|
||||
return len(s) >= len(suffix) && EqualFoldString(s[len(s)-len(suffix):], suffix)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Code generated by command: go run equal_fold_asm.go -pkg ascii -out ../ascii/equal_fold_amd64.s -stubs ../ascii/equal_fold_amd64.go. DO NOT EDIT.
|
||||
|
||||
//go:build !purego
|
||||
// +build !purego
|
||||
|
||||
package ascii
|
||||
|
||||
// EqualFoldString is a version of strings.EqualFold designed to work on ASCII
|
||||
// input instead of UTF-8.
|
||||
//
|
||||
// When the program has guarantees that the input is composed of ASCII
|
||||
// characters only, it allows for greater optimizations.
|
||||
func EqualFoldString(a string, b string) bool
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
// Code generated by command: go run equal_fold_asm.go -pkg ascii -out ../ascii/equal_fold_amd64.s -stubs ../ascii/equal_fold_amd64.go. DO NOT EDIT.
|
||||
|
||||
//go:build !purego
|
||||
// +build !purego
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// func EqualFoldString(a string, b string) bool
|
||||
// Requires: AVX, AVX2, SSE4.1
|
||||
TEXT ·EqualFoldString(SB), NOSPLIT, $0-33
|
||||
MOVQ a_base+0(FP), CX
|
||||
MOVQ a_len+8(FP), DX
|
||||
MOVQ b_base+16(FP), BX
|
||||
CMPQ DX, b_len+24(FP)
|
||||
JNE done
|
||||
XORQ AX, AX
|
||||
CMPQ DX, $0x10
|
||||
JB init_x86
|
||||
BTL $0x08, github·com∕segmentio∕asm∕cpu·X86+0(SB)
|
||||
JCS init_avx
|
||||
|
||||
init_x86:
|
||||
LEAQ github·com∕segmentio∕asm∕ascii·lowerCase+0(SB), R9
|
||||
XORL SI, SI
|
||||
|
||||
cmp8:
|
||||
CMPQ DX, $0x08
|
||||
JB cmp7
|
||||
MOVBLZX (CX)(AX*1), DI
|
||||
MOVBLZX (BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
MOVBLZX 1(CX)(AX*1), DI
|
||||
MOVBLZX 1(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
MOVBLZX 2(CX)(AX*1), DI
|
||||
MOVBLZX 2(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
MOVBLZX 3(CX)(AX*1), DI
|
||||
MOVBLZX 3(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
MOVBLZX 4(CX)(AX*1), DI
|
||||
MOVBLZX 4(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
MOVBLZX 5(CX)(AX*1), DI
|
||||
MOVBLZX 5(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
MOVBLZX 6(CX)(AX*1), DI
|
||||
MOVBLZX 6(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
MOVBLZX 7(CX)(AX*1), DI
|
||||
MOVBLZX 7(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
JNE done
|
||||
ADDQ $0x08, AX
|
||||
SUBQ $0x08, DX
|
||||
JMP cmp8
|
||||
|
||||
cmp7:
|
||||
CMPQ DX, $0x07
|
||||
JB cmp6
|
||||
MOVBLZX 6(CX)(AX*1), DI
|
||||
MOVBLZX 6(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
|
||||
cmp6:
|
||||
CMPQ DX, $0x06
|
||||
JB cmp5
|
||||
MOVBLZX 5(CX)(AX*1), DI
|
||||
MOVBLZX 5(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
|
||||
cmp5:
|
||||
CMPQ DX, $0x05
|
||||
JB cmp4
|
||||
MOVBLZX 4(CX)(AX*1), DI
|
||||
MOVBLZX 4(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
|
||||
cmp4:
|
||||
CMPQ DX, $0x04
|
||||
JB cmp3
|
||||
MOVBLZX 3(CX)(AX*1), DI
|
||||
MOVBLZX 3(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
|
||||
cmp3:
|
||||
CMPQ DX, $0x03
|
||||
JB cmp2
|
||||
MOVBLZX 2(CX)(AX*1), DI
|
||||
MOVBLZX 2(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
|
||||
cmp2:
|
||||
CMPQ DX, $0x02
|
||||
JB cmp1
|
||||
MOVBLZX 1(CX)(AX*1), DI
|
||||
MOVBLZX 1(BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
|
||||
cmp1:
|
||||
CMPQ DX, $0x01
|
||||
JB success
|
||||
MOVBLZX (CX)(AX*1), DI
|
||||
MOVBLZX (BX)(AX*1), R8
|
||||
MOVB (R9)(DI*1), DI
|
||||
XORB (R9)(R8*1), DI
|
||||
ORB DI, SI
|
||||
|
||||
done:
|
||||
SETEQ ret+32(FP)
|
||||
RET
|
||||
|
||||
success:
|
||||
MOVB $0x01, ret+32(FP)
|
||||
RET
|
||||
|
||||
init_avx:
|
||||
MOVB $0x20, SI
|
||||
PINSRB $0x00, SI, X12
|
||||
VPBROADCASTB X12, Y12
|
||||
MOVB $0x1f, SI
|
||||
PINSRB $0x00, SI, X13
|
||||
VPBROADCASTB X13, Y13
|
||||
MOVB $0x9a, SI
|
||||
PINSRB $0x00, SI, X14
|
||||
VPBROADCASTB X14, Y14
|
||||
MOVB $0x01, SI
|
||||
PINSRB $0x00, SI, X15
|
||||
VPBROADCASTB X15, Y15
|
||||
|
||||
cmp128:
|
||||
CMPQ DX, $0x80
|
||||
JB cmp64
|
||||
VMOVDQU (CX)(AX*1), Y0
|
||||
VMOVDQU 32(CX)(AX*1), Y1
|
||||
VMOVDQU 64(CX)(AX*1), Y2
|
||||
VMOVDQU 96(CX)(AX*1), Y3
|
||||
VMOVDQU (BX)(AX*1), Y4
|
||||
VMOVDQU 32(BX)(AX*1), Y5
|
||||
VMOVDQU 64(BX)(AX*1), Y6
|
||||
VMOVDQU 96(BX)(AX*1), Y7
|
||||
VXORPD Y0, Y4, Y4
|
||||
VPCMPEQB Y12, Y4, Y8
|
||||
VORPD Y12, Y0, Y0
|
||||
VPADDB Y13, Y0, Y0
|
||||
VPCMPGTB Y0, Y14, Y0
|
||||
VPAND Y8, Y0, Y0
|
||||
VPAND Y15, Y0, Y0
|
||||
VPSLLW $0x05, Y0, Y0
|
||||
VPCMPEQB Y4, Y0, Y0
|
||||
VXORPD Y1, Y5, Y5
|
||||
VPCMPEQB Y12, Y5, Y9
|
||||
VORPD Y12, Y1, Y1
|
||||
VPADDB Y13, Y1, Y1
|
||||
VPCMPGTB Y1, Y14, Y1
|
||||
VPAND Y9, Y1, Y1
|
||||
VPAND Y15, Y1, Y1
|
||||
VPSLLW $0x05, Y1, Y1
|
||||
VPCMPEQB Y5, Y1, Y1
|
||||
VXORPD Y2, Y6, Y6
|
||||
VPCMPEQB Y12, Y6, Y10
|
||||
VORPD Y12, Y2, Y2
|
||||
VPADDB Y13, Y2, Y2
|
||||
VPCMPGTB Y2, Y14, Y2
|
||||
VPAND Y10, Y2, Y2
|
||||
VPAND Y15, Y2, Y2
|
||||
VPSLLW $0x05, Y2, Y2
|
||||
VPCMPEQB Y6, Y2, Y2
|
||||
VXORPD Y3, Y7, Y7
|
||||
VPCMPEQB Y12, Y7, Y11
|
||||
VORPD Y12, Y3, Y3
|
||||
VPADDB Y13, Y3, Y3
|
||||
VPCMPGTB Y3, Y14, Y3
|
||||
VPAND Y11, Y3, Y3
|
||||
VPAND Y15, Y3, Y3
|
||||
VPSLLW $0x05, Y3, Y3
|
||||
VPCMPEQB Y7, Y3, Y3
|
||||
VPAND Y1, Y0, Y0
|
||||
VPAND Y3, Y2, Y2
|
||||
VPAND Y2, Y0, Y0
|
||||
ADDQ $0x80, AX
|
||||
SUBQ $0x80, DX
|
||||
VPMOVMSKB Y0, SI
|
||||
XORL $0xffffffff, SI
|
||||
JNE done
|
||||
JMP cmp128
|
||||
|
||||
cmp64:
|
||||
CMPQ DX, $0x40
|
||||
JB cmp32
|
||||
VMOVDQU (CX)(AX*1), Y0
|
||||
VMOVDQU 32(CX)(AX*1), Y1
|
||||
VMOVDQU (BX)(AX*1), Y2
|
||||
VMOVDQU 32(BX)(AX*1), Y3
|
||||
VXORPD Y0, Y2, Y2
|
||||
VPCMPEQB Y12, Y2, Y4
|
||||
VORPD Y12, Y0, Y0
|
||||
VPADDB Y13, Y0, Y0
|
||||
VPCMPGTB Y0, Y14, Y0
|
||||
VPAND Y4, Y0, Y0
|
||||
VPAND Y15, Y0, Y0
|
||||
VPSLLW $0x05, Y0, Y0
|
||||
VPCMPEQB Y2, Y0, Y0
|
||||
VXORPD Y1, Y3, Y3
|
||||
VPCMPEQB Y12, Y3, Y5
|
||||
VORPD Y12, Y1, Y1
|
||||
VPADDB Y13, Y1, Y1
|
||||
VPCMPGTB Y1, Y14, Y1
|
||||
VPAND Y5, Y1, Y1
|
||||
VPAND Y15, Y1, Y1
|
||||
VPSLLW $0x05, Y1, Y1
|
||||
VPCMPEQB Y3, Y1, Y1
|
||||
VPAND Y1, Y0, Y0
|
||||
ADDQ $0x40, AX
|
||||
SUBQ $0x40, DX
|
||||
VPMOVMSKB Y0, SI
|
||||
XORL $0xffffffff, SI
|
||||
JNE done
|
||||
|
||||
cmp32:
|
||||
CMPQ DX, $0x20
|
||||
JB cmp16
|
||||
VMOVDQU (CX)(AX*1), Y0
|
||||
VMOVDQU (BX)(AX*1), Y1
|
||||
VXORPD Y0, Y1, Y1
|
||||
VPCMPEQB Y12, Y1, Y2
|
||||
VORPD Y12, Y0, Y0
|
||||
VPADDB Y13, Y0, Y0
|
||||
VPCMPGTB Y0, Y14, Y0
|
||||
VPAND Y2, Y0, Y0
|
||||
VPAND Y15, Y0, Y0
|
||||
VPSLLW $0x05, Y0, Y0
|
||||
VPCMPEQB Y1, Y0, Y0
|
||||
ADDQ $0x20, AX
|
||||
SUBQ $0x20, DX
|
||||
VPMOVMSKB Y0, SI
|
||||
XORL $0xffffffff, SI
|
||||
JNE done
|
||||
|
||||
cmp16:
|
||||
CMPQ DX, $0x10
|
||||
JLE cmp_tail
|
||||
VMOVDQU (CX)(AX*1), X0
|
||||
VMOVDQU (BX)(AX*1), X1
|
||||
VXORPD X0, X1, X1
|
||||
VPCMPEQB X12, X1, X2
|
||||
VORPD X12, X0, X0
|
||||
VPADDB X13, X0, X0
|
||||
VPCMPGTB X0, X14, X0
|
||||
VPAND X2, X0, X0
|
||||
VPAND X15, X0, X0
|
||||
VPSLLW $0x05, X0, X0
|
||||
VPCMPEQB X1, X0, X0
|
||||
ADDQ $0x10, AX
|
||||
SUBQ $0x10, DX
|
||||
VPMOVMSKB X0, SI
|
||||
XORL $0x0000ffff, SI
|
||||
JNE done
|
||||
|
||||
cmp_tail:
|
||||
SUBQ $0x10, DX
|
||||
ADDQ DX, AX
|
||||
VMOVDQU (CX)(AX*1), X0
|
||||
VMOVDQU (BX)(AX*1), X1
|
||||
VXORPD X0, X1, X1
|
||||
VPCMPEQB X12, X1, X2
|
||||
VORPD X12, X0, X0
|
||||
VPADDB X13, X0, X0
|
||||
VPCMPGTB X0, X14, X0
|
||||
VPAND X2, X0, X0
|
||||
VPAND X15, X0, X0
|
||||
VPSLLW $0x05, X0, X0
|
||||
VPCMPEQB X1, X0, X0
|
||||
VPMOVMSKB X0, AX
|
||||
XORL $0x0000ffff, AX
|
||||
JMP done
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
//go:build purego || !amd64
|
||||
// +build purego !amd64
|
||||
|
||||
package ascii
|
||||
|
||||
// EqualFoldString is a version of strings.EqualFold designed to work on ASCII
|
||||
// input instead of UTF-8.
|
||||
//
|
||||
// When the program has guarantees that the input is composed of ASCII
|
||||
// characters only, it allows for greater optimizations.
|
||||
func EqualFoldString(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
var cmp byte
|
||||
|
||||
for len(a) >= 8 {
|
||||
cmp |= lowerCase[a[0]] ^ lowerCase[b[0]]
|
||||
cmp |= lowerCase[a[1]] ^ lowerCase[b[1]]
|
||||
cmp |= lowerCase[a[2]] ^ lowerCase[b[2]]
|
||||
cmp |= lowerCase[a[3]] ^ lowerCase[b[3]]
|
||||
cmp |= lowerCase[a[4]] ^ lowerCase[b[4]]
|
||||
cmp |= lowerCase[a[5]] ^ lowerCase[b[5]]
|
||||
cmp |= lowerCase[a[6]] ^ lowerCase[b[6]]
|
||||
cmp |= lowerCase[a[7]] ^ lowerCase[b[7]]
|
||||
|
||||
if cmp != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
a = a[8:]
|
||||
b = b[8:]
|
||||
}
|
||||
|
||||
switch len(a) {
|
||||
case 7:
|
||||
cmp |= lowerCase[a[6]] ^ lowerCase[b[6]]
|
||||
fallthrough
|
||||
case 6:
|
||||
cmp |= lowerCase[a[5]] ^ lowerCase[b[5]]
|
||||
fallthrough
|
||||
case 5:
|
||||
cmp |= lowerCase[a[4]] ^ lowerCase[b[4]]
|
||||
fallthrough
|
||||
case 4:
|
||||
cmp |= lowerCase[a[3]] ^ lowerCase[b[3]]
|
||||
fallthrough
|
||||
case 3:
|
||||
cmp |= lowerCase[a[2]] ^ lowerCase[b[2]]
|
||||
fallthrough
|
||||
case 2:
|
||||
cmp |= lowerCase[a[1]] ^ lowerCase[b[1]]
|
||||
fallthrough
|
||||
case 1:
|
||||
cmp |= lowerCase[a[0]] ^ lowerCase[b[0]]
|
||||
}
|
||||
|
||||
return cmp == 0
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package ascii
|
||||
|
||||
import "github.com/segmentio/asm/internal/unsafebytes"
|
||||
|
||||
// Valid returns true if b contains only ASCII characters.
|
||||
func Valid(b []byte) bool {
|
||||
return ValidString(unsafebytes.String(b))
|
||||
}
|
||||
|
||||
// ValidBytes returns true if b is an ASCII character.
|
||||
func ValidByte(b byte) bool {
|
||||
return b <= 0x7f
|
||||
}
|
||||
|
||||
// ValidBytes returns true if b is an ASCII character.
|
||||
func ValidRune(r rune) bool {
|
||||
return r <= 0x7f
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Code generated by command: go run valid_asm.go -pkg ascii -out ../ascii/valid_amd64.s -stubs ../ascii/valid_amd64.go. DO NOT EDIT.
|
||||
|
||||
//go:build !purego
|
||||
// +build !purego
|
||||
|
||||
package ascii
|
||||
|
||||
// ValidString returns true if s contains only ASCII characters.
|
||||
func ValidString(s string) bool
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
// Code generated by command: go run valid_asm.go -pkg ascii -out ../ascii/valid_amd64.s -stubs ../ascii/valid_amd64.go. DO NOT EDIT.
|
||||
|
||||
//go:build !purego
|
||||
// +build !purego
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// func ValidString(s string) bool
|
||||
// Requires: AVX, AVX2, SSE4.1
|
||||
TEXT ·ValidString(SB), NOSPLIT, $0-17
|
||||
MOVQ s_base+0(FP), AX
|
||||
MOVQ s_len+8(FP), CX
|
||||
MOVQ $0x8080808080808080, DX
|
||||
CMPQ CX, $0x10
|
||||
JB cmp8
|
||||
BTL $0x08, github·com∕segmentio∕asm∕cpu·X86+0(SB)
|
||||
JCS init_avx
|
||||
|
||||
cmp8:
|
||||
CMPQ CX, $0x08
|
||||
JB cmp4
|
||||
TESTQ DX, (AX)
|
||||
JNZ invalid
|
||||
ADDQ $0x08, AX
|
||||
SUBQ $0x08, CX
|
||||
JMP cmp8
|
||||
|
||||
cmp4:
|
||||
CMPQ CX, $0x04
|
||||
JB cmp3
|
||||
TESTL $0x80808080, (AX)
|
||||
JNZ invalid
|
||||
ADDQ $0x04, AX
|
||||
SUBQ $0x04, CX
|
||||
|
||||
cmp3:
|
||||
CMPQ CX, $0x03
|
||||
JB cmp2
|
||||
MOVWLZX (AX), CX
|
||||
MOVBLZX 2(AX), AX
|
||||
SHLL $0x10, AX
|
||||
ORL CX, AX
|
||||
TESTL $0x80808080, AX
|
||||
JMP done
|
||||
|
||||
cmp2:
|
||||
CMPQ CX, $0x02
|
||||
JB cmp1
|
||||
TESTW $0x8080, (AX)
|
||||
JMP done
|
||||
|
||||
cmp1:
|
||||
CMPQ CX, $0x00
|
||||
JE done
|
||||
TESTB $0x80, (AX)
|
||||
|
||||
done:
|
||||
SETEQ ret+16(FP)
|
||||
RET
|
||||
|
||||
invalid:
|
||||
MOVB $0x00, ret+16(FP)
|
||||
RET
|
||||
|
||||
init_avx:
|
||||
PINSRQ $0x00, DX, X4
|
||||
VPBROADCASTQ X4, Y4
|
||||
|
||||
cmp256:
|
||||
CMPQ CX, $0x00000100
|
||||
JB cmp128
|
||||
VMOVDQU (AX), Y0
|
||||
VPOR 32(AX), Y0, Y0
|
||||
VMOVDQU 64(AX), Y1
|
||||
VPOR 96(AX), Y1, Y1
|
||||
VMOVDQU 128(AX), Y2
|
||||
VPOR 160(AX), Y2, Y2
|
||||
VMOVDQU 192(AX), Y3
|
||||
VPOR 224(AX), Y3, Y3
|
||||
VPOR Y1, Y0, Y0
|
||||
VPOR Y3, Y2, Y2
|
||||
VPOR Y2, Y0, Y0
|
||||
VPTEST Y0, Y4
|
||||
JNZ invalid
|
||||
ADDQ $0x00000100, AX
|
||||
SUBQ $0x00000100, CX
|
||||
JMP cmp256
|
||||
|
||||
cmp128:
|
||||
CMPQ CX, $0x80
|
||||
JB cmp64
|
||||
VMOVDQU (AX), Y0
|
||||
VPOR 32(AX), Y0, Y0
|
||||
VMOVDQU 64(AX), Y1
|
||||
VPOR 96(AX), Y1, Y1
|
||||
VPOR Y1, Y0, Y0
|
||||
VPTEST Y0, Y4
|
||||
JNZ invalid
|
||||
ADDQ $0x80, AX
|
||||
SUBQ $0x80, CX
|
||||
|
||||
cmp64:
|
||||
CMPQ CX, $0x40
|
||||
JB cmp32
|
||||
VMOVDQU (AX), Y0
|
||||
VPOR 32(AX), Y0, Y0
|
||||
VPTEST Y0, Y4
|
||||
JNZ invalid
|
||||
ADDQ $0x40, AX
|
||||
SUBQ $0x40, CX
|
||||
|
||||
cmp32:
|
||||
CMPQ CX, $0x20
|
||||
JB cmp16
|
||||
VPTEST (AX), Y4
|
||||
JNZ invalid
|
||||
ADDQ $0x20, AX
|
||||
SUBQ $0x20, CX
|
||||
|
||||
cmp16:
|
||||
CMPQ CX, $0x10
|
||||
JLE cmp_tail
|
||||
VPTEST (AX), X4
|
||||
JNZ invalid
|
||||
ADDQ $0x10, AX
|
||||
SUBQ $0x10, CX
|
||||
|
||||
cmp_tail:
|
||||
SUBQ $0x10, CX
|
||||
ADDQ CX, AX
|
||||
VPTEST (AX), X4
|
||||
JMP done
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
//go:build purego || !amd64
|
||||
// +build purego !amd64
|
||||
|
||||
package ascii
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ValidString returns true if s contains only ASCII characters.
|
||||
func ValidString(s string) bool {
|
||||
p := *(*unsafe.Pointer)(unsafe.Pointer(&s))
|
||||
i := uintptr(0)
|
||||
n := uintptr(len(s))
|
||||
|
||||
for i+8 <= n {
|
||||
if (*(*uint64)(unsafe.Pointer(uintptr(p) + i)) & 0x8080808080808080) != 0 {
|
||||
return false
|
||||
}
|
||||
i += 8
|
||||
}
|
||||
|
||||
if i+4 <= n {
|
||||
if (*(*uint32)(unsafe.Pointer(uintptr(p) + i)) & 0x80808080) != 0 {
|
||||
return false
|
||||
}
|
||||
i += 4
|
||||
}
|
||||
|
||||
if i == n {
|
||||
return true
|
||||
}
|
||||
|
||||
p = unsafe.Pointer(uintptr(p) + i)
|
||||
|
||||
var x uint32
|
||||
switch n - i {
|
||||
case 3:
|
||||
x = uint32(*(*uint16)(p)) | uint32(*(*uint8)(unsafe.Pointer(uintptr(p) + 2)))<<16
|
||||
case 2:
|
||||
x = uint32(*(*uint16)(p))
|
||||
case 1:
|
||||
x = uint32(*(*uint8)(p))
|
||||
default:
|
||||
return true
|
||||
}
|
||||
return (x & 0x80808080) == 0
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package ascii
|
||||
|
||||
import "github.com/segmentio/asm/internal/unsafebytes"
|
||||
|
||||
// ValidPrint returns true if b contains only printable ASCII characters.
|
||||
func ValidPrint(b []byte) bool {
|
||||
return ValidPrintString(unsafebytes.String(b))
|
||||
}
|
||||
|
||||
// ValidPrintBytes returns true if b is an ASCII character.
|
||||
func ValidPrintByte(b byte) bool {
|
||||
return 0x20 <= b && b <= 0x7e
|
||||
}
|
||||
|
||||
// ValidPrintBytes returns true if b is an ASCII character.
|
||||
func ValidPrintRune(r rune) bool {
|
||||
return 0x20 <= r && r <= 0x7e
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Code generated by command: go run valid_print_asm.go -pkg ascii -out ../ascii/valid_print_amd64.s -stubs ../ascii/valid_print_amd64.go. DO NOT EDIT.
|
||||
|
||||
//go:build !purego
|
||||
// +build !purego
|
||||
|
||||
package ascii
|
||||
|
||||
// ValidPrintString returns true if s contains only printable ASCII characters.
|
||||
func ValidPrintString(s string) bool
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
// Code generated by command: go run valid_print_asm.go -pkg ascii -out ../ascii/valid_print_amd64.s -stubs ../ascii/valid_print_amd64.go. DO NOT EDIT.
|
||||
|
||||
//go:build !purego
|
||||
// +build !purego
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// func ValidPrintString(s string) bool
|
||||
// Requires: AVX, AVX2, SSE4.1
|
||||
TEXT ·ValidPrintString(SB), NOSPLIT, $0-17
|
||||
MOVQ s_base+0(FP), AX
|
||||
MOVQ s_len+8(FP), CX
|
||||
CMPQ CX, $0x10
|
||||
JB init_x86
|
||||
BTL $0x08, github·com∕segmentio∕asm∕cpu·X86+0(SB)
|
||||
JCS init_avx
|
||||
|
||||
init_x86:
|
||||
CMPQ CX, $0x08
|
||||
JB cmp4
|
||||
MOVQ $0xdfdfdfdfdfdfdfe0, DX
|
||||
MOVQ $0x0101010101010101, BX
|
||||
MOVQ $0x8080808080808080, SI
|
||||
|
||||
cmp8:
|
||||
MOVQ (AX), DI
|
||||
MOVQ DI, R8
|
||||
LEAQ (DI)(DX*1), R9
|
||||
NOTQ R8
|
||||
ANDQ R8, R9
|
||||
LEAQ (DI)(BX*1), R8
|
||||
ORQ R8, DI
|
||||
ORQ R9, DI
|
||||
ADDQ $0x08, AX
|
||||
SUBQ $0x08, CX
|
||||
TESTQ SI, DI
|
||||
JNE done
|
||||
CMPQ CX, $0x08
|
||||
JB cmp4
|
||||
JMP cmp8
|
||||
|
||||
cmp4:
|
||||
CMPQ CX, $0x04
|
||||
JB cmp3
|
||||
MOVL (AX), DX
|
||||
MOVL DX, BX
|
||||
LEAL 3755991008(DX), SI
|
||||
NOTL BX
|
||||
ANDL BX, SI
|
||||
LEAL 16843009(DX), BX
|
||||
ORL BX, DX
|
||||
ORL SI, DX
|
||||
ADDQ $0x04, AX
|
||||
SUBQ $0x04, CX
|
||||
TESTL $0x80808080, DX
|
||||
JNE done
|
||||
|
||||
cmp3:
|
||||
CMPQ CX, $0x03
|
||||
JB cmp2
|
||||
MOVWLZX (AX), DX
|
||||
MOVBLZX 2(AX), AX
|
||||
SHLL $0x10, AX
|
||||
ORL DX, AX
|
||||
ORL $0x20000000, AX
|
||||
JMP final
|
||||
|
||||
cmp2:
|
||||
CMPQ CX, $0x02
|
||||
JB cmp1
|
||||
MOVWLZX (AX), AX
|
||||
ORL $0x20200000, AX
|
||||
JMP final
|
||||
|
||||
cmp1:
|
||||
CMPQ CX, $0x00
|
||||
JE done
|
||||
MOVBLZX (AX), AX
|
||||
ORL $0x20202000, AX
|
||||
|
||||
final:
|
||||
MOVL AX, CX
|
||||
LEAL 3755991008(AX), DX
|
||||
NOTL CX
|
||||
ANDL CX, DX
|
||||
LEAL 16843009(AX), CX
|
||||
ORL CX, AX
|
||||
ORL DX, AX
|
||||
TESTL $0x80808080, AX
|
||||
|
||||
done:
|
||||
SETEQ ret+16(FP)
|
||||
RET
|
||||
|
||||
init_avx:
|
||||
MOVB $0x1f, DL
|
||||
PINSRB $0x00, DX, X8
|
||||
VPBROADCASTB X8, Y8
|
||||
MOVB $0x7e, DL
|
||||
PINSRB $0x00, DX, X9
|
||||
VPBROADCASTB X9, Y9
|
||||
|
||||
cmp128:
|
||||
CMPQ CX, $0x80
|
||||
JB cmp64
|
||||
VMOVDQU (AX), Y0
|
||||
VMOVDQU 32(AX), Y1
|
||||
VMOVDQU 64(AX), Y2
|
||||
VMOVDQU 96(AX), Y3
|
||||
VPCMPGTB Y8, Y0, Y4
|
||||
VPCMPGTB Y9, Y0, Y0
|
||||
VPANDN Y4, Y0, Y0
|
||||
VPCMPGTB Y8, Y1, Y5
|
||||
VPCMPGTB Y9, Y1, Y1
|
||||
VPANDN Y5, Y1, Y1
|
||||
VPCMPGTB Y8, Y2, Y6
|
||||
VPCMPGTB Y9, Y2, Y2
|
||||
VPANDN Y6, Y2, Y2
|
||||
VPCMPGTB Y8, Y3, Y7
|
||||
VPCMPGTB Y9, Y3, Y3
|
||||
VPANDN Y7, Y3, Y3
|
||||
VPAND Y1, Y0, Y0
|
||||
VPAND Y3, Y2, Y2
|
||||
VPAND Y2, Y0, Y0
|
||||
ADDQ $0x80, AX
|
||||
SUBQ $0x80, CX
|
||||
VPMOVMSKB Y0, DX
|
||||
XORL $0xffffffff, DX
|
||||
JNE done
|
||||
JMP cmp128
|
||||
|
||||
cmp64:
|
||||
CMPQ CX, $0x40
|
||||
JB cmp32
|
||||
VMOVDQU (AX), Y0
|
||||
VMOVDQU 32(AX), Y1
|
||||
VPCMPGTB Y8, Y0, Y2
|
||||
VPCMPGTB Y9, Y0, Y0
|
||||
VPANDN Y2, Y0, Y0
|
||||
VPCMPGTB Y8, Y1, Y3
|
||||
VPCMPGTB Y9, Y1, Y1
|
||||
VPANDN Y3, Y1, Y1
|
||||
VPAND Y1, Y0, Y0
|
||||
ADDQ $0x40, AX
|
||||
SUBQ $0x40, CX
|
||||
VPMOVMSKB Y0, DX
|
||||
XORL $0xffffffff, DX
|
||||
JNE done
|
||||
|
||||
cmp32:
|
||||
CMPQ CX, $0x20
|
||||
JB cmp16
|
||||
VMOVDQU (AX), Y0
|
||||
VPCMPGTB Y8, Y0, Y1
|
||||
VPCMPGTB Y9, Y0, Y0
|
||||
VPANDN Y1, Y0, Y0
|
||||
ADDQ $0x20, AX
|
||||
SUBQ $0x20, CX
|
||||
VPMOVMSKB Y0, DX
|
||||
XORL $0xffffffff, DX
|
||||
JNE done
|
||||
|
||||
cmp16:
|
||||
CMPQ CX, $0x10
|
||||
JLE cmp_tail
|
||||
VMOVDQU (AX), X0
|
||||
VPCMPGTB X8, X0, X1
|
||||
VPCMPGTB X9, X0, X0
|
||||
VPANDN X1, X0, X0
|
||||
ADDQ $0x10, AX
|
||||
SUBQ $0x10, CX
|
||||
VPMOVMSKB X0, DX
|
||||
XORL $0x0000ffff, DX
|
||||
JNE done
|
||||
|
||||
cmp_tail:
|
||||
SUBQ $0x10, CX
|
||||
ADDQ CX, AX
|
||||
VMOVDQU (AX), X0
|
||||
VPCMPGTB X8, X0, X1
|
||||
VPCMPGTB X9, X0, X0
|
||||
VPANDN X1, X0, X0
|
||||
VPMOVMSKB X0, DX
|
||||
XORL $0x0000ffff, DX
|
||||
JMP done
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
//go:build purego || !amd64
|
||||
// +build purego !amd64
|
||||
|
||||
package ascii
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// ValidString returns true if s contains only printable ASCII characters.
|
||||
func ValidPrintString(s string) bool {
|
||||
p := *(*unsafe.Pointer)(unsafe.Pointer(&s))
|
||||
i := uintptr(0)
|
||||
n := uintptr(len(s))
|
||||
|
||||
for i+8 <= n {
|
||||
if hasLess64(*(*uint64)(unsafe.Pointer(uintptr(p) + i)), 0x20) || hasMore64(*(*uint64)(unsafe.Pointer(uintptr(p) + i)), 0x7e) {
|
||||
return false
|
||||
}
|
||||
i += 8
|
||||
}
|
||||
|
||||
if i+4 <= n {
|
||||
if hasLess32(*(*uint32)(unsafe.Pointer(uintptr(p) + i)), 0x20) || hasMore32(*(*uint32)(unsafe.Pointer(uintptr(p) + i)), 0x7e) {
|
||||
return false
|
||||
}
|
||||
i += 4
|
||||
}
|
||||
|
||||
if i == n {
|
||||
return true
|
||||
}
|
||||
|
||||
p = unsafe.Pointer(uintptr(p) + i)
|
||||
|
||||
var x uint32
|
||||
switch n - i {
|
||||
case 3:
|
||||
x = 0x20000000 | uint32(*(*uint16)(p)) | uint32(*(*uint8)(unsafe.Pointer(uintptr(p) + 2)))<<16
|
||||
case 2:
|
||||
x = 0x20200000 | uint32(*(*uint16)(p))
|
||||
case 1:
|
||||
x = 0x20202000 | uint32(*(*uint8)(p))
|
||||
default:
|
||||
return true
|
||||
}
|
||||
return !(hasLess32(x, 0x20) || hasMore32(x, 0x7e))
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package base64
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
const (
|
||||
StdPadding rune = base64.StdPadding
|
||||
NoPadding rune = base64.NoPadding
|
||||
|
||||
encodeStd = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
encodeURL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
encodeIMAP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,"
|
||||
|
||||
letterRange = int8('Z' - 'A' + 1)
|
||||
)
|
||||
|
||||
// StdEncoding is the standard base64 encoding, as defined in RFC 4648.
|
||||
var StdEncoding = NewEncoding(encodeStd)
|
||||
|
||||
// URLEncoding is the alternate base64 encoding defined in RFC 4648.
|
||||
// It is typically used in URLs and file names.
|
||||
var URLEncoding = NewEncoding(encodeURL)
|
||||
|
||||
// RawStdEncoding is the standard unpadded base64 encoding defined in RFC 4648 section 3.2.
|
||||
// This is the same as StdEncoding but omits padding characters.
|
||||
var RawStdEncoding = StdEncoding.WithPadding(NoPadding)
|
||||
|
||||
// RawURLEncoding is the unpadded alternate base64 encoding defined in RFC 4648.
|
||||
// This is the same as URLEncoding but omits padding characters.
|
||||
var RawURLEncoding = URLEncoding.WithPadding(NoPadding)
|
||||
|
||||
// NewEncoding returns a new padded Encoding defined by the given alphabet,
|
||||
// which must be a 64-byte string that does not contain the padding character
|
||||
// or CR / LF ('\r', '\n'). Unlike the standard library, the encoding alphabet
|
||||
// cannot be abitrary, and it must follow one of the know standard encoding
|
||||
// variants.
|
||||
//
|
||||
// Required alphabet values:
|
||||
// * [0,26): characters 'A'..'Z'
|
||||
// * [26,52): characters 'a'..'z'
|
||||
// * [52,62): characters '0'..'9'
|
||||
// Flexible alphabet value options:
|
||||
// * RFC 4648, RFC 1421, RFC 2045, RFC 2152, RFC 4880: '+' and '/'
|
||||
// * RFC 4648 URI: '-' and '_'
|
||||
// * RFC 3501: '+' and ','
|
||||
//
|
||||
// The resulting Encoding uses the default padding character ('='), which may
|
||||
// be changed or disabled via WithPadding. The padding characters is urestricted,
|
||||
// but it must be a character outside of the encoder alphabet.
|
||||
func NewEncoding(encoder string) *Encoding {
|
||||
if len(encoder) != 64 {
|
||||
panic("encoding alphabet is not 64-bytes long")
|
||||
}
|
||||
|
||||
if _, ok := allowedEncoding[encoder]; !ok {
|
||||
panic("non-standard encoding alphabets are not supported")
|
||||
}
|
||||
|
||||
return newEncoding(encoder)
|
||||
}
|
||||
|
||||
var allowedEncoding = map[string]struct{}{
|
||||
encodeStd: {},
|
||||
encodeURL: {},
|
||||
encodeIMAP: {},
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
//go:build amd64 && !purego
|
||||
// +build amd64,!purego
|
||||
|
||||
package base64
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/segmentio/asm/cpu"
|
||||
"github.com/segmentio/asm/cpu/x86"
|
||||
"github.com/segmentio/asm/internal/unsafebytes"
|
||||
)
|
||||
|
||||
// An Encoding is a radix 64 encoding/decoding scheme, defined by a
|
||||
// 64-character alphabet.
|
||||
type Encoding struct {
|
||||
enc func(dst []byte, src []byte, lut *int8) (int, int)
|
||||
enclut [32]int8
|
||||
|
||||
dec func(dst []byte, src []byte, lut *int8) (int, int)
|
||||
declut [48]int8
|
||||
|
||||
base *base64.Encoding
|
||||
}
|
||||
|
||||
const (
|
||||
minEncodeLen = 28
|
||||
minDecodeLen = 45
|
||||
)
|
||||
|
||||
func newEncoding(encoder string) *Encoding {
|
||||
e := &Encoding{base: base64.NewEncoding(encoder)}
|
||||
if cpu.X86.Has(x86.AVX2) {
|
||||
e.enableEncodeAVX2(encoder)
|
||||
e.enableDecodeAVX2(encoder)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Encoding) enableEncodeAVX2(encoder string) {
|
||||
// Translate values 0..63 to the Base64 alphabet. There are five sets:
|
||||
//
|
||||
// From To Add Index Example
|
||||
// [0..25] [65..90] +65 0 ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||
// [26..51] [97..122] +71 1 abcdefghijklmnopqrstuvwxyz
|
||||
// [52..61] [48..57] -4 [2..11] 0123456789
|
||||
// [62] [43] -19 12 +
|
||||
// [63] [47] -16 13 /
|
||||
tab := [32]int8{int8(encoder[0]), int8(encoder[letterRange]) - letterRange}
|
||||
for i, ch := range encoder[2*letterRange:] {
|
||||
tab[2+i] = int8(ch) - 2*letterRange - int8(i)
|
||||
}
|
||||
|
||||
e.enc = encodeAVX2
|
||||
e.enclut = tab
|
||||
}
|
||||
|
||||
func (e *Encoding) enableDecodeAVX2(encoder string) {
|
||||
c62, c63 := int8(encoder[62]), int8(encoder[63])
|
||||
url := c63 == '_'
|
||||
if url {
|
||||
c63 = '/'
|
||||
}
|
||||
|
||||
// Translate values from the Base64 alphabet using five sets. Values outside
|
||||
// of these ranges are considered invalid:
|
||||
//
|
||||
// From To Add Index Example
|
||||
// [47] [63] +16 1 /
|
||||
// [43] [62] +19 2 +
|
||||
// [48..57] [52..61] +4 3 0123456789
|
||||
// [65..90] [0..25] -65 4,5 ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||
// [97..122] [26..51] -71 6,7 abcdefghijklmnopqrstuvwxyz
|
||||
tab := [48]int8{
|
||||
0, 63 - c63, 62 - c62, 4, -65, -65, -71, -71,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x15, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
|
||||
0x11, 0x11, 0x13, 0x1B, 0x1B, 0x1B, 0x1B, 0x1B,
|
||||
}
|
||||
tab[(c62&15)+16] = 0x1A
|
||||
tab[(c63&15)+16] = 0x1A
|
||||
|
||||
if url {
|
||||
e.dec = decodeAVX2URI
|
||||
} else {
|
||||
e.dec = decodeAVX2
|
||||
}
|
||||
e.declut = tab
|
||||
}
|
||||
|
||||
// WithPadding creates a duplicate Encoding updated with a specified padding
|
||||
// character, or NoPadding to disable padding. The padding character must not
|
||||
// be contained in the encoding alphabet, must not be '\r' or '\n', and must
|
||||
// be no greater than '\xFF'.
|
||||
func (enc Encoding) WithPadding(padding rune) *Encoding {
|
||||
enc.base = enc.base.WithPadding(padding)
|
||||
return &enc
|
||||
}
|
||||
|
||||
// Strict creates a duplicate encoding updated with strict decoding enabled.
|
||||
// This requires that trailing padding bits are zero.
|
||||
func (enc Encoding) Strict() *Encoding {
|
||||
enc.base = enc.base.Strict()
|
||||
return &enc
|
||||
}
|
||||
|
||||
// Encode encodes src using the defined encoding alphabet.
|
||||
// This will write EncodedLen(len(src)) bytes to dst.
|
||||
func (enc *Encoding) Encode(dst, src []byte) {
|
||||
if len(src) >= minEncodeLen && enc.enc != nil {
|
||||
d, s := enc.enc(dst, src, &enc.enclut[0])
|
||||
dst = dst[d:]
|
||||
src = src[s:]
|
||||
}
|
||||
enc.base.Encode(dst, src)
|
||||
}
|
||||
|
||||
// Encode encodes src using the encoding enc, writing
|
||||
// EncodedLen(len(src)) bytes to dst.
|
||||
func (enc *Encoding) EncodeToString(src []byte) string {
|
||||
buf := make([]byte, enc.base.EncodedLen(len(src)))
|
||||
enc.Encode(buf, src)
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// EncodedLen calculates the base64-encoded byte length for a message
|
||||
// of length n.
|
||||
func (enc *Encoding) EncodedLen(n int) int {
|
||||
return enc.base.EncodedLen(n)
|
||||
}
|
||||
|
||||
// Decode decodes src using the defined encoding alphabet.
|
||||
// This will write DecodedLen(len(src)) bytes to dst and return the number of
|
||||
// bytes written.
|
||||
func (enc *Encoding) Decode(dst, src []byte) (n int, err error) {
|
||||
var d, s int
|
||||
if len(src) >= minDecodeLen && enc.dec != nil {
|
||||
d, s = enc.dec(dst, src, &enc.declut[0])
|
||||
dst = dst[d:]
|
||||
src = src[s:]
|
||||
}
|
||||
n, err = enc.base.Decode(dst, src)
|
||||
n += d
|
||||
return
|
||||
}
|
||||
|
||||
// DecodeString decodes the base64 encoded string s, returns the decoded
|
||||
// value as bytes.
|
||||
func (enc *Encoding) DecodeString(s string) ([]byte, error) {
|
||||
src := unsafebytes.BytesOf(s)
|
||||
dst := make([]byte, enc.base.DecodedLen(len(s)))
|
||||
n, err := enc.Decode(dst, src)
|
||||
return dst[:n], err
|
||||
}
|
||||
|
||||
// DecodedLen calculates the decoded byte length for a base64-encoded message
|
||||
// of length n.
|
||||
func (enc *Encoding) DecodedLen(n int) int {
|
||||
return enc.base.DecodedLen(n)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user