Help us shape the future of Go! Take the 2026 Go Developer Survey

vov

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 29, 2026 License: MIT Imports: 26 Imported by: 1

README

vov

A declarative, batteries-included Go backend framework, using standard net/http under the hood. vov holds all the configuration of the server and the handlers via declarations instead of closures.

Features

  • The App object, holding all the config of the application
    • Named middleware stack, reusable on the endpoint level

Example

app, err := vov.NewApp(vov.AppConfig{
    Address: ":8080",

    // Everything belonging to the HTTP channel. ServerWrappers cover every
    // request the server receives, including the 404s and 405s the mux answers
    // itself — and the POST carrying a tool call, but not the call inside it.
    API: vov.APIConfig{
        Authenticator:  authenticate,
        ServerWrappers: []vov.Middleware{recoverPanic(log), cors(origin)},
    },

    // Named middleware combinations, split by the auth seam.
    MiddlewareStacks: map[string]vov.MiddlewareStack{
        vov.DefaultStackName: {
            Pre:  []vov.Middleware{requestID},
            Post: []vov.Middleware{auditLog},
        },
    },

    Routes: []vov.Route{{
        Path: "/investors",
        Endpoints: vov.Endpoints{
            // Says nothing about auth, and is protected anyway.
            GET:  vov.Endpoint{Handler: listInvestors, MinTier: 1},
            POST: vov.Endpoint{Handler: createInvestor, RolesAnyOf: []string{"admin"}},
        },
    }},
})
if err != nil {
    log.Fatal(err) // a bad declaration fails here, not in production
}
log.Fatal(app.Run()) // serves until SIGINT/SIGTERM, then drains

Why declarations

A route's policy written as mux.HandleFunc("GET /investors", needPaid(list)) lives in a closure, and closures do not diff. Written as data it can be rendered:

GET     /investors     auth:required  stack:default  min-tier:1
POST    /investors     auth:required  stack:default  scopes-all:write@mcp  roles-any:admin

Check that file in and a loosened policy becomes a changed line in a pull request. No test tier catches that on its own — generated tests assert against the declaration, so they move with it and stay green.

What's in the box

Routing One Route per URL, methods as named fields — GET, POST, … A URL declared twice is a construction error.
Auth Authenticated by default; AuthModeNone opts out. RolesAnyOf (any-of), PermissionsAllOf (all-of), MinTier (paid, refuses 402 not 403).
Declared inputs BodyOf[T]/QueryOf[T] derive shape from the type the handler decodes into; jsonschema:"…" adds the prose, and PathParams gives a route wildcard a caller-facing name and a description. One declaration feeds the MCP tool schema, OpenAPI, and the manifest.
Scopes ScopeAllOf gates on what the credential was issued for, not what its owner may do — the OAuth axis. Declared per channel, so tokens can govern your MCP tools while browser sessions stay untouched, and the two can differ. Defaults key on method, so a new endpoint is governed the moment it exists.
Middleware Named stacks per endpoint, split Pre/Post around the auth seam; API.ServerWrappers for every HTTP request the server receives.
Config LoadEnv binds env vars onto your struct — required fields, defaults, every problem reported at once, and never a value in an error message.
Shared objects Global[T], a typed write-once holder, so handlers keep the plain net/http signature.
Lifecycle Run handles binding, SIGINT/SIGTERM, draining, and OnShutdown hooks.
Manifest App.Manifest() renders every endpoint and its policy for review.

Fails closed

A declaration that cannot mean what it says is rejected by NewApp, before the server listens — a route requiring auth with no Authenticator, a middleware stack that was never declared, a URL declared twice, roles on an open endpoint. An endpoint that says nothing about auth requires it.

Adopting it

App.Mux() returns the underlying *http.ServeMux, so declared routes and plain mux.HandleFunc calls coexist. A live app can migrate one group at a time and stop wherever it likes.

Example

examples/tasks is a working service exercising all of the above from the outside, as its own module.

Status

Pre-1.0, and the API still moves between minor versions. MIT.

Documentation

Overview

Package vov is a declarative, batteries-included Go backend framework.

The endpoint declaration is the single source of truth. A Route groups every method of one URL, and each Endpoint carries its handler alongside the facts that govern it — which middleware stack wraps it, whether it needs an authenticated user, which roles or permissions or paid tier that user must hold — all as plain data. Everything else is a consumer of that declaration rather than a parallel structure that can drift from it: routing today, and Manifest, generated tests, and an OpenAPI or MCP surface after.

The point of keeping those facts as data is that data can be diffed. A policy that is quietly loosened still passes every test, because the tests assert against the declaration that changed; a checked-in Manifest turns it into a modified line in a pull request, which is the only place a person sees it.

An App assembles those declarations onto a standard http.ServeMux, wraps that mux in APIConfig.ServerWrappers, and serves the result — see App.Handler for what is served and App.Mux for the escape hatch that lets routes vov does not model coexist with the ones it does. It owns the server lifecycle too: binding, SIGINT and SIGTERM, draining, and cleanup hooks.

Two things sit deliberately outside the App, so that neither depends on it: LoadEnv, which binds the process environment onto a config struct before anything is built, and Global, a typed write-once holder for the objects an app builds at boot and reaches from many handlers.

Requests pass through two distinct layers, and the difference is not cosmetic:

API.ServerWrappers  every HTTP request the server receives, including the 404s and
                 405s http.ServeMux answers itself — so CORS preflight,
                 panic recovery, and access logging belong here
MiddlewareStack  only requests that reach an endpoint, split by the auth
                 seam into Pre (outside the guard) and Post (inside it,
                 where the user is known)

Every piece is meant to be droppable. Handlers keep the standard net/http signature, middleware keeps the standard wrapper shape, and the mux underneath is a plain *http.ServeMux — so a codebase can adopt vov one route at a time and stop wherever it likes.

Index

Constants

View Source
const DefaultAddress = ":8080"

DefaultAddress is the listen address used when AppConfig.Address is empty.

View Source
const DefaultShutdownTimeout = 15 * time.Second

DefaultShutdownTimeout bounds how long App.Run waits for in-flight requests and cleanup hooks to finish before giving up.

View Source
const DefaultStackName = "default"

DefaultStackName is the key in AppConfig.MiddlewareStacks whose stack applies to every endpoint that does not name one.

Variables

This section is empty.

Functions

func ContextWithUser added in v0.1.0

func ContextWithUser(ctx context.Context, u User) context.Context

ContextWithUser returns a copy of ctx carrying u. vov calls this for every endpoint that requires auth; apps rarely need it outside tests.

func LoadEnv added in v0.1.0

func LoadEnv(dst any) error

LoadEnv fills dst from the process environment, where dst is a pointer to a struct whose fields carry `env` tags:

type Config struct {
    DatabaseURL string        `env:"DATABASE_URL,required"`
    Port        int           `env:"PORT" envDefault:"8080"`
    Timeout     time.Duration `env:"TIMEOUT" envDefault:"30s"`
    Debug       bool          `env:"DEBUG"`
}

The struct is both the declaration and the access surface, so what a service requires and how it reads it cannot drift apart. Call it before NewApp: the dependencies that need configuration are built before the endpoints that use them.

A field with no `env` tag is left alone, so a config struct may hold values that come from somewhere else. Nested structs are walked. A missing optional variable leaves the field at its zero value; declare the field as a pointer to tell "unset" from "set to the zero value", as Ptr does elsewhere in vov.

An unset variable and one set to the empty string are treated the same: the default applies, and a required variable is reported missing.

Every problem is collected and returned together as an EnvError — one boot failure listing all of them beats fixing one variable per restart.

func Manifest added in v0.1.0

func Manifest(routes []Route, scopes *ScopePolicy) string

