8 Commits
Author SHA1 Message Date
cyrilixandClaude Sonnet 5 04770492d5 fix: anchor the .dockerignore binary pattern
The previous commit renamed .Dockerignore to .dockerignore but missed
staging this content fix: an unanchored "domogeek" pattern excludes any
path named domogeek anywhere in the build context, including the
cmd/domogeek source directory itself, not just the compiled binary at
the repo root.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hu8wwahgecuCGfesTKCfEd
2026-09-05 11:09:47 +02:00
cyrilixandClaude Sonnet 5 bf357015bc build: fix Docker build and switch releases to the homelab Tekton pipeline
The Dockerfile was broken since the MCP endpoint was added: it built a
single file (cmd/domogeek/domogeek.go) instead of the whole package, so
mcp.go's symbols (calendarDayFor, newMCPHandler) were undefined. Fixes
that by building ./cmd/domogeek, pins the builder image to
golang:1.27-alpine (module now requires go 1.25+), and drops the
hardcoded GOOS/GOARCH=amd64 so multi-arch builds compile natively per
platform instead of always producing an amd64 binary.

Also fixes .Dockerignore (wrong case, never actually read by
docker/buildah) by renaming it to .dockerignore, and anchors its
"domogeek" pattern to /domogeek so it excludes only the compiled binary,
not the whole cmd/domogeek source directory (same bug as the .gitignore
fix in an earlier commit).

Removes build-docker.sh: image releases now go through the existing
homelab Tekton pipeline (gitea-docker-build-multiarch), triggered by a
Gitea webhook on tag push, same pattern as other repos (e.g.
dra-devices). Images move from docker.io/cyrilix/domogeek to
git.cyrilix.bzh/cyrilix/domogeek. Documented in CLAUDE.md and README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hu8wwahgecuCGfesTKCfEd
2026-09-05 11:09:28 +02:00
cyrilixandClaude Sonnet 5 383c889cab feat: expose current date and time via HTTP and MCP
Adds GET /datetime and an MCP get_current_datetime tool returning the
current date/time in Europe/Paris, alongside the existing calendar
endpoints. Both share the same CurrentDateTime payload shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hu8wwahgecuCGfesTKCfEd
2026-09-05 10:58:38 +02:00
cyrilixandClaude Sonnet 5 e5468ef654 feat: add MCP endpoint exposing calendar tools over Streamable HTTP
Adds a POST /mcp endpoint (github.com/modelcontextprotocol/go-sdk) with
three tools mirroring the existing /calendar logic: get_calendar_today,
get_calendar_for_date and get_holidays. Bumps go.mod to Go 1.25 (required
by the SDK) and vendors the new dependencies. Also fixes an unanchored
.gitignore rule ("domogeek") that was silently excluding all untracked
files under cmd/domogeek/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hu8wwahgecuCGfesTKCfEd
2026-09-05 10:53:31 +02:00
cyrilix e30c25b198 fix: increate retry attemps of caldav init 2022-09-28 09:11:16 +02:00
cyrilix b8fde634b5 fix: retry caldav init at startup 2022-09-28 09:02:31 +02:00
cyrilix 352dfcf2b6 refactor: manage caldav credentials 2022-06-06 17:59:51 +02:00
cyrilix df5c189743 fix: bad response for holidays from caldav 2022-05-27 11:01:13 +02:00
569 changed files with 89574 additions and 13000 deletions
-2
View File
@@ -1,2 +0,0 @@
domogeek
.git
+2
View File
@@ -0,0 +1,2 @@
/domogeek
.git
+1 -1
View File
@@ -108,5 +108,5 @@ tags
# Binary
domogeek
/domogeek
build/
+105
View File
@@ -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
View File
@@ -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
+27
View File
@@ -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>
+35
View File
@@ -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)
}
}
+37 -9
View File
@@ -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() {
+94
View File
@@ -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)
}
+78
View File
@@ -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")
}
}
+143
View File
@@ -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())
}
})
}
+11 -2
View File
@@ -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
)
+26 -3
View File
@@ -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=
+20 -3
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,37 @@
# {{ .Name }}
[![Release](https://img.shields.io/github/release/avast/retry-go.svg?style=flat-square)](https://github.com/avast/retry-go/releases/latest)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md)
[![Travis](https://img.shields.io/travis/avast/retry-go.svg?style=flat-square)](https://travis-ci.org/avast/retry-go)
[![AppVeyor](https://ci.appveyor.com/api/projects/status/fieg9gon3qlq0a9a?svg=true)](https://ci.appveyor.com/project/JaSei/retry-go)
[![Go Report Card](https://goreportcard.com/badge/github.com/avast/retry-go?style=flat-square)](https://goreportcard.com/report/github.com/avast/retry-go)
[![GoDoc](https://godoc.org/github.com/avast/retry-go?status.svg&style=flat-square)](http://godoc.org/github.com/avast/retry-go)
[![codecov.io](https://codecov.io/github/avast/retry-go/coverage.svg?branch=master)](https://codecov.io/github/avast/retry-go?branch=master)
[![Sourcegraph](https://sourcegraph.com/github.com/avast/retry-go/-/badge.svg)](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
View File
@@ -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
View File
@@ -0,0 +1,3 @@
[[constraint]]
name = "github.com/stretchr/testify"
version = "1.1.4"
+21
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,351 @@
# retry
[![Release](https://img.shields.io/github/release/avast/retry-go.svg?style=flat-square)](https://github.com/avast/retry-go/releases/latest)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md)
[![Travis](https://img.shields.io/travis/avast/retry-go.svg?style=flat-square)](https://travis-ci.org/avast/retry-go)
[![AppVeyor](https://ci.appveyor.com/api/projects/status/fieg9gon3qlq0a9a?svg=true)](https://ci.appveyor.com/project/JaSei/retry-go)
[![Go Report Card](https://goreportcard.com/badge/github.com/avast/retry-go?style=flat-square)](https://goreportcard.com/report/github.com/avast/retry-go)
[![GoDoc](https://godoc.org/github.com/avast/retry-go?status.svg&style=flat-square)](http://godoc.org/github.com/avast/retry-go)
[![codecov.io](https://codecov.io/github/avast/retry-go/coverage.svg?branch=master)](https://codecov.io/github/avast/retry-go?branch=master)
[![Sourcegraph](https://sourcegraph.com/github.com/avast/retry-go/-/badge.svg)](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
View File
@@ -0,0 +1 @@
2.7.0
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
})
}
@@ -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
View File
@@ -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
View File
@@ -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
}
@@ -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)
}
@@ -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
View File
@@ -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
View File
@@ -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 }
@@ -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)
@@ -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
}
@@ -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
}
@@ -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
View File
@@ -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
View File
@@ -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()
}
@@ -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
View File
@@ -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
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
+108
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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
View File
@@ -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&notification != 0 && req.IsCall() {
return methodInfo{}, fmt.Errorf("%w: unexpected id for %q", jsonrpc2.ErrInvalidRequest, req.Method)
}
if info.flags&notification == 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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+231
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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, &params); 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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, &regResponse); 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(&regResponse.ClientRegistrationMetadata); err != nil {
return nil, err
}
return &regResponse, nil
}
if resp.StatusCode == http.StatusBadRequest {
var regError ClientRegistrationError
if err := internaljson.Unmarshal(body, &regError); err != nil {
return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body))
}
return nil, &regError
}
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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·comsegmentioasmcpu·X86+0(SB)
JCS init_avx
init_x86:
LEAQ github·comsegmentioasmascii·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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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·comsegmentioasmcpu·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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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·comsegmentioasmcpu·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
View File
@@ -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
View File
@@ -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
View File
@@ -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