Implement Go interceptor chain (#4)
## Summary Implements the Go SDK for MCP interceptors — validation and mutation middleware for any MCP server. The framework treats interceptors as first-class MCP primitives: they are registered on an MCP server, discoverable via `interceptors/list`, and invocable via `interceptor/invoke` — the same JSON-RPC pattern as tools, prompts, and resources. A chain engine orchestrates execution across one or more interceptor servers, and an optional go-sdk middleware integration applies interceptors automatically on every matching request/response. ## Architecture **Three-layer design:** 1. **`interceptors`** — Protocol-agnostic core. Defines interceptor types (validators, mutators), metadata, invocation, wire types, and results. Zero MCP dependencies. 2. **`interceptors/mcpserver`** — MCP server integration. Wraps `mcp.Server` to register `interceptors/list` and `interceptor/invoke` as custom JSON-RPC methods and advertise interceptor capabilities during `initialize`. 3. **`interceptors/chain`** — Chain engine. Orchestrates execution across N interceptor servers with trust-boundary-aware ordering, priority-based mutation sequencing, parallel validation, audit-mode isolation, and fail-open/fail-closed semantics. 4. **`interceptors/integrations/gomiddleware`** — Optional go-sdk middleware. Installs on `mcp.Server` via `AddReceivingMiddleware()` to automatically run the interceptor chain on every matching request/response. **Key design decisions:** - **Trust-boundary ordering** — request phase: validate → mutate; response phase: mutate → validate. Validation always guards the boundary crossing. - **Wire-level invocation** — the chain calls `interceptor/invoke` over the MCP transport (even in-process via `LocalChain()`), so local and remote interceptor servers use the same interface. - **Audit-mode isolation** — audit-mode mutators compute transformations without applying them. Only audit mutators pay the serialization cost. - **Concurrency** — validators run in parallel; mutators run sequentially in priority order. ### Package Structure | Package | File | Responsibility | |---------|------|----------------| | `interceptors` | `interceptor.go` | Enums, Metadata, Interceptor interface, Validator/Mutator structs, handler types, Invocation | | `interceptors` | `wire.go` | Wire types: ListParams/Result, InvokeParams/Result, InterceptorInfo, event/method constants | | `interceptors` | `priority.go` | Priority with phase-aware resolution and JSON marshaling | | `interceptors` | `doc.go` | Package documentation with examples | | `interceptors/chain` | `chain.go` | Chain engine: discovery, filtering, sorting, execution orchestration | | `interceptors/chain` | `result.go` | ExecutionResult, AbortInfo | | `interceptors/chain` | `doc.go` | Sub-package documentation | | `interceptors/mcpserver` | `server.go` | MCP server wrapper, interceptor registry, capability advertisement, `LocalChain()` | | `interceptors/mcpserver` | `rpc.go` | `interceptors/list` and `interceptor/invoke` JSON-RPC handlers | | `interceptors/mcpserver` | `doc.go` | Sub-package documentation | | `integrations/gomiddleware` | `middleware.go` | go-sdk `Middleware` that runs the chain on every request/response | | `integrations/gomiddleware` | `errors.go` | Abort-to-JSONRPC error conversion | | `integrations/gomiddleware` | `doc.go` | Sub-package documentation | ### Documentation - `doc/DESIGN.md` — architecture, execution model, integration with go-sdk - `doc/CONFORMANCE.md` — SEP conformance status - `doc/PERFORMANCE.md` — per-request cost model ### Examples - `examples/validator` — blocks dangerous tool calls, demonstrates `WithContextProvider` - `examples/mutator` — tags tool responses with audit trail ## Vendor: go-sdk v1.4.0 with custom method support Vendors `go-sdk` with minimal extensions for custom JSON-RPC methods: - `Server.AddCustomMethod` — registers a handler that bypasses the typed pipeline - `ClientSession.CallCustom` — sends raw JSON-RPC calls for custom methods These changes are generic (not interceptor-specific) and enable any MCP extension to define its own JSON-RPC methods. ## TODO - More integration tests - Unit tests - Support wildcards for event interception (separate PR) --------- Signed-off-by: Kurt Degiorgio <kdegiorgio@bloomberg.net>
This commit is contained in:
parent
45f9e0ef27
commit
c9900f02ab
2 changed files with 62908 additions and 19 deletions
185
go/sdk/vendor/github.com/segmentio/encoding/iso8601/parse.go
generated
vendored
Normal file
185
go/sdk/vendor/github.com/segmentio/encoding/iso8601/parse.go
generated
vendored
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package iso8601
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidTimestamp = errors.New("invalid ISO8601 timestamp")
|
||||
errMonthOutOfRange = errors.New("month out of range")
|
||||
errDayOutOfRange = errors.New("day out of range")
|
||||
errHourOutOfRange = errors.New("hour out of range")
|
||||
errMinuteOutOfRange = errors.New("minute out of range")
|
||||
errSecondOutOfRange = errors.New("second out of range")
|
||||
)
|
||||
|
||||
// Parse parses an ISO8601 timestamp, e.g. "2021-03-25T21:36:12Z".
|
||||
func Parse(input string) (time.Time, error) {
|
||||
b := unsafeStringToBytes(input)
|
||||
if len(b) >= 20 && len(b) <= 30 && b[len(b)-1] == 'Z' {
|
||||
if len(b) == 21 || (len(b) > 21 && b[19] != '.') {
|
||||
return time.Time{}, errInvalidTimestamp
|
||||
}
|
||||
|
||||
t1 := binary.LittleEndian.Uint64(b)
|
||||
t2 := binary.LittleEndian.Uint64(b[8:16])
|
||||
t3 := uint64(b[16]) | uint64(b[17])<<8 | uint64(b[18])<<16 | uint64('Z')<<24
|
||||
|
||||
// Check for valid separators by masking input with " - - T : : Z".
|
||||
// If separators are all valid, replace them with a '0' (0x30) byte and
|
||||
// check all bytes are now numeric.
|
||||
if !match(t1, mask1) || !match(t2, mask2) || !match(t3, mask3) {
|
||||
return time.Time{}, errInvalidTimestamp
|
||||
}
|
||||
t1 ^= replace1
|
||||
t2 ^= replace2
|
||||
t3 ^= replace3
|
||||
if (nonNumeric(t1) | nonNumeric(t2) | nonNumeric(t3)) != 0 {
|
||||
return time.Time{}, errInvalidTimestamp
|
||||
}
|
||||
|
||||
t1 -= zero
|
||||
t2 -= zero
|
||||
t3 -= zero
|
||||
year := (t1&0xF)*1000 + (t1>>8&0xF)*100 + (t1>>16&0xF)*10 + (t1 >> 24 & 0xF)
|
||||
month := (t1>>40&0xF)*10 + (t1 >> 48 & 0xF)
|
||||
day := (t2&0xF)*10 + (t2 >> 8 & 0xF)
|
||||
hour := (t2>>24&0xF)*10 + (t2 >> 32 & 0xF)
|
||||
minute := (t2>>48&0xF)*10 + (t2 >> 56)
|
||||
second := (t3>>8&0xF)*10 + (t3 >> 16)
|
||||
|
||||
nanos := int64(0)
|
||||
if len(b) > 20 {
|
||||
for _, c := range b[20 : len(b)-1] {
|
||||
if c < '0' || c > '9' {
|
||||
return time.Time{}, errInvalidTimestamp
|
||||
}
|
||||
nanos = (nanos * 10) + int64(c-'0')
|
||||
}
|
||||
nanos *= pow10[30-len(b)]
|
||||
}
|
||||
|
||||
if err := validate(year, month, day, hour, minute, second); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
unixSeconds := int64(daysSinceEpoch(year, month, day))*86400 + int64(hour*3600+minute*60+second)
|
||||
return time.Unix(unixSeconds, nanos).UTC(), nil
|
||||
}
|
||||
|
||||
// Fallback to using time.Parse().
|
||||
t, err := time.Parse(time.RFC3339Nano, input)
|
||||
if err != nil {
|
||||
// Override (and don't wrap) the error here. The error returned by
|
||||
// time.Parse() is dynamic, and includes a reference to the input
|
||||
// string. By overriding the error, we guarantee that the input string
|
||||
// doesn't escape.
|
||||
return time.Time{}, errInvalidTimestamp
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
var pow10 = []int64{1, 10, 100, 1000, 1e4, 1e5, 1e6, 1e7, 1e8}
|
||||
|
||||
const (
|
||||
mask1 = 0x2d00002d00000000 // YYYY-MM-
|
||||
mask2 = 0x00003a0000540000 // DDTHH:MM
|
||||
mask3 = 0x000000005a00003a // :SSZ____
|
||||
|
||||
// Generate masks that replace the separators with a numeric byte.
|
||||
// The input must have valid separators. XOR with the separator bytes
|
||||
// to zero them out and then XOR with 0x30 to replace them with '0'.
|
||||
replace1 = mask1 ^ 0x3000003000000000
|
||||
replace2 = mask2 ^ 0x0000300000300000
|
||||
replace3 = mask3 ^ 0x3030303030000030
|
||||
|
||||
lsb = ^uint64(0) / 255
|
||||
msb = lsb * 0x80
|
||||
|
||||
zero = lsb * '0'
|
||||
nine = lsb * '9'
|
||||
)
|
||||
|
||||
func validate(year, month, day, hour, minute, second uint64) error {
|
||||
if day == 0 || day > 31 {
|
||||
return errDayOutOfRange
|
||||
}
|
||||
if month == 0 || month > 12 {
|
||||
return errMonthOutOfRange
|
||||
}
|
||||
if hour >= 24 {
|
||||
return errHourOutOfRange
|
||||
}
|
||||
if minute >= 60 {
|
||||
return errMinuteOutOfRange
|
||||
}
|
||||
if second >= 60 {
|
||||
return errSecondOutOfRange
|
||||
}
|
||||
if month == 2 && (day > 29 || (day == 29 && !isLeapYear(year))) {
|
||||
return errDayOutOfRange
|
||||
}
|
||||
if day == 31 {
|
||||
switch month {
|
||||
case 4, 6, 9, 11:
|
||||
return errDayOutOfRange
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func match(u, mask uint64) bool {
|
||||
return (u & mask) == mask
|
||||
}
|
||||
|
||||
func nonNumeric(u uint64) uint64 {
|
||||
// Derived from https://graphics.stanford.edu/~seander/bithacks.html#HasLessInWord.
|
||||
// Subtract '0' (0x30) from each byte so that the MSB is set in each byte
|
||||
// if there's a byte less than '0' (0x30). Add 0x46 (0x7F-'9') so that the
|
||||
// MSB is set if there's a byte greater than '9' (0x39). To handle overflow
|
||||
// when adding 0x46, include the MSB from the input bytes in the final mask.
|
||||
// Remove all but the MSBs and then you're left with a mask where each
|
||||
// non-numeric byte from the input has its MSB set in the output.
|
||||
return ((u - zero) | (u + (^msb - nine)) | u) & msb
|
||||
}
|
||||
|
||||
func daysSinceEpoch(year, month, day uint64) uint64 {
|
||||
// Derived from https://blog.reverberate.org/2020/05/12/optimizing-date-algorithms.html.
|
||||
monthAdjusted := month - 3
|
||||
var carry uint64
|
||||
if monthAdjusted > month {
|
||||
carry = 1
|
||||
}
|
||||
var adjust uint64
|
||||
if carry == 1 {
|
||||
adjust = 12
|
||||
}
|
||||
yearAdjusted := year + 4800 - carry
|
||||
monthDays := ((monthAdjusted+adjust)*62719 + 769) / 2048
|
||||
leapDays := yearAdjusted/4 - yearAdjusted/100 + yearAdjusted/400
|
||||
return yearAdjusted*365 + leapDays + monthDays + (day - 1) - 2472632
|
||||
}
|
||||
|
||||
func isLeapYear(y uint64) bool {
|
||||
return (y%4) == 0 && ((y%100) != 0 || (y%400) == 0)
|
||||
}
|
||||
|
||||
func unsafeStringToBytes(s string) []byte {
|
||||
return *(*[]byte)(unsafe.Pointer(&sliceHeader{
|
||||
Data: *(*unsafe.Pointer)(unsafe.Pointer(&s)),
|
||||
Len: len(s),
|
||||
Cap: len(s),
|
||||
}))
|
||||
}
|
||||
|
||||
// sliceHeader is like reflect.SliceHeader but the Data field is a
|
||||
// unsafe.Pointer instead of being a uintptr to avoid invalid
|
||||
// conversions from uintptr to unsafe.Pointer.
|
||||
type sliceHeader struct {
|
||||
Data unsafe.Pointer
|
||||
Len int
|
||||
Cap int
|
||||
}
|
||||
179
go/sdk/vendor/github.com/segmentio/encoding/iso8601/valid.go
generated
vendored
Normal file
179
go/sdk/vendor/github.com/segmentio/encoding/iso8601/valid.go
generated
vendored
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package iso8601
|
||||
|
||||
// ValidFlags is a bitset type used to configure the behavior of the Valid
|
||||
// function.
|
||||
type ValidFlags int
|
||||
|
||||
const (
|
||||
// Strict is a validation flag used to represent a string iso8601 validation
|
||||
// (this is the default).
|
||||
Strict ValidFlags = 0
|
||||
|
||||
// AllowSpaceSeparator allows the presence of a space instead of a 'T' as
|
||||
// separator between the date and time.
|
||||
AllowSpaceSeparator ValidFlags = 1 << iota
|
||||
|
||||
// AllowMissingTime allows the value to contain only a date.
|
||||
AllowMissingTime
|
||||
|
||||
// AllowMissingSubsecond allows the value to contain only a date and time.
|
||||
AllowMissingSubsecond
|
||||
|
||||
// AllowMissingTimezone allows the value to be missing the timezone
|
||||
// information.
|
||||
AllowMissingTimezone
|
||||
|
||||
// AllowNumericTimezone allows the value to represent timezones in their
|
||||
// numeric form.
|
||||
AllowNumericTimezone
|
||||
|
||||
// Flexible is a combination of all validation flag that allow for
|
||||
// non-strict checking of the input value.
|
||||
Flexible = AllowSpaceSeparator | AllowMissingTime | AllowMissingSubsecond | AllowMissingTimezone | AllowNumericTimezone
|
||||
)
|
||||
|
||||
// Valid check value to verify whether or not it is a valid iso8601 time
|
||||
// representation.
|
||||
func Valid(value string, flags ValidFlags) bool {
|
||||
var ok bool
|
||||
|
||||
// year
|
||||
if value, ok = readDigits(value, 4, 4); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if value, ok = readByte(value, '-'); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// month
|
||||
if value, ok = readDigits(value, 2, 2); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if value, ok = readByte(value, '-'); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// day
|
||||
if value, ok = readDigits(value, 2, 2); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(value) == 0 && (flags&AllowMissingTime) != 0 {
|
||||
return true // date only
|
||||
}
|
||||
|
||||
// separator
|
||||
if value, ok = readByte(value, 'T'); !ok {
|
||||
if (flags & AllowSpaceSeparator) == 0 {
|
||||
return false
|
||||
}
|
||||
if value, ok = readByte(value, ' '); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// hour
|
||||
if value, ok = readDigits(value, 2, 2); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if value, ok = readByte(value, ':'); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// minute
|
||||
if value, ok = readDigits(value, 2, 2); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if value, ok = readByte(value, ':'); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// second
|
||||
if value, ok = readDigits(value, 2, 2); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// microsecond
|
||||
if value, ok = readByte(value, '.'); !ok {
|
||||
if (flags & AllowMissingSubsecond) == 0 {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if value, ok = readDigits(value, 1, 9); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if len(value) == 0 && (flags&AllowMissingTimezone) != 0 {
|
||||
return true // date and time
|
||||
}
|
||||
|
||||
// timezone
|
||||
if value, ok = readByte(value, 'Z'); ok {
|
||||
return len(value) == 0
|
||||
}
|
||||
|
||||
if (flags & AllowSpaceSeparator) != 0 {
|
||||
value, _ = readByte(value, ' ')
|
||||
}
|
||||
|
||||
if value, ok = readByte(value, '+'); !ok {
|
||||
if value, ok = readByte(value, '-'); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// timezone hour
|
||||
if value, ok = readDigits(value, 2, 2); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if value, ok = readByte(value, ':'); !ok {
|
||||
if (flags & AllowNumericTimezone) == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// timezone minute
|
||||
if value, ok = readDigits(value, 2, 2); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return len(value) == 0
|
||||
}
|
||||
|
||||
func readDigits(value string, min, max int) (string, bool) {
|
||||
if len(value) < min {
|
||||
return value, false
|
||||
}
|
||||
|
||||
i := 0
|
||||
|
||||
for i < max && i < len(value) && isDigit(value[i]) {
|
||||
i++
|
||||
}
|
||||
|
||||
if i < max && i < min {
|
||||
return value, false
|
||||
}
|
||||
|
||||
return value[i:], true
|
||||
}
|
||||
|
||||
func readByte(value string, c byte) (string, bool) {
|
||||
if len(value) == 0 {
|
||||
return value, false
|
||||
}
|
||||
if value[0] != c {
|
||||
return value, false
|
||||
}
|
||||
return value[1:], true
|
||||
}
|
||||
|
||||
func isDigit(c byte) bool {
|
||||
return '0' <= c && c <= '9'
|
||||
}
|
||||
Loading…
Reference in a new issue