Manifest renders routes as the text to check in beside the code.

It exists because of a gap no test tier can cover. Generated tests assert that the code does what the declaration says, so an endpoint whose policy is quietly loosened stays green — the tests move with it. A checked-in manifest turns that same change into a modified line in a pull request, which is the only place a human sees it.

The output is deterministic: routes are sorted by path, and each route's methods appear in a fixed order, so the file changes only when a declaration changes and not when the Routes slice is reordered.

Every line carries its own method and path rather than being grouped under a URL heading, because a diff shows a handful of lines without their surroundings — a changed line has to say for itself which endpoint it is.

The manifest covers declared routes only. Anything registered straight on App.Mux is outside the framework and cannot appear here.

func Ptr added in v0.1.0

func Ptr[T any](v T) *T

Ptr returns a pointer to v. It lets the pointer-typed fields of Server be set inline instead of through a temporary variable:

vov.Server{ReadTimeout: vov.Ptr(30 * time.Second)}

func ScopesFrom added in v0.3.0

func ScopesFrom(ctx context.Context) ([]string, bool)

ScopesFrom returns the scopes the request's credential carried, and whether the credential declared any. A handler on an endpoint behind a scope rule can rely on ok being true.

Types

type APIConfig added in v0.3.0

type APIConfig struct {
	// Authenticator resolves the user an HTTP request acts as. Endpoints require
	// an authenticated user unless they declare [AuthModeNone], so this is
	// required unless every endpoint opts out — [NewApp] rejects a configuration
	// that needs an authenticator and has none, rather than starting a server
	// whose every protected route answers 401.
	//
	// It is per channel because credentials are. A tool call is authenticated by
	// [MCPConfig.Authenticate], which an application may set to this same
	// function when its tool endpoint honours ordinary credentials — and will not
	// when it honours OAuth access tokens bound to an audience, which is a
	// different thing from the cookie a browser sends.
	//
	// The guard calls exactly one of the two, never both: a tool call arrives
	// with its principal already vouched for, so this never runs for it.
	Authenticator Authenticator

	// ServerWrappers wrap the whole assembled handler, outermost first, and
	// therefore see every request the server receives — not only the ones that
	// reach an endpoint. They wrap in both directions: a wrapper observes the
	// response on the way out as well as the request on the way in, which is
	// what makes access logging and panic recovery possible here.
	//
	// That is the difference from MiddlewareStacks, and it is not a shade of
	// meaning. http.ServeMux answers two kinds of request itself, before
	// dispatching to anything registered: a path no route declares (404), and a
	// method the matched path does not declare (405). No endpoint middleware
	// runs for either. A CORS preflight is exactly the second kind — OPTIONS
	// against a path registered for GET and PATCH — so a cross-origin browser
	// app cannot work unless something outside the mux answers it.
	//
	// The same is true of the things an operator most wants to be unconditional:
	// panic recovery, request-id stamping, access logging. An unmatched path
	// that panics or goes unlogged is precisely the request worth a record.
	//
	// Because it wraps the mux, it also covers routes registered straight on
	// [App.Mux]. That is unavoidable rather than chosen: the mux decides its own
	// 404 internally, so anything that can see that decision is necessarily
	// outside everything the mux serves.
	//
	// A wrapper runs before routing, so it cannot know which endpoint matched:
	// no path parameters, no AuthMode, no declared roles. It is also invisible to
	// [Manifest], which documents endpoint policy. Keep authorization out of it —
	// a rule declared here cannot be reviewed there.
	//
	// # A tool call is not one of these
	//
	// The HTTP POST carrying a tool call is wrapped, because it is an HTTP
	// request like any other — [ModeFrom] reports it as [RequestModeAPI], since
	// it reaches no endpoint. The tool call inside it is not: that reaches its
	// endpoint in process, which skips wrappers by design.
	//
	// So access logging, request ids and metering fire once per transport
	// request, not once per call. And panic recovery does not reach a tool
	// handler at all: the protocol SDK dispatches each call on a goroutine of its
	// own, where a wrapper's deferred recover cannot see it. vov recovers those
	// itself and reports them through [MCPConfig.OnToolCall].
	ServerWrappers []Middleware
}

APIConfig is the half of an application's configuration that belongs to its HTTP API and not to its tool interface.

The split exists because the two channels are not the same request twice. A tool call reaches its endpoint in process, so anything wrapped around the server sees the one HTTP request that carried it and never the call itself — a distinction that used to live in a doc comment and now lives in a type name. MCPConfig is the other half, and holds the things with no API counterpart.

Routes are deliberately not here. An endpoint answers both channels from one declaration, which is the property in-process dispatch exists to preserve; only the machinery around a request is channel-specific.

type App added in v0.1.0

type App struct {
	// contains filtered or unexported fields
}

App assembles a set of Endpoint declarations onto a standard http.ServeMux, holds the http.Server it will serve with, and owns the server lifecycle. Construct it with NewApp.

func NewApp added in v0.1.0

func NewApp(cfg AppConfig) (*App, error)

NewApp validates the configuration, builds a mux, registers every handler on it, and resolves the http.Server to serve with. It fails closed: an endpoint with an empty path, a nil handler, or a route that duplicates an earlier one is a construction error, not a boot-time surprise. (Genuinely conflicting — but non-identical — mux patterns are still reported by the underlying http.ServeMux.)

func (*App) Handler added in v0.2.0

func (a *App) Handler() http.Handler

Handler returns what the app serves: the mux wrapped in [AppConfig.ServerWrappers]. This is the handler to hand to httptest, or to mount inside a larger server when App.Run is not being used.

func (*App) MCP added in v0.2.1

func (a *App) MCP() *MCPConfig

MCP returns the app's Model Context Protocol declaration, or nil when it has none. Applications rarely need it.

func (*App) MCPHandler added in v0.3.0

func (a *App) MCPHandler() (http.Handler, error)

MCPHandler returns the http.Handler serving this app's tool-declaring endpoints over MCP, for an app that mounts it itself.

Setting MCPConfig.Path is the usual way and needs none of this: NewApp builds the handler and registers it. Reach for this only to mount the result somewhere vov would not — inside an application's own OAuth gate, for instance, since that gate is not vov's Authenticator. Leave Path empty in that case.

It returns an error if the app declares no AppConfig.MCP.

func (*App) Manifest added in v0.1.0

func (a *App) Manifest() string

Manifest renders the app's routes — see Manifest.

func (*App) Mux added in v0.1.0

func (a *App) Mux() *http.ServeMux

Mux returns the underlying *http.ServeMux, for registering routes vov does not model. Handlers registered on it bypass the framework's endpoint management and do not appear in Manifest.

It is the registration surface, not the serving one: it does not include [AppConfig.ServerWrappers], which wrap it. To exercise the app as it is actually served — in a test, or behind another server — use App.Handler.

func (*App) OnShutdown added in v0.1.0

func (a *App) OnShutdown(fn func(context.Context) error)

OnShutdown registers a cleanup function to run during graceful shutdown, after the HTTP server has stopped accepting connections and in-flight requests have drained. Hooks run in registration order, sharing the shutdown deadline. Safe to call concurrently.

func (*App) Routes added in v0.1.0

func (a *App) Routes() []Route

Routes returns the route declarations the app was built from, in the order they were given. The slice is a copy, so a caller cannot reshape the app's routing by editing it.

func (*App) Run added in v0.1.0

func (a *App) Run() error

Run starts the HTTP server and blocks until an interrupt (SIGINT) or termination (SIGTERM) signal arrives, or the server stops on its own. On signal it stops accepting connections, waits for in-flight requests to drain, and runs the registered shutdown hooks, all bounded by ShutdownTimeout.

type AppConfig added in v0.1.0

type AppConfig struct {
	// Routes are the URLs to serve, in order. Each groups every method of one
	// URL — see [Route]. A URL may be declared only once: two Routes sharing a
	// Path is a construction error, since it would split one URL's definition
	// across two places.
	Routes []Route

	// RequireGlobals are the [Global] holders that must be populated before this
	// app may be built. Listing one turns "forgot to call Set" from a panic on
	// the first request that happens to touch it into an error at construction,
	// before the server listens.
	//
	// vov never reads what a holder contains; it only asks whether it is ready.
	RequireGlobals []Readiness

	// Stacks are the named middleware combinations endpoints can be wrapped in,
	// each split into a Pre and Post half by the auth seam — see
	// [MiddlewareStack]. The stack under [DefaultStackName] applies to every
	// endpoint that does not name another; an endpoint naming one that was never
	// declared is a construction error.
	//
	// Stacks apply to endpoints only. Routes registered straight on [App.Mux]
	// are outside the framework and get no middleware from it.
	MiddlewareStacks map[string]MiddlewareStack

	// API holds the configuration that applies to requests arriving over HTTP,
	// and to nothing else — see [APIConfig].
	API APIConfig

	// Scopes, when set, declares the app-wide scope policy — which channels
	// enforce scopes, and what an endpoint that names none requires. See
	// [ScopePolicy].
	//
	// Its method maps are what keep a new endpoint governed without anyone
	// remembering to declare one: the requirement is keyed by method, so an
	// endpoint added later is covered the moment it exists.
	Scopes *ScopePolicy

	// Address is the TCP listen address, e.g. ":8080". Defaults to
	// [DefaultAddress] when empty.
	Address string

	// ShutdownTimeout bounds graceful shutdown. Defaults to
	// [DefaultShutdownTimeout] when zero.
	ShutdownTimeout time.Duration

	// MCP, when set, describes the Model Context Protocol server this app
	// exposes — see [MCPConfig]. The endpoints it exposes are the ones declaring
	// an [MCPTool]; nothing is restated here.
	//
	// Setting it is enough to serve one: give it a Path and [NewApp] builds the
	// handler from this and the declarations and mounts it. An app that would
	// rather mount it itself takes it from [App.MCPHandler].
	MCP *MCPConfig

	// Server optionally tunes the http.Server vov serves with — timeouts, TLS,
	// connection hooks, byte limits, and so on. It mirrors http.Server but drops
	// Addr and Handler, which vov owns (one place for each, no conflicting
	// values). Its defaulted timeout fields are pointers: a nil field takes vov's
	// default, an explicit value — including 0, meaning "no timeout" — is honored.
	// Nil means all defaults. See [Server].
	Server *Server
}

AppConfig is the declarative input to NewApp. Apart from Handlers, its fields are the app-wide operational preferences; more are added here as they are needed rather than imagined up front.

type AuthMode added in v0.1.0

type AuthMode string

AuthMode says whether an Endpoint needs an authenticated user.

The zero value — an endpoint that says nothing — means AuthModeRequired. An endpoint is opened only by naming AuthModeNone, so forgetting to declare auth protects a route rather than exposing it. Everything here is arranged around that: see [AuthMode.required].

const (
	// AuthModeRequired demands an authenticated user. It is also what the zero
	// value means, so it rarely needs writing.
	AuthModeRequired AuthMode = "required"

	// AuthModeNone opts out: no user is resolved and none is required. Reach for
	// it deliberately — it is the visible, greppable token that says a route is
	// open.
	AuthModeNone AuthMode = "none"
)

type AuthResponse added in v0.2.0

type AuthResponse struct {
	// contains filtered or unexported fields
}

AuthResponse is the part of the response an Authenticator may touch: its headers, and nothing else. vov owns the status and the body, because vov is what decides whether the outcome is 401, 403, 402, or the handler's own response — so an authenticator can add a header without any risk of writing a response that collides with the one about to be sent.

The motivating case is a credential that must be revoked as it is rejected. A long-lived signed cookie belonging to an account that no longer exists should be cleared while it is refused; otherwise the browser re-sends the dead credential on every request until it expires on its own, which for a 30-day session means 30 days.

Rotating a session on successful authentication — sliding expiry — is the same mechanism, on the other branch.

func NewAuthResponse added in v0.2.1

func NewAuthResponse(h http.Header) AuthResponse

NewAuthResponse wraps h so that an Authenticator can be driven from outside this package — by an in-process caller such as an MCP tool server, or a test.

Pass the header of the response the call will produce. Passing a throwaway header is valid and means any header the authenticator sets is discarded, which is the honest thing to do when there is no response to put it on.

func (AuthResponse) Header added in v0.2.0

func (a AuthResponse) Header() http.Header

Header returns the response headers, for anything AuthResponse.SetCookie does not cover — a WWW-Authenticate challenge, for instance.

func (AuthResponse) SetCookie added in v0.2.0

func (a AuthResponse) SetCookie(c *http.Cookie)

SetCookie adds a Set-Cookie header, like http.SetCookie does for a ResponseWriter. Clearing a cookie is the same call with MaxAge set to -1 and the Name and Path that were used to write it.

func (AuthResponse) SetScopes added in v0.3.0

func (a AuthResponse) SetScopes(scopes []string)

SetScopes records the scopes the credential granted, for the endpoint's Endpoint.ScopeAllOf to be checked against.

It lives here because this is the one place that has read the credential. The Authenticator has just parsed a token and knows exactly what it was issued for; nothing later in the request does, and the User it returns is the wrong place to put it — a user is the same person whichever key they used, and two tokens for one account routinely carry different scopes.

Pass the *effective* set. If an application's "write" implies "read", expand it here: which of its scopes imply which others is a fact about that application's authorization model, and vov comparing strings is what keeps it from having an opinion about them.

Not calling this leaves the credential with no scopes, which satisfies no non-empty requirement. That is the fail-closed reading and the intended one: an endpoint declaring a scope has said a bare credential is not enough. A channel with no scope model at all — a browser session, typically — is exempted by leaving that channel out of Endpoint.ScopeAllOf and AppConfig.Scopes, not by leaving this uncalled.

type Authenticator added in v0.1.0

type Authenticator func(resp AuthResponse, r *http.Request) (User, error)

Authenticator resolves the User a request acts as. The app supplies it, because only the app knows where credentials live — a session cookie, a bearer token, a signed header — and how to look them up.

It receives an AuthResponse so that it can set headers on the way past — clearing a revoked cookie, rotating a live one. Headers set there survive whatever vov writes next, including a refusal.

Return values are distinguished on purpose:

(user, nil) → the request is authenticated as user
(nil, nil)  → no credentials were presented; vov answers 401
(nil, err)  → the lookup itself failed (database down, and so on); vov
              answers 500, because a broken dependency is not a bad password

Return an untyped nil User for "no user". A typed nil pointer is a non-nil interface, and vov would call IsAuthenticated on it.

type Endpoint added in v0.1.0

type Endpoint struct {
	// Handler serves the request. It is the standard net/http signature; the
	// objects an app builds at boot are reached through a [Global] holder rather
	// than being threaded through here.
	Handler http.HandlerFunc

	// MiddlewareStack names the [MiddlewareStack] from
	// [AppConfig.MiddlewareStacks] that wraps this endpoint. Empty selects
	// [DefaultStackName], so the common case says nothing. Naming a stack
	// replaces the default outright rather than adding to it; a name that was
	// never declared is a construction error.
	MiddlewareStack string

	// AuthMode declares this endpoint's authentication requirement. The zero
	// value requires an authenticated user, so a method that says nothing is
	// protected; [AuthModeNone] opts out. It is per-method on purpose: a URL may
	// be readable by anyone and writable only by an authenticated user.
	AuthMode AuthMode

	// RolesAnyOf restricts the endpoint to users holding at least one of them —
	// any-of, because a role is an identity and any of the listed identities
	// will do: "an admin or an owner may do this". Empty means no restriction.
	RolesAnyOf []string

	// PermissionsAllOf restricts the endpoint to users holding every one of
	// them — all-of, because a permission is a capability and each listed one is
	// needed. Every entry is checked. Empty means no restriction.
	//
	// The two may be declared together; a user must then satisfy both. Neither
	// is checked on an endpoint declaring [AuthModeNone], which resolves no
	// user, so that combination is a construction error rather than a
	// requirement that silently does nothing.
	PermissionsAllOf []string

	// ScopeAllOf restricts the endpoint to credentials carrying all of the listed
	// scopes per channel. It is a different axis from permissions as permissions
	// are applied to user, regardless of the auth method/token while a single
	// user can have multiple auth tokens, each with a different scope
	//
	//	ScopeAllOf: vov.ScopeAllOf{vov.RequestModeMCP: {"tasks:write"}}
	//
	// Providing a key for this field will override the scopes of that mode, described
	// in [AppConfig.Scopes]. For more detail, see [ScopeAllOf]
	ScopeAllOf ScopeAllOf

	// PathParams documents this route's wildcards, keyed by the wildcard name —
	// see [PathParam]. It is how a path parameter gets a caller-facing name and a
	// description, which a body or query field carries on its Go struct field and
	// a wildcard has nowhere else to put.
	//
	// Documenting a name the route does not declare is a construction error, so a
	// typo fails the build rather than quietly documenting nothing.
	PathParams map[string]PathParam

	// Body describes the shape of the request body, built with [BodyOf] from the
	// type the handler decodes into. Query does the same for the query string,
	// with [QueryOf].
	//
	// They describe; vov does not decode or enforce them. What they are for is
	// the consumers that cannot work without a machine-readable contract: an
	// OpenAPI document, an MCP tool's input schema, and a test runner that has to
	// build a valid body to attempt a request it should not be allowed to make.
	// Declaring the shape once, on the endpoint, is what keeps those three from
	// becoming three copies that drift.
	//
	// A type vov cannot describe is a construction error, so a declaration never
	// silently renders as something it is not.
	Body  *Schema
	Query *Schema

	// MCPTool, when set, declares this endpoint callable by an AI assistant over
	// the Model Context Protocol, under the given name — see [MCPTool]. It adds
	// only that name and the prose a model reads; the method, path, arguments
	// and policy are the ones already declared here, so a tool is never a second
	// description of the endpoint.
	MCPTool *MCPTool

	// MinTier restricts the endpoint to users whose [User.Tier] is at least this
	// high. Zero — the default — places no restriction, so only endpoints behind
	// a paywall mention it.
	//
	// A user who is refused here gets **402 Payment Required**, not 403, because
	// the two say different things to a client: 403 is a denial, 402 is a price.
	// A frontend can act on that — show the upgrade panel rather than a generic
	// error — which is why tier is a declaration of its own rather than a
	// permission.
	//
	// Reach for it only for access that money actually unlocks. Ordinal gating
	// that payment cannot resolve belongs in [Endpoint.PermissionsAllOf], whose
	// refusal is honest about being final.
	MinTier int
}

Endpoint is one method of one URL: the handler, plus the facts that describe how it is served — all as plain data. It lives inside an Endpoints group, which supplies the path and the method, so an Endpoint says only what is specific to that method.

It is deliberately a value type with no exported methods and no hidden state, so it can be read, listed, and (later) checked by tools without constructing an App. Its zero value declares nothing: a method field left empty on a group is a method that URL does not answer.

type Endpoints added in v0.1.0

type Endpoints struct {
	// Any answers every method that no specific field below claims. It is for
	// catch-alls — a static file tree, a single-page-app fallback — not for
	// ordinary endpoints, which should name their method so the declaration
	// says what the URL supports.
	Any Endpoint

	GET Endpoint

	// HEAD rarely needs declaring: net/http already answers HEAD with the GET
	// endpoint, and reports it in Allow. Declare it only to handle HEAD
	// differently from GET.
	HEAD Endpoint

	POST    Endpoint
	PUT     Endpoint
	PATCH   Endpoint
	DELETE  Endpoint
	OPTIONS Endpoint
}

Endpoints is every method of one URL, declared together.

It is separate from Route so that it can live beside the handlers that implement it — in the same file, next to the code it dispatches to — while the AppConfig.Routes slice stays a thin mapping from URL to endpoint group. The grouping is then visible in both places: the route list says which URLs exist, and the handler file says what one URL answers.

Methods are fields rather than map keys, so they cannot be misspelled, an editor completes them, and a single go-to-definition shows a URL's whole surface. Each carries its own Endpoint, so — unlike a Django class view — a URL's methods need not share configuration: one may take a different middleware stack or opt out of auth without affecting its siblings.

A method left empty is simply not registered, and requests using it get the 405 and Allow header that net/http derives from the methods that are.

type EnvError added in v0.1.0

type EnvError struct {
	Problems []string
}

EnvError reports everything wrong with the environment at once. Its Problems name variables and say what was expected; they never contain a variable's value, because the environment is where credentials live and an error string ends up in logs.

func (*EnvError) Error added in v0.1.0

func (e *EnvError) Error() string

type Field added in v0.2.1

type Field struct {
	// Name is the JSON name, from the json tag when there is one.
	Name string

	// Schema describes the field's value.
	Schema *Schema

	// Required reports whether the field must be present. It comes from a
	// `vov:"required"` tag and never from inference: `omitempty` is about how a
	// value is written, not whether a caller must send it, and reading a
	// contract off an encoding hint would make the declaration mean something
	// its author did not write.
	Required bool

	// Description is the prose a consumer shows for this field, from a
	// `jsonschema:"…"` tag whose whole value is the description:
	//
	//	Country string `json:"country" jsonschema:"ISO-3166 alpha-2 code, matched against the investor's home country"`
	//
	// It is a separate tag from vov's own on purpose. `vov:"…"` is a list of
	// comma-separated options, and a description is free text that routinely
	// contains commas and colons — putting prose in a flag list would need
	// escaping to say the very things worth saying. The tag is the one the
	// ecosystem already uses for exactly this, so a type carrying descriptions
	// reads the same to vov and to a generic JSON Schema inferrer.
	//
	// Shape describes what a value *is*; this describes what to put there, which
	// is a different question and the one a caller usually gets wrong. For an
	// assistant it is often the whole difference between a working call and a
	// silently empty result.
	Description string
}

Field is one member of an object Schema.

func (Field) JSONSchema added in v0.3.1

func (f Field) JSONSchema() map[string]any

JSONSchema renders the field as a JSON Schema property: its value's schema, plus the field's own description. The description belongs to the field rather than to its type, which is why it is added here and not by Schema.JSONSchema — two fields of the same type describe different things.

type Global added in v0.1.0

type Global[T any] struct {
	// contains filtered or unexported fields
}

Global is a typed, write-once holder for an object an application builds at boot and needs from many handlers: a struct of a database handle, a logger, service clients.

The name is literal. This is process-wide state, with the coupling and the test-substitution hazards that implies, and it says so rather than dressing itself up as dependency injection — vov injects nothing, constructs nothing, and never looks inside what it holds.

It exists so that handlers can reach those objects while keeping the standard net/http signature. The alternatives both cost more than they give: threading a dependency type through the framework makes every declaration mention it and forces one shared type on every handler, and stashing values in the request context trades a compile error for a runtime assertion.

Declare one holder per application, next to the type it holds:

type Deps struct{ DB *sql.DB; Log *slog.Logger }

var deps = vov.NewGlobal[*Deps]()

func main() {
    deps.Set(&Deps{DB: db, Log: log})
    app, err := vov.NewApp(vov.AppConfig{RequireGlobals: []vov.Readiness{deps}, ...})
}

func listTasks(w http.ResponseWriter, r *http.Request) {
    d := deps.Get()
    d.Log.Debug("listing")
}

Get is typed, so nothing is asserted at the call site and a wrong field is a compile error. What it does not do — and cannot — is put a handler's dependencies in its signature: reaching for a holder is invisible to a reader of the declaration. That is the price of the standard handler shape.

A Global is safe for concurrent use. Handlers may call Get on every request; it takes a read lock and returns the stored value.

func NewGlobal added in v0.1.0

func NewGlobal[T any]() *Global[T]

NewGlobal returns an empty holder. Populate it with Global.Set once the value exists — typically after configuration has been loaded and before NewApp is called.

func (*Global[T]) Describe added in v0.1.0

func (g *Global[T]) Describe() string

Describe names the held type, for error messages. It satisfies Readiness.

func (*Global[T]) Get added in v0.1.0

func (g *Global[T]) Get() T

Get returns the stored value. It panics if Set has not been called, rather than handing back a zero value that would fail later as a nil dereference somewhere less obvious.

Pass the holder to AppConfig.RequireGlobals to turn that panic into a construction error instead, caught before the server ever listens.

func (*Global[T]) Ready added in v0.1.0

func (g *Global[T]) Ready() bool

Ready reports whether Set has been called. It satisfies Readiness.

func (*Global[T]) Set added in v0.1.0

func (g *Global[T]) Set(v T)

Set stores v. It panics if called twice: a global swapped while requests are in flight would let two requests see different worlds, and every legitimate use sets it exactly once during start-up.

type Kind added in v0.2.1

type Kind string

Kind is the JSON type of a declared value.

const (
	KindString  Kind = "string"
	KindNumber  Kind = "number"
	KindInteger Kind = "integer"
	KindBoolean Kind = "boolean"
	KindArray   Kind = "array"
	KindObject  Kind = "object"

	// KindAny is a value whose shape is deliberately not described — a
	// json.RawMessage or an any field. It says "something goes here", which is
	// honest, rather than pretending to a structure that was never declared.
	KindAny Kind = ""
)

type MCPConfig added in v0.2.1

type MCPConfig struct {
	// Name and Version identify this server to clients. Both are required when
	// MCPConfig is set.
	Name    string
	Version string

	// Title is an optional human-readable name.
	Title string

	// Instructions tell an assistant how to navigate this particular product —
	// which tool to establish context with before the others make sense, for
	// instance. They reach the model once, at connection, and change how well it
	// uses everything else.
	Instructions string

	// Authenticate resolves the user a tool call acts as. It is required, and it
	// is the seam vov cannot cross: only the application knows which credentials
	// its tool endpoint honours — an OAuth access token bound to an audience is
	// a different thing from the session cookie a browser sends.
	//
	// An app whose tool endpoint honours its ordinary credentials passes the
	// same function it gave [APIConfig.Authenticator].
	Authenticate Authenticator

	// Path is the URL the tool server is served at, e.g. "/mcp". Setting it is
	// all it takes: [NewApp] builds the handler and mounts it, and there is
	// nothing to do afterwards.
	//
	// Leave it empty to mount the handler yourself — take it from
	// [App.MCPHandler] and register it wherever you like, which is what an app
	// that wraps the tool endpoint in its own OAuth gate will want. The handler
	// is built either way, so a declaration that cannot produce one fails at
	// construction whichever route is taken.
	Path string

	// OnToolCall, if set, is handed a record of every tool call — see [ToolCall].
	//
	// It exists because middleware structurally cannot do this job. An endpoint's
	// middleware sees an invocation but never the tool name, and, more to the
	// point, never sees the calls that fail before dispatch: a call rejected for
	// a bad argument reaches no endpoint, so no stack runs for it, and an
	// assistant looping on an argument it keeps getting wrong is visible here and
	// nowhere else.
	//
	// It observes and cannot decide. It returns nothing, and a panic inside it is
	// recovered, because by the time it runs the endpoint has committed whatever
	// it was going to commit — an error it could only hand back would be a
	// complaint about something already done. It runs on the calling goroutine,
	// so a sink that blocks delays the call; push to a channel if that matters.
	//
	// The context is the call's, with cancellation stripped
	// ([context.WithoutCancel]). Its values are there — a trace id, whatever an
	// application's own middleware put on the request — so a row can be
	// correlated with the rest of a trace. Its cancellation is not, deliberately:
	// this runs after the endpoint has committed, so an assistant hanging up must
	// not be what loses the record of what it did.
	//
	// # It is handed raw arguments
	//
	// [ToolCall.Arguments] is what the assistant sent, undecoded and
	// unsummarised. vov cannot summarise it honestly — it does not know which of
	// an application's fields hold a person's name or the body of a private note
	// — so it passes them through rather than inventing a redaction that is wrong
	// in a way nobody notices.
	//
	// That makes the sink responsible. A tool call's arguments are user content:
	// storing them verbatim, or logging them, is one careless line away from
	// putting private text somewhere it was never meant to go. Record the field
	// names, or a length, or a hash — record the values only if that is a
	// decision someone made on purpose.
	OnToolCall func(context.Context, ToolCall)

	// Logger, if set, receives protocol-level logging from the tool server.
	Logger *slog.Logger
}

MCPConfig describes the Model Context Protocol server an app exposes, when it exposes one. It is the app-level half of the declaration; the per-endpoint half is MCPTool.

It records what the server is called and how a caller is identified, so that the handler is built from declarations alone rather than from a second copy of them. Give it a Path and NewApp mounts it; otherwise take it from App.MCPHandler.

type MCPTool added in v0.2.1

type MCPTool struct {
	// Name is what an assistant calls, e.g. "list_tasks". It must be unique
	// across the app.
	Name string

	// Description tells the assistant what the tool is for and when to use it.
	// It is the whole of what a model knows when choosing between tools, so it
	// is worth more care than its length suggests.
	Description string

	// ReadOnly marks the tool as making no modifications, which lets a client
	// treat it as safe to call freely. Nil derives it from the endpoint's
	// method: GET and HEAD are read-only, everything else is not.
	ReadOnly *bool
}

MCPTool declares that an endpoint is callable by an AI assistant over the Model Context Protocol.

It carries only what cannot be derived from the endpoint it sits on. The method, the path, the arguments, who may call it and what happens when they may not are all already declared — a tool adds a name and the prose that tells a model when to reach for it, and nothing else. Restating the route here would be the same duplication Route exists to remove.

Descriptions are usually long, and long string literals read badly inside a route table. Keep them in a const beside the handler and reference it:

const listTasksDoc = "Every task on the list, with its id, title and owner. " +
    "Start here: the ids returned are what get_task takes."

GET: vov.Endpoint{
    Handler:     listTasks,
    Query:       vov.QueryOf[listTasksQuery](),
    MCPTool:     &vov.MCPTool{Name: "list_tasks", Description: listTasksDoc},
}

Declaring an MCPTool does not by itself serve anything: AppConfig.MCP is what builds a server. On its own it records that the endpoint is meant to be reachable that way, which is a fact worth having in the manifest either way.

type Middleware added in v0.1.0

type Middleware func(http.Handler) http.Handler

Middleware wraps an http.Handler, following the standard net/http idiom. It is declared as data — inside a MiddlewareStack — rather than pre-applied to the handler, so that what wraps a route stays readable off the declaration.

type MiddlewareStack added in v0.1.0

type MiddlewareStack struct {
	// Pre runs outside the auth guard, outermost first, for every request that
	// reaches this endpoint — including the ones the guard rejects, which is what
	// keeps a 401 logged and stamped.
	//
	// "Reaches this endpoint" is the limit, and it matters. Anything that must
	// see every request the server receives — CORS, panic recovery, access
	// logging, request ids on unrouted paths — belongs in
	// [APIConfig.ServerWrappers] instead. http.ServeMux answers a request for an
	// undeclared path (404) or an undeclared method (405) itself, before any Pre
	// middleware exists to run, and a CORS preflight is exactly such a request:
	// put CORS here and a browser client silently breaks.
	//
	// What belongs here is endpoint-scoped and must precede authentication: a
	// per-route rate limit, a body-size cap, a webhook signature check. So does
	// anything an endpoint needs while declaring [AuthModeNone], since Post does
	// not run there.
	Pre []Middleware

	// Post runs inside the auth guard, outermost first, so it can read the user
	// with [UserFrom]: audit logging, tenant scoping, per-user rate limits.
	//
	// It runs only on endpoints that require auth. An endpoint declaring
	// [AuthModeNone] has no user, so Post is skipped for it — a stack shared between
	// protected and open endpoints keeps working, but middleware an open
	// endpoint depends on must live in Pre.
	Post []Middleware
}

MiddlewareStack is one named combination of middleware, split by the seam where the request's user becomes known.

Stacks are declared once in AppConfig.MiddlewareStacks and selected by name from an endpoint. Real services converge on a handful of combinations — public, authenticated, admin, webhook — so naming them puts each combination in one place and turns "what wraps this route?" into a word a reviewer can read off the declaration.

type PathParam added in v0.3.1

type PathParam struct {
	// Name is what a caller sends this parameter as, when that should differ
	// from the wildcard. Empty keeps the wildcard's own name.
	//
	// It exists because the two audiences want different words. `{id}` is
	// idiomatic in a URL, where the surrounding path supplies the noun —
	// /projects/{id} is unambiguous to anyone reading it. It is not idiomatic as
	// an argument name offered to a model choosing between thirty tools, several
	// of which take the id of a different thing; there the noun has to be in the
	// name, because there is no path around it.
	//
	// Renaming the wildcard is the cleaner fix where an app can afford it —
	// /projects/{id} and /projects/{projectId} are the same route to
	// http.ServeMux, so it is an internal rename and not a URL change. Reach for
	// an alias when that rename would cost more than it is worth, and know that
	// it leaves two names for one value; [Manifest] renders the alias so that the
	// second one is visible rather than buried.
	Name string

	// Description tells a caller what to put here. For an assistant it is often
	// the difference between a working call and a silent empty result: which
	// format a value takes, which closed set it comes from, or — most valuable —
	// which other tool produces it ("the project's UUID, from list_projects").
	//
	// Long prose reads badly inside a route table. Keep it in a const beside the
	// handler and reference it, as [MCPTool] advises for the same reason.
	Description string
}

PathParam documents one wildcard in a route pattern.

A path parameter is the one input with no Go type to hang anything on: it exists only as text in the pattern, so unlike a body or query field it has nowhere to carry a name of its own or a word about what it means. This is that somewhere.

It is on Endpoint rather than on MCPTool because the facts are not MCP-specific. A generated OpenAPI document wants the same description in parameters[].description, and one declaration serving both is the same reason Endpoint.Body and Endpoint.Query live where they do.

type Readiness added in v0.1.0

type Readiness interface {
	Ready() bool
	Describe() string
}

Readiness is anything that can report whether it has been populated. A *Global satisfies it, which is what lets AppConfig.RequireGlobals check holders without knowing the type inside them — the reason none of vov's other types need a type parameter.

type RequestMode added in v0.2.1

type RequestMode string

RequestMode says which of an application's channels a request arrived on: its HTTP API, or its MCP tool interface.

It names the channel rather than the transport that carried it. Every API request happens to arrive over the network and every MCP call happens to be dispatched in process, but those are implementation, and a reader of an audit row or a `switch` in a handler wants the fact they can act on. It also stays true when the correspondence breaks: a runner exercising the API dispatches in process like any tool call, and is still exercising the API.

It is a legitimate input to how an application handles a request, not merely a label to record. Metering and audit rows are the obvious uses; rendering a response differently for a model than for a browser, or applying a different body limit, are equally proper.

Vary the wiring, not the effect

The reason [App.invoke] dispatches to the declared endpoint at all is that a tool call and an API call should be the same code path. Varying how a request is carried, rendered, limited or metered keeps that. Varying what the operation *does* — which records it touches, what it writes, whether it is permitted — gives it up, and puts a second implementation behind one URL that no test, no manifest and no reviewer is looking at.

The line that holds in practice: shape the request and the response by mode, decide the outcome by the declaration.

A worked example, because the line reads stricter than it is

A directory endpoint returns a compact row to an assistant and the full record to a browser:

func listInvestors(w http.ResponseWriter, r *http.Request) {
    found := store.Search(filtersFrom(r))
    if vov.ModeFrom(r.Context()) == vov.RequestModeMCP {
        writeJSON(w, http.StatusOK, compact(found)) // id, name, fit
        return
    }
    writeJSON(w, http.StatusOK, found)
}

That is on the permitted side, and squarely so. The same rows are found for both callers, by the same query, after the same declared policy has admitted them; only how much of each is written differs. An application metering full reads has a second reason to do this and it is the same reason — what a caller is charged for is what it was handed.

The same shape becomes a violation one step further:

if vov.ModeFrom(r.Context()) == vov.RequestModeMCP {
    found = onlyPublic(found) // ✗ a policy no reviewer can see
}

Which rows a caller may see is Endpoint.RolesAnyOf, Endpoint.MinTier and Endpoint.ScopeAllOf — declared, rendered in Manifest, and reviewable in a diff. Deciding it here hides the app's real authorization rule inside a handler, which is the second implementation this whole design exists to prevent.

The test that separates them: would the other channel, given the same credential, be entitled to what this branch withheld? If yes it is rendering. If no it is policy, and belongs in the declaration.

It cannot be set from outside

There is no wire representation and no exported setter. That is a security property rather than housekeeping, and more so the more an application decides with it: were the mode a header, whoever held a credential could choose it — billing browser traffic to a tool's allowance, or marking records as agent-reviewed that no agent saw. The only ways into a mode are to arrive over the network, or to be dispatched by [App.invoke], whose caller is in process and already trusted with [invokeRequest.User].

const (
	// RequestModeAPI is a call to the application's HTTP API. It is what
	// [ModeFrom] reports for any request not tagged otherwise, which is both safe
	// and true: a request nothing dispatched arrived over the network, and
	// defaulting the other way would bill browser traffic to a tool's allowance.
	RequestModeAPI RequestMode = "api"

	// RequestModeMCP is a call arriving as a Model Context Protocol tool, which
	// reaches its endpoint through [App.invoke].
	RequestModeMCP RequestMode = "mcp"
)

func ModeFrom added in v0.2.1

func ModeFrom(ctx context.Context) RequestMode

ModeFrom reports which channel the request carrying ctx arrived on. It answers RequestModeAPI for anything vov did not dispatch itself, which is what an untagged request is.

vov names the two channels it serves. An application with more of them — a queue consumer, a CLI, a second protocol — can put its own value on the context it passes to [App.invoke], where it reaches the handler alongside this one.

type Route added in v0.1.0

type Route struct {
	// Path is the URL path pattern, using net/http ServeMux syntax, e.g.
	// "/projects/{projectId}/rounds". It must be non-empty and begin with "/".
	Path string

	// Endpoints are the methods this URL answers. Declaring none is a
	// construction error: a Route that serves nothing is a mistake, not a
	// placeholder.
	Endpoints Endpoints
}

Route binds one URL to the Endpoints that serve it.

type Schema added in v0.2.1

type Schema struct {
	Kind Kind

	// Fields are the members of an object, in declaration order. Empty on an
	// object means free-form — a map, whose keys are not known ahead of time.
	Fields []Field

	// Elem is the element schema of an array.
	Elem *Schema

	// Nullable reports whether null is an accepted value. A Go pointer is both
	// nullable and, absent a required tag, optional.
	Nullable bool

	// Format carries a JSON Schema format hint, e.g. "date-time".
	Format string

	// TypeName is the Go type the schema was derived from, for the manifest.
	TypeName string
	// contains filtered or unexported fields
}

Schema describes the shape of a request body or query string.

It is derived from a Go type — the same type the handler decodes into — so a declaration cannot drift from the code that reads it. What it is *for* is being consumed: an OpenAPI document, an MCP tool's input schema, and a test runner that has to construct a valid-but-foreign body to attempt a cross-tenant write all need this same description, and deriving it once is why it lives on the endpoint.

It describes; it does not enforce. vov does not decode the body, because decoding is where a PATCH decides whether an absent field and an explicit null mean the same thing — a distinction Go's own decoder collapses for a plain pointer, and one only the application's types can resolve. Handlers decode as they always did; semantic rules stay with them.

func BodyOf added in v0.2.1

func BodyOf[T any]() *Schema

BodyOf describes T as an endpoint's request body.

Pass the type that describes the *contract* — what a caller may send. Often that is the type the handler decodes into, and keeping them the same is worth something: the declaration and the code that reads it cannot then drift.

It is not always the same, and should not be forced to be. A handler that decodes into a domain model would, by declaring it, advertise every field of that model — including the ones the server owns and overwrites, which a caller must not set and an assistant should never be offered. Declaring a narrower type is the safer direction and the recommended one there: a field added to the model later is not silently published as an input.

vov cannot check that the two agree — a handler is opaque to it — so this is convention either way. The trade is between drift vov cannot detect and a surface that is wrong on purpose, and the second is worse.

A body type in the simple case:

type createProject struct {
    Name    string  `json:"name" vov:"required"`
    Country *string `json:"hqCountry"` // optional, and may be null
}

POST: vov.Endpoint{Handler: create, Body: vov.BodyOf[createProject]()}

func QueryOf added in v0.2.1

func QueryOf[T any]() *Schema

QueryOf describes T as an endpoint's query string. T should be a flat struct: a query is a list of names and values, so a nested object has no natural encoding and is rejected.

func (*Schema) Err added in v0.2.1

func (s *Schema) Err() error

Err reports why a declared type could not be described, if it could not be.

func (*Schema) JSONSchema added in v0.2.1

func (s *Schema) JSONSchema() map[string]any

JSONSchema renders the schema as a JSON Schema document, which is the shape an MCP tool's inputSchema and an OpenAPI requestBody both want.

type ScopeAllOf added in v0.5.0

type ScopeAllOf = map[RequestMode][]string

ScopeAllOf declares the scopes a credential must carry, per channel.

A scope is a property of the credential, not of the user, which is what separates it from Endpoint.PermissionsAllOf

The key of the map determinses the RequestMode of the request. Any value provided for a request mode will override that of AppConfig.Scopes and if a key is absent, the app-level scope is used.

The value of the map determines the scopes the credential requires to reach the endpoint, all of them. An empty slice means no scope is required for the specified key

func ScopeNone added in v0.3.0

func ScopeNone() ScopeAllOf

ScopeNone requires no scopes on any channel, overriding whatever AppConfig.Scopes would otherwise apply.

type ScopePolicy added in v0.3.0

type ScopePolicy struct {
	API map[string][]string
	MCP map[string][]string
}

ScopePolicy is the app-wide scope declaration, stating which RequestModes requires which scopes for different methods.

A scope defined for a RequestMode and Method combo will be applied to the entire app, removing the need to declare the scopes in each endpoint individually. If, however, an endpoint requires a different set of scopes than what is described in the AppConfig, the endpoint can override the scope requirements.

Any missing field/key or empty slice is interpreted as "no scope required".

Example:

 Scopes: &vov.ScopePolicy{
 	API: map[string][]string{
			http.MethodGet:    {"tasks:read"},
			http.MethodPost:   {"tasks:write"},
			http.MethodDelete:    {"tasks:read", "tasks:delete"},
		},
 	MCP: map[string][]string{
			http.MethodGet:    {"tasks:read"},
			http.MethodPost:   {"tasks:write"},
			http.MethodPut:    {"tasks:read", "tasks:write"},
		},
	}

Have in mind that `vov` has no understanding of the vocabulary of your scopes, treats any given string equally, and only uses your declaration to make a distinction

type Server added in v0.1.0

type Server struct {

	// DisableGeneralOptionsHandler, if true, passes "OPTIONS *" requests to the Handler,
	// otherwise responds with 200 OK and Content-Length: 0.
	DisableGeneralOptionsHandler bool

	// TLSConfig optionally provides a TLS configuration for use
	// by ServeTLS and ListenAndServeTLS. Note that this value is
	// cloned by ServeTLS and ListenAndServeTLS, so it's not
	// possible to modify the configuration with methods like
	// tls.Config.SetSessionTicketKeys. To use
	// SetSessionTicketKeys, use Server.Serve with a TLS Listener
	// instead.
	TLSConfig *tls.Config

	// default: 15 * time.Second
	//
	// ReadTimeout is the maximum duration for reading the entire
	// request, including the body. A zero or negative value means
	// there will be no timeout.
	//
	// Because ReadTimeout does not let Handlers make per-request
	// decisions on each request body's acceptable deadline or
	// upload rate, most users will prefer to use
	// ReadHeaderTimeout. It is valid to use them both.
	ReadTimeout *time.Duration

	// default: 10 * time.Second
	//
	// ReadHeaderTimeout is the amount of time allowed to read
	// request headers. The connection's read deadline is reset
	// after reading the headers and the Handler can decide what
	// is considered too slow for the body. If zero, the value of
	// ReadTimeout is used. If negative, or if zero and ReadTimeout
	// is zero or negative, there is no timeout.
	ReadHeaderTimeout *time.Duration

	// default: 15 * time.Second
	//
	// WriteTimeout is the maximum duration before timing out
	// writes of the response. It is reset whenever a new
	// request's header is read. Like ReadTimeout, it does not
	// let Handlers make decisions on a per-request basis.
	// A zero or negative value means there will be no timeout.
	WriteTimeout *time.Duration

	// default: 60 * time.Second
	//
	// IdleTimeout is the maximum amount of time to wait for the
	// next request when keep-alives are enabled. If zero, the value
	// of ReadTimeout is used. If negative, or if zero and ReadTimeout
	// is zero or negative, there is no timeout.
	IdleTimeout *time.Duration

	// MaxHeaderBytes controls the maximum number of bytes the
	// server will read parsing the request header's keys and
	// values, including the request line. It does not limit the
	// size of the request body.
	// If zero, DefaultMaxHeaderBytes is used.
	MaxHeaderBytes int

	// MaxHeaderValueCount controls the maximum number of header
	// values that the server is willing to parse from a request.
	// If zero, DefaultMaxHeaderValueCount is used.
	// Note that comma-separated values in a single header line are
	// counted once, while values sent as multiple header lines are
	// counted multiple times.
	MaxHeaderValueCount int

	// TLSNextProto optionally specifies a function to take over
	// ownership of the provided TLS connection when an ALPN
	// protocol upgrade has occurred. The map key is the protocol
	// name negotiated. The Handler argument should be used to
	// handle HTTP requests and will initialize the Request's TLS
	// and RemoteAddr if not already set. The connection is
	// automatically closed when the function returns.
	// If TLSNextProto is not nil, HTTP/2 support is not enabled
	// automatically.
	//
	// Historically, TLSNextProto was used to disable HTTP/2 support.
	// The Server.Protocols field now provides a simpler way to do this.
	TLSNextProto map[string]func(*http.Server, *tls.Conn, http.Handler)

	// ConnState specifies an optional callback function that is
	// called when a client connection changes state. See the
	// ConnState type and associated constants for details.
	ConnState func(net.Conn, http.ConnState)

	// ErrorLog specifies an optional logger for errors accepting
	// connections, unexpected behavior from handlers, and
	// underlying FileSystem errors.
	// If nil, logging is done via the log package's standard logger.
	ErrorLog *log.Logger

	// BaseContext optionally specifies a function that returns
	// the base context for incoming requests on this server.
	// The provided Listener is the specific Listener that's
	// about to start accepting requests.
	// If BaseContext is nil, the default is context.Background().
	// If non-nil, it must return a non-nil context.
	BaseContext func(net.Listener) context.Context

	// ConnContext optionally specifies a function that modifies
	// the context used for a new connection c. The provided ctx
	// is derived from the base context and has a ServerContextKey
	// value.
	ConnContext func(ctx context.Context, c net.Conn) context.Context

	// HTTP2 configures HTTP/2 connections.
	HTTP2 *http.HTTP2Config

	// Protocols is the set of protocols accepted by the server.
	//
	// If Protocols includes UnencryptedHTTP2, the server will accept
	// unencrypted HTTP/2 connections. The server can serve both
	// HTTP/1 and unencrypted HTTP/2 on the same address and port.
	//
	// If Protocols is nil, the default is usually HTTP/1 and HTTP/2.
	// If TLSNextProto is non-nil and does not contain an "h2" entry,
	// the default is HTTP/1 only.
	Protocols *http.Protocols

	// DisableClientPriority specifies whether client-specified priority, as
	// specified in RFC 9218, should be respected or not.
	//
	// This field only takes effect if using HTTP/2, and if no custom write
	// scheduler is defined for the HTTP/2 server. Otherwise, this field is a
	// no-op.
	//
	// If set to true, requests will be served in a round-robin manner, without
	// prioritization.
	DisableClientPriority bool
}

A Server defines parameters for running an HTTP server. The zero value for Server is a valid configuration.

func (*Server) ToNetHTTPServer added in v0.1.0

func (s *Server) ToNetHTTPServer(addr string, handler http.Handler) *http.Server

type ToolCall added in v0.3.0

type ToolCall struct {
	// Tool is the name the assistant called.
	Tool string

	// Arguments are the arguments it sent, undecoded, and are empty only when
	// the call failed before they were read.
	//
	// They are a copy, taken before vov routes them. Routing consumes the
	// argument object as it places each value in the path, the query or the
	// body, so a record sharing it would keep only what vov could not place —
	// nothing at all, for a tool whose inputs are all path parameters.
	//
	// They are raw on purpose. vov does not know which of an application's
	// fields hold a person's name, a note body, or anything else worth being
	// careful with, so it passes what it has rather than inventing a summary
	// that would be wrong in a way nobody notices. Deciding what is safe to keep
	// is the application's — see [MCPConfig.OnToolCall].
	Arguments map[string]json.RawMessage

	// Outcome says how the call ended; Status is the endpoint's, and is zero
	// unless the outcome is [ToolOutcomeOK] or [ToolOutcomeRefused].
	Outcome ToolOutcome
	Status  int

	// User is the principal the call acted as, and is nil when the call failed
	// before one was resolved.
	User User

	// Scopes are the scopes the calling credential carried.
	Scopes []string

	// Err is set for [ToolOutcomeFailed] and explains what could not be done.
	Err error

	// Duration is how long the call took, including authentication.
	Duration time.Duration
}

ToolCall is the record of one tool call, handed to MCPConfig.OnToolCall.

type ToolOutcome added in v0.3.0

type ToolOutcome string

ToolOutcome says how a tool call ended.

The distinction that matters is whether an endpoint was reached at all: a call an endpoint refused and a call that never got that far are different events, and only the first has a status worth recording.

const (
	// ToolOutcomeOK is a call an endpoint answered successfully.
	ToolOutcomeOK ToolOutcome = "ok"

	// ToolOutcomeRefused is a call an endpoint answered with a non-2xx status —
	// the declared policy said no, or the handler did.
	ToolOutcomeRefused ToolOutcome = "refused"

	// ToolOutcomeRejected is a call that never reached an endpoint because its
	// arguments could not be mapped onto one: a missing path parameter, a value
	// that is not a scalar.
	//
	// This is the outcome nothing else can see. No endpoint ran, so no
	// middleware ran either, and an assistant looping on an argument it keeps
	// getting wrong is invisible everywhere but here.
	ToolOutcomeRejected ToolOutcome = "rejected"

	// ToolOutcomeFailed is a call that could not be dispatched: the caller could
	// not be resolved, or dispatch itself errored. It is reported to the client
	// as a protocol error rather than a tool error.
	ToolOutcomeFailed ToolOutcome = "failed"
)

type User added in v0.1.0

type User interface {
	// IsAuthenticated reports whether the request carries a valid principal.
	IsAuthenticated() bool

	// Roles are the roles the user holds. An endpoint's [Endpoint.RolesAnyOf] is
	// satisfied by any one of them.
	Roles() []string

	// Permissions are the permissions the user holds. An endpoint's
	// [Endpoint.PermissionsAllOf] requires every one it lists.
	Permissions() []string

	// Tier reports the user's paid tier: 0 for free, and higher numbers for
	// higher subscriptions. It is ordinal, so an endpoint declaring
	// [Endpoint.MinTier] admits every tier at or above it and a subscription
	// upgrade never removes access.
	//
	// A model with nothing to sell returns 0, which is honest and fail-closed:
	// an endpoint that later gates on tier refuses rather than opening.
	Tier() int
}

User is the principal a request acts as. It is behavioral and deliberately tiny: vov asks it yes-or-no questions and never reads its storage. Capability lives on the model — the app decides what a role or a permission means — while policy lives on the endpoint declaration, where it can be read and reviewed.

A model that does not use roles or permissions returns nothing, which is both honest and fail-closed: an endpoint that later requires one is refused rather than accidentally opened.

Return the *effective* sets. If an application's roles imply permissions, or a wildcard covers a family of them, expand that here — vov compares strings, and which of an application's values imply which others is a fact about its own model. AuthResponse.SetScopes says the same about scopes, for the same reason.

vov asks only what the matched endpoint declares, and asks once: Roles only when the endpoint lists roles, Permissions only when it lists permissions, Tier only when it sets MinTier. An endpoint declaring none of them costs none of them, and one declaring three permissions still costs a single call.

func UserFrom added in v0.1.0

func UserFrom(ctx context.Context) (User, bool)

UserFrom returns the User resolved for the request, and whether there was one. Handlers on endpoints that require auth can rely on ok being true.

vov intentionally returns the interface rather than a generic concrete type: the guard only ever asks yes/no questions, so only the interface belongs here. Apps that want their own type back wrap this in a one-line typed accessor.

Directories

Path Synopsis
internal
mcp
Package mcp adapts a set of already-described tools onto the Model Context Protocol.
Package mcp adapts a set of already-described tools onto the Model Context Protocol.
mcp module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL