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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 77 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Four things here that we did not find elsewhere:
| `audit` | One record per call — who asked, what was decided, what came out |

Dependencies run strictly downward: `mcpkit` → `mcp`; `doc` → `mcp`; `mcptool` →
`mcp`; `mcptest` → `mcp`; `ratelimit` → `auth`; `audit` → `redact`. Nothing points back up, `mcp`
`mcp`; `mcptest` → `mcp`; `ratelimit` → `auth`, `mcp`; `audit` → `redact`. Nothing points back up, `mcp`
depends on nothing, and `auth` is imported by exactly one package — `ratelimit`,
which keys its buckets on the principal. The server, the dispatcher and the
catalogues never learn who is asking.
Expand Down Expand Up @@ -133,7 +133,9 @@ curl -s localhost:8075/mcp -H 'Authorization: Bearer demo-token' \

One endpoint. `POST` is a JSON-RPC 2.0 request and always gets a synchronous
JSON response; a notification — no `id`, or a null one — is dispatched and
answered with `202` and an empty body. `GET` and `DELETE` are `405` with
answered with `202` and an empty body. Only `notifications/*` go without an id:
any other method without one is a `400`, since its answer would be read by
nobody and its work would run past the rate limiter. `GET` and `DELETE` are `405` with
`Allow: POST`: the server pushes nothing and holds no session, and saying so is
better than opening a stream that dies. A JSON array in the body — a JSON-RPC
batch — is `400`, and a body over 4 MiB is `413`.
Expand Down Expand Up @@ -274,8 +276,11 @@ whoever knows it.

Metrics: `app_mcp_transport_rejected_total{reason}` — `batch`, `parse`,
`too_large`, `read_body`, `dispatch`, `method_not_allowed`, `origin`, `host`,
plus the modern-era refusals `header_mismatch`, `bad_version`, `missing_meta`; and
`app_mcp_requests_total{era}`, which is how you find out whether
plus the modern-era refusals `header_mismatch`, `bad_version`, `missing_meta`, and
`repeated_key` — a member named twice, in any case, in the envelope, its params
or their `_meta`, which this transport and the decoder behind it would read
differently — and `missing_id`, a call other than a notification sent without an
id; and `app_mcp_requests_total{era}`, which is how you find out whether
anything still speaks the old one. A request refused here reaches no handler and
appears in no other series, so without these counters a client that speaks the
wrong dialect is invisible.
Expand Down Expand Up @@ -339,7 +344,38 @@ defer limiter.Stop()
Each limit is switched off by a value ≤ 0; with all four off `Middleware`
returns your handler unwrapped. Only `POST` is counted — the listening `GET`
would hold a concurrency slot for the lifetime of a bridge and drain the bucket
by reconnecting. A denial is `429` with `Retry-After: 10`.
by reconnecting.

A denial is a `429` carrying a JSON-RPC error to the refused call, so a client
can show it as a failed call with a reason rather than as a server that went
away:

```json
{"jsonrpc":"2.0","id":7,"error":{"code":-32010,"message":"rate limit: cost_budget",
"data":{"reason":"cost_budget","retry_after":1847,"budget_used":"30m2s","budget_limit":"30m","window":"1h"}}}
```

`retry_after` and the `Retry-After` header are the same honest wait: until the
window rolls for the budget, until the next token for the rate, a second for a
concurrency slot. The budget fields are there whenever the budget is on. A body
with no id to answer — a notification, a batch, not JSON, too large to look
into — gets the plain-text `429` instead: a refusal reads at most 64 KiB of a
body, so that shedding load costs less than serving it.

A help tool or a static resource is what teaches a caller to spend less, and
once the budget is gone it would be the first thing to stop answering. `Exempt`
spares such calls the budget — only the budget: they still take a rate token
and a concurrency slot.

```go
Exempt: func(method, name string) bool {
return method == "tools/call" && name == "help"
},
```

`name` is what `Mcp-Name` mirrors: the tool, the prompt, the resource URI. With
`Exempt` set and the budget on, the limiter parses every `POST` body; otherwise
only a refused one.

The bucket is keyed by `Principal.UserID`, so the limiter has to run inside the
authentication middleware. Everything is in memory and resets with the process:
Expand All @@ -354,16 +390,48 @@ the work says so:
```go
start := time.Now()
// … one upstream call …
ratelimit.Charge(ctx, time.Since(start))
ratelimit.ChargeFor(ctx, "grafana", time.Since(start))
```

The label is what `app_mcp_ratelimit_charge_seconds` breaks the budget down by,
so "who ate the budget" is a PromQL query rather than an afternoon in the audit
log. It is one series per label: name a target from your own catalogue, never a
value from the request. `Charge(ctx, d)` is the same without a label.

`Charge` is safe to call concurrently and does nothing when limits are off, so a
handler never has to ask whether they are. A request that charges nothing is
priced by its wall clock.

Metrics: `app_mcp_ratelimit_denied_total{reason}` — `rpm`, `user_concurrent`,
`global_concurrent`, `cost_budget` — and `app_mcp_ratelimit_inflight{scope}` with
`user` and `global`.
A handler can also ask what is left, and put it in its answer, so that a long
investigation narrows its queries before it runs into the ceiling rather than
after:

```go
if b, ok := ratelimit.Remaining(ctx); ok {
// b.Used, b.Limit, b.Left(), b.ResetAt
}
```

It is the caller's finished requests in this window plus what this request has
charged so far — a snapshot, which their other requests still running will
change. `ok` is false with the budget off.

Metrics:

| Metric | Labels |
|---|---|
| `app_mcp_ratelimit_denied_total` | `reason`: `rpm`, `user_concurrent`, `global_concurrent`, `cost_budget` |
| `app_mcp_ratelimit_inflight` | `scope`: `user`, `global` |
| `app_mcp_ratelimit_charge_seconds` (histogram, 10ms–60s) | `label`: what you passed to `ChargeFor`, `unlabelled` for `Charge`, `wall_clock` for a request that charged nothing |
| `app_mcp_ratelimit_budget_used_seconds` | `user`: spent in the current window |

The histogram adds up to the budget: exempt calls and a limiter with the budget
off are not in it. The `user` label is `Principal.UserID` as it is — where your
authentication names a user by e-mail, the e-mail is in `/metrics`. The gauge is
set as requests finish, drops to zero when the caller's next request opens a new
window or within fifteen minutes of the old one ending, and goes with an idle
caller's entry — an alert on `used / limit` fires for someone running out, not
for someone who already has it back.

## mcp and doc

Expand Down
27 changes: 9 additions & 18 deletions era.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,27 +158,18 @@ func metaString(v *fastjson.Value, key string) string {
// and always a 400 — every refusal decided before dispatch is one, which is why
// fault carries no status of its own.
//
// The id is echoed exactly as it arrived — "error responses MUST include the
// same ID as the request they correspond to" — and a request whose id could not
// be read gets null, which is what JSON-RPC asks for.
// The envelope is zenrpc's, the one every dispatched error goes out in, so a
// refusal here cannot come out in a shape of its own. The id is echoed exactly
// as it arrived — "error responses MUST include the same ID as the request they
// correspond to" — and a request whose id could not be read gets null, which is
// what JSON-RPC asks for.
func writeFault(w http.ResponseWriter, id *fastjson.Value, f fault) {
body := struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Error struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
} `json:"error"`
}{JSONRPC: "2.0", ID: json.RawMessage("null")}
var raw *json.RawMessage
if id != nil {
body.ID = id.MarshalTo(nil)
b := json.RawMessage(id.MarshalTo(nil))
raw = &b
}
body.Error.Code = f.code
body.Error.Message = f.message
body.Error.Data = f.data

b, err := json.Marshal(body)
b, err := json.Marshal(zenrpc.NewResponseError(raw, f.code, f.message, f.data))
if err != nil { // a fixed shape plus data the caller supplied
http.Error(w, f.message, http.StatusBadRequest)
return
Expand Down
12 changes: 11 additions & 1 deletion example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,17 @@ func (helloTool) Call(ctx context.Context, args map[string]any) mcp.ToolCallResu
if a.Who == "" {
a.Who = "stranger"
}

// A tool that does work upstream says what it cost and where it went: the
// budget is spent by it, and app_mcp_ratelimit_charge_seconds is broken
// down by the label. The label names a target from the service's own
// catalogue, never a value from the request. A greeting costs next to
// nothing; the call is here to show where a real tool puts it.
start := time.Now()
greeting := "hello, " + a.Who
ratelimit.ChargeFor(ctx, "greeter", time.Since(start))

// A struct rather than a map: the same type the output schema was reflected
// from, so the answer cannot drift from what the tool promised.
return mcptool.OKResult(helloResult{Greeting: "hello, " + a.Who})
return mcptool.OKResult(helloResult{Greeting: greeting})
}
69 changes: 69 additions & 0 deletions example/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@ package main

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/vmkteam/mcpkit"
"github.com/vmkteam/mcpkit/doc"
"github.com/vmkteam/mcpkit/mcp"
"github.com/vmkteam/mcpkit/mcptest"
"github.com/vmkteam/mcpkit/mcptool"
"github.com/vmkteam/mcpkit/ratelimit"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vmkteam/embedlog"
"github.com/vmkteam/zenrpc/v2"
)

const testKey = "test-token"
Expand Down Expand Up @@ -132,3 +139,65 @@ func TestExampleRefusesAnUnauthenticatedCall(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, res.Status)
assert.NotEmpty(t, res.Header.Get("WWW-Authenticate"), "a 401 has to say how to authenticate")
}

// A denial has to reach the client as an answer to its call, through the
// transport and in either era — the limiter's own tests stop at a stub
// handler. The example allows sixty calls a minute, so the burst runs out
// within the loop.
func TestExampleRefusalAnswersTheCall(t *testing.T) {
t.Parallel()
for _, era := range []mcptest.Era{mcptest.Modern, mcptest.Legacy} {
t.Run(string(era), func(t *testing.T) {
t.Parallel()
c := serve(t, era)
hello := map[string]any{"name": "hello", "arguments": map[string]any{}}

var res mcptest.Response
for range 70 {
if res = c.Call(t, "tools/call", hello); res.Status != http.StatusOK {
break
}
}
require.Equal(t, http.StatusTooManyRequests, res.Status, "%s", res.Body)
assert.Equal(t, "application/json", res.Header.Get("Content-Type"))
assert.NotEmpty(t, res.Header.Get("Retry-After"))
require.NotNil(t, res.Error, "%s", res.Body)
assert.Equal(t, mcp.CodeRateLimited, res.Error.Code)
assert.Contains(t, string(res.Error.Data), `"reason":"rpm"`)
})
}
}

// An exempt name cannot be borrowed by repeating the key it is read from, in
// the same case or another: the limiter reads the first, the dispatcher runs
// the last. Built here rather
// than through newMCP, because it needs an exemption and a spent budget.
func TestExampleExemptionCannotBeBorrowed(t *testing.T) {
t.Parallel()
zsrv := zenrpc.NewServer(zenrpc.Options{})
zsrv.RegisterAll(map[string]zenrpc.Invoker{
mcpkit.NamespaceTools: ToolsService{registry: mcptool.NewRegistry(helloTool{})},
})
limiter := ratelimit.New(ratelimit.Config{
CostBudgetPerHour: time.Nanosecond, // the first call spends it
Exempt: func(_, name string) bool { return name == "help" },
})
t.Cleanup(limiter.Stop)
h := limiter.Middleware(mcpkit.NewServer(zsrv, embedlog.Logger{}), embedlog.Logger{})

post := func(body string) int {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body)))
return rec.Code
}
hello := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hello","arguments":{}}}`
require.Equal(t, http.StatusOK, post(hello))
require.Equal(t, http.StatusTooManyRequests, post(hello), "the budget is spent")

for _, borrowed := range []string{
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"help","name":"hello","arguments":{}}}`,
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"help","Name":"hello","arguments":{}}}`,
} {
assert.NotEqual(t, http.StatusOK, post(borrowed), "hello ran under help's exemption: %s", borrowed)
}
}
18 changes: 18 additions & 0 deletions internal/metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,24 @@ func (g *Group) Gauge(name, help, label string, values ...string) *prometheus.Ga
return gv
}

// Histogram declares a histogram with one label, and the series it starts at
// zero.
func (g *Group) Histogram(name, help, label string, buckets []float64, values ...string) *prometheus.HistogramVec {
h := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: name,
Help: help,
Buckets: buckets,
}, []string{label})
g.add(h, func() {
for _, v := range values {
h.WithLabelValues(v)
}
})
return h
}

// add collects a metric and how to warm it. It runs while the declaring
// package's var block does, which is before anything can reach Register: the
// slices are unguarded because at that point there is nobody to guard them
Expand Down
12 changes: 12 additions & 0 deletions internal/metrics/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"sync"
"testing"

"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -56,6 +57,17 @@ func TestGaugeStartsItsSeriesAtZero(t *testing.T) {
assert.Equal(t, 2, testutil.CollectAndCount(gauge))
}

func TestHistogramStartsItsSeriesAtZero(t *testing.T) {
t.Parallel()
g := NewGroup()
h := g.Histogram("warmed_seconds", "help.", "label", []float64{1, 10}, "a", "b")

assert.Equal(t, 0, testutil.CollectAndCount(h), "nothing exists before Register")
g.Register()
assert.Equal(t, 2, testutil.CollectAndCount(h), "one series per value, from the start")
assert.Contains(t, h.WithLabelValues("a").(prometheus.Metric).Desc().String(), "app_mcp_warmed_seconds")
}

// Register is called from every entry point that touches a metric, so it is
// reached concurrently and reached often. Registering the same collector twice
// panics, which is what the Once is for.
Expand Down
11 changes: 11 additions & 0 deletions mcp/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ const (
CodeUnsupportedProtocolVersion = -32022
)

// Error codes this library defines itself. JSON-RPC leaves -32000..-32099 to
// the implementation, the spec has taken -32020 upward (above), and that leaves
// -32000..-32019 to us. Nothing in mcpkit or in the services built on it
// emitted a code from there when the first one was taken, so this list is the
// whole of what is in use.
const (
// CodeRateLimited is a request the rate limiter turned away. Its data says
// which limit it was and how many seconds until it is worth asking again.
CodeRateLimited = -32010
)

// The sentinel wrapping a header value that cannot be written as plain ASCII.
const (
base64Prefix = "=?base64?"
Expand Down
4 changes: 2 additions & 2 deletions mcptest/mcptest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,11 @@ func TestOptions(t *testing.T) {
t.Run("client identity and capabilities are the caller's to set", func(t *testing.T) {
t.Parallel()
c, got := recorder(t,
WithClientInfo("ringsrv-test", "2.0"),
WithClientInfo("example-client", "2.0"),
WithClientCapabilities(map[string]any{"roots": map[string]any{}}))
c.Call(t, "tools/list", nil)
meta := got.meta(t)
assert.Equal(t, map[string]any{"name": "ringsrv-test", "version": "2.0"}, meta[mcp.MetaClientInfo])
assert.Equal(t, map[string]any{"name": "example-client", "version": "2.0"}, meta[mcp.MetaClientInfo])
assert.Contains(t, meta[mcp.MetaClientCapabilities], "roots")
})

Expand Down
4 changes: 3 additions & 1 deletion metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const (
reasonHeaderMismatch = "header_mismatch"
reasonBadVersion = "bad_version"
reasonMissingMeta = "missing_meta"
reasonRepeatedKey = "repeated_key"
reasonMissingID = "missing_id"
)

// era labels on app_mcp_requests_total.
Expand All @@ -41,7 +43,7 @@ var (
"reason",
reasonBatch, reasonParse, reasonTooLarge,
reasonReadBody, reasonDispatch, reasonBadMethod, reasonOrigin, reasonHost,
reasonHeaderMismatch, reasonBadVersion, reasonMissingMeta,
reasonHeaderMismatch, reasonBadVersion, reasonMissingMeta, reasonRepeatedKey, reasonMissingID,
)

// Which era clients actually speak. This is the number that decides when
Expand Down
Loading
Loading