Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid, at_hash, signed userinfo, RP-initiated and back-channel logout), managed refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens, DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592 registration, RFC 9207 iss, signing keyring with rotation. State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct backends, four dialect DDLs, conformance cases). Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL. PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse. Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
resolvemcp
Package resolvemcp exposes registered database models to AI clients through a fixed set of Model Context Protocol (MCP) meta tools over SSE or Streamable HTTP. The tool count does not grow with the number of models. It mirrors the resolvespec package — same model registration, filter/sort/pagination/preload options, hook system and security rules.
Every endpoint requires authentication; tools run as the authenticated caller.
Quick Start
import (
"github.com/bitechdev/ResolveSpec/pkg/resolvemcp"
"github.com/bitechdev/ResolveSpec/pkg/security"
"github.com/gorilla/mux"
)
handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{
BaseURL: "http://localhost:8080",
BasePath: "/mcp",
})
securityList, _ := security.NewSecurityList(provider)
resolvemcp.RegisterSecurityHooks(handler, securityList)
handler.RegisterModel("public", "users", &User{})
handler.RegisterModel("public", "orders", &Order{})
r := mux.NewRouter()
resolvemcp.SetupMuxRoutes(r, handler, securityList) // guarded
Config
| Field | Default | Purpose |
|---|---|---|
BaseURL |
request-detected | Public base URL sent to SSE clients |
BasePath |
request-detected | Mount path (e.g. /mcp) |
DefaultLimit |
50 | Page size when a read gives no limit |
MaxLimit |
1000 | Larger limits are clamped |
MaxOffset |
100000 | Larger offsets are rejected |
MaxBatch |
100 | Rows in one batch insert |
MaxPreloadDepth |
2 | Depth of a preload path (a.b.c) |
MaxWriteRows |
100 | Rows a filter-based update/delete may touch |
QueryTimeout |
30s | One tool call, hooks and queries included |
ConfirmTTL |
5m | Lifetime of a filter-write confirm token |
AllowedHosts |
any | Host allowlist for SSE when BaseURL is empty (prefer setting BaseURL) |
EnableAnnotations |
false | Registers resolvespec_annotate (opt-in) |
Handler Creation
| Function | Description |
|---|---|
NewHandlerWithGORM(db *gorm.DB, cfg Config) *Handler |
Backed by GORM |
NewHandlerWithBun(db *bun.DB, cfg Config) *Handler |
Backed by Bun |
NewHandlerWithDB(db common.Database, cfg Config) *Handler |
Backed by any common.Database |
NewHandler(db common.Database, registry common.ModelRegistry, cfg Config) *Handler |
Full control over registry |
Registering Models
handler.RegisterModel(schema, entity string, model interface{}) error
schema— database schema name (e.g."public"), or empty string for no schema prefix.entity— table/entity name (e.g."users").model— a pointer to a struct (e.g.&User{}).
RegisterModel only adds the model to the registry; it creates no tools. All registered models are visible to list_tables; per-entity rules (see Security) restrict the operations.
Functions
// Go callback
handler.RegisterFunction(resolvemcp.Function{
Name: "recalc_totals",
Description: "Recalculate order totals",
Params: []resolvemcp.FunctionParam{{Name: "order_id", Type: resolvemcp.ParamNumber, Required: true}},
Handler: func(ctx context.Context, tx common.Database, args map[string]any) (any, error) { return nil, nil },
Authorize: func(ctx context.Context) error { return nil }, // optional per-caller gate
})
// SQL procedure: SELECT * FROM public.my_proc($1, $2::jsonb)
handler.RegisterFunction(resolvemcp.Function{
Name: "my_proc", Procedure: "public.my_proc",
Params: []resolvemcp.FunctionParam{{Name: "a", Type: resolvemcp.ParamString, Required: true}},
})
Only registered functions are callable. Arguments are validated against Params; calls run in a transaction (OnTxBegin fired). A function the caller is not authorized for looks identical to an unknown one.
HTTP Transports
Config.BasePath is used for route registration. Config.BaseURL is optional — when empty it is detected from each request.
All Setup*/New* helpers wrap the endpoint in Guard(securityList): a valid OAuth bearer token, session token or API key is required, there is no guest/optional mode, and it fails closed. handler.SSEServer() / handler.StreamableHTTPServer() and the *Unauthenticated variants serve without a guard and log a warning; use them only behind your own authentication.
Two transports are supported: SSE (legacy, two-endpoint) and Streamable HTTP (recommended, single-endpoint).
SSE Transport
Two endpoints: GET {BasePath}/sse (subscribe) + POST {BasePath}/message (send).
Gorilla Mux
resolvemcp.SetupMuxRoutes(r, handler, securityList)
| Route | Method | Description |
|---|---|---|
{BasePath}/sse |
GET | SSE connection — clients subscribe here |
{BasePath}/message |
POST | JSON-RPC — clients send requests here |
bunrouter
resolvemcp.SetupBunRouterRoutes(router, handler, securityList)
Gin / net/http / Echo
sse := resolvemcp.NewSSEServer(handler, securityList) // guarded
engine.Any("/mcp/*path", gin.WrapH(sse)) // Gin
http.Handle("/mcp/", sse) // net/http
e.Any("/mcp/*", echo.WrapHandler(sse)) // Echo
Streamable HTTP Transport
Single endpoint at {BasePath}. Handles POST (client→server) and GET (server→client streaming). Preferred for new integrations.
Gorilla Mux
resolvemcp.SetupMuxStreamableHTTPRoutes(r, handler, securityList)
Mounts the handler at {BasePath} (all methods).
bunrouter
resolvemcp.SetupBunRouterStreamableHTTPRoutes(router, handler, securityList)
Registers GET, POST, DELETE on {BasePath}.
Gin / net/http / Echo
h := resolvemcp.NewStreamableHTTPHandler(handler, securityList) // guarded
engine.Any("/mcp", gin.WrapH(h)) // Gin
http.Handle("/mcp", h) // net/http
e.Any("/mcp", echo.WrapHandler(h)) // Echo
OAuth2 Authentication
resolvemcp ships a full MCP-standard OAuth2 authorization server (pkg/security.OAuthServer) that MCP clients (Claude Desktop, Cursor, etc.) can discover and use automatically.
It can operate as:
- Its own identity provider — shows a login form, validates via
DatabaseAuthenticator.Login() - An OAuth2 federation layer — delegates to external providers (Google, GitHub, Microsoft, etc.)
- Both simultaneously
The underlying
security.OAuthServeralso supports consent, OpenID Connect, rotating refresh tokens, JWT access tokens, DPoP, PAR, the device grant and token exchange; they are opt-inOAuthServerConfigoptions described in pkg/security/OAUTH2_SERVER.md. The options ofresolvemcp.OAuth2Configare unchanged.
Standard endpoints served
| Path | Spec | Purpose |
|---|---|---|
GET /.well-known/oauth-authorization-server |
RFC 8414 | MCP client auto-discovery |
POST /oauth/register |
RFC 7591 | Dynamic client registration |
GET /oauth/authorize |
OAuth 2.1 + PKCE | Start login (form or provider redirect) |
POST /oauth/authorize |
— | Login form submission |
POST /oauth/token |
OAuth 2.1 | Auth code → Bearer token exchange |
POST /oauth/token (refresh) |
OAuth 2.1 | Refresh token rotation |
GET /oauth/provider/callback |
Internal | External provider redirect target |
MCP clients send Authorization: Bearer <token> on all subsequent requests.
Mode 1 — Direct login (server as identity provider)
import "github.com/bitechdev/ResolveSpec/pkg/security"
db, _ := sql.Open("postgres", dsn)
auth := security.NewDatabaseAuthenticator(db)
handler := resolvemcp.NewHandlerWithGORM(gormDB, resolvemcp.Config{
BaseURL: "https://api.example.com",
BasePath: "/mcp",
})
// Enable the OAuth2 server — auth enables the login form
handler.EnableOAuthServer(security.OAuthServerConfig{
Issuer: "https://api.example.com",
}, auth)
provider, _ := security.NewCompositeSecurityProvider(auth, colSec, rowSec)
securityList, _ := security.NewSecurityList(provider)
resolvemcp.RegisterSecurityHooks(handler, securityList)
http.ListenAndServe(":8080", handler.HTTPHandler(securityList))
MCP client flow:
- Discovers server at
/.well-known/oauth-authorization-server - Registers itself at
/oauth/register - Redirects user to
/oauth/authorize→ login form appears - On submit, exchanges code at
/oauth/token→ receivesAuthorization: Bearertoken - Uses token on all MCP tool calls
Mode 2 — External provider (Google, GitHub, etc.)
The RedirectURL in the provider config must point to /oauth/provider/callback on this server.
auth := security.NewDatabaseAuthenticator(db).WithOAuth2(security.OAuth2Config{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
RedirectURL: "https://api.example.com/oauth/provider/callback",
Scopes: []string{"openid", "profile", "email"},
AuthURL: "https://accounts.google.com/o/oauth2/auth",
TokenURL: "https://oauth2.googleapis.com/token",
UserInfoURL: "https://www.googleapis.com/oauth2/v2/userinfo",
ProviderName: "google",
})
// Pass `auth` so the OAuth server supports persistence, introspection, and revocation.
// Google handles the end-user authentication flow via redirect.
handler.EnableOAuthServer(security.OAuthServerConfig{
Issuer: "https://api.example.com",
}, auth)
handler.RegisterOAuth2Provider(auth, "google")
Mode 3 — Both (login form + external providers)
handler.EnableOAuthServer(security.OAuthServerConfig{
Issuer: "https://api.example.com",
LoginTitle: "My App Login",
}, auth) // auth enables the username/password form
handler.RegisterOAuth2Provider(googleAuth, "google")
handler.RegisterOAuth2Provider(githubAuth, "github")
When external providers are registered they take priority; the login form is used as fallback when no providers are configured.
Using security.OAuthServer standalone
The authorization server lives in pkg/security and can be used with any HTTP framework independently of resolvemcp:
oauthSrv := security.NewOAuthServer(security.OAuthServerConfig{
Issuer: "https://api.example.com",
}, auth)
oauthSrv.RegisterExternalProvider(googleAuth, "google")
mux := http.NewServeMux()
mux.Handle("/", oauthSrv.HTTPHandler()) // mounts all OAuth2 routes
mux.Handle("/mcp/", myMCPHandler)
http.ListenAndServe(":8080", mux)
Cookie-based flow (legacy)
For simple setups without full MCP OAuth2 compliance, use the legacy helpers that set a session cookie after external provider login:
resolvemcp.SetupMuxOAuth2Routes(r, auth, resolvemcp.OAuth2RouteConfig{
ProviderName: "google",
LoginPath: "/auth/google/login",
CallbackPath: "/auth/google/callback",
AfterLoginRedirect: "/",
})
resolvemcp.SetupMuxRoutesWithAuth(r, handler, securityList)
Security
resolvemcp integrates with the security package to provide per-entity access control, row-level security, and column-level security — the same system used by resolvespec and restheadspec.
Wiring security hooks
import "github.com/bitechdev/ResolveSpec/pkg/security"
securityList, err := security.NewSecurityList(mySecurityProvider)
if err != nil {
log.Fatal(err)
}
resolvemcp.RegisterSecurityHooks(handler, securityList)
Call RegisterSecurityHooks once, after creating the handler and before registering models. It installs these controls automatically:
| Hook | Effect |
|---|---|
OnTxBegin |
Stamps transaction-local settings (RLS GUCs) set with SecurityList.SetTxSettings |
BeforeHandle |
Enforces per-entity operation rules (see below); preloads column rules for writes |
BeforeRead |
Loads RLS/CLS rules, then injects a user-scoped WHERE clause |
BeforeScan |
Applies row security to the row an update or delete targets; a row the user cannot see is "not found" |
AfterRead |
Masks/hides columns per column-security rules; writes audit log |
BeforeCreate |
Blocks create if CanCreate is false; drops hidden/masked columns from the payload |
BeforeUpdate |
Blocks update if CanUpdate is false; drops hidden/masked columns from the payload |
BeforeDelete |
Blocks delete if CanDelete is false |
Additional hooks: BeforeScan (row pre-read of update/delete/filter writes), BeforeCall/AfterCall (functions) and OnTxBegin. Hooks are mutex-protected and panics in hooks are recovered.
Per-entity operation rules
Use RegisterModelWithRules instead of RegisterModel to set access rules at registration time:
import "github.com/bitechdev/ResolveSpec/pkg/modelregistry"
// Read-only entity
handler.RegisterModelWithRules("public", "audit_logs", &AuditLog{}, modelregistry.ModelRules{
CanRead: true,
CanCreate: false,
CanUpdate: false,
CanDelete: false,
})
// Public read, authenticated write
handler.RegisterModelWithRules("public", "products", &Product{}, modelregistry.ModelRules{
CanPublicRead: true,
CanRead: true,
CanCreate: true,
CanUpdate: true,
CanDelete: false,
})
To update rules for an already-registered model:
handler.SetModelRules("public", "users", modelregistry.ModelRules{
CanRead: true,
CanCreate: true,
CanUpdate: true,
CanDelete: false,
})
RegisterModel (no rules) registers with all-allowed defaults (CanRead/Create/Update/Delete = true).
ModelRules fields
| Field | Default | Description |
|---|---|---|
CanPublicRead |
false |
Allow unauthenticated reads |
CanPublicCreate |
false |
Allow unauthenticated creates |
CanPublicUpdate |
false |
Allow unauthenticated updates |
CanPublicDelete |
false |
Allow unauthenticated deletes |
CanRead |
true |
Allow authenticated reads |
CanCreate |
true |
Allow authenticated creates |
CanUpdate |
true |
Allow authenticated updates |
CanDelete |
true |
Allow authenticated deletes |
SecurityDisabled |
false |
Skip all security checks for this model |
MCP Tools
Fixed set, independent of the models. table is schema.entity. Errors return {"success":false,"error":{"code","message"}} with codes invalid_argument, not_found, forbidden, limit_exceeded, internal (internal details are logged, the client gets a reference id).
| Tool | Purpose |
|---|---|
list_tables |
Tables the caller may use and the allowed operations |
describe_table |
Columns, PK, relations, writable columns, operations, limits |
select_table |
Read rows (filters, sort, columns, preloads, paging) |
insert_into_table |
Insert one row or a capped batch |
update_table |
Update by id or filters |
delete_from_table |
Delete by id or filters |
list_functions |
Registered functions the caller may call, with parameters |
call_function |
Call a registered function |
resolvespec_annotate |
Only with EnableAnnotations |
select_table
| Argument | Type | Description |
|---|---|---|
table |
string (required) | schema.entity |
id |
string | Primary key of one row |
filters, sort |
array | See Filtering, Sorting |
columns, omit_columns |
array | Column selection |
preloads |
array | Relations (validated against the model, max depth MaxPreloadDepth) |
limit, offset |
number | Clamped to MaxLimit / rejected above MaxOffset |
cursor_forward, cursor_backward |
string | PK cursor, requires sort |
include_count |
boolean | Also compute totals (slower); otherwise total/filtered are 0 |
Response: {"success":true,"data":[...],"metadata":{"total","filtered","count","limit","offset"}}
insert_into_table
data is an object or an array (one transaction, max MaxBatch). Unknown, duplicate or read-only keys are rejected; keys are resolved to columns from the model.
update_table / delete_from_table
Either id or filters is required.
| Mode | Behaviour |
|---|---|
id |
One row, applied immediately. The row is locked and row security applies; an invisible row is "not found". |
filters |
Matching rows are found inside the transaction (row security applied, max MaxWriteRows). The first call returns a preview and a confirm_token; repeat the identical call with confirm_token to apply. |
dry_run |
Report match count and preview ids; change nothing. |
The token is single-use, expires after ConfirmTTL, and is bound to user, table, operation and a hash of filters, data and matched ids; it is held in memory (lost on restart, single instance). Update changes only the keys in data; null sets NULL. Filters are strictly parsed (never silently dropped), columns validated, and only the documented operators are accepted.
call_function
name and arguments (object). See Functions.
resolvespec_annotate
Opt-in (Config.EnableAnnotations). Stores/retrieves freeform annotations through resolvespec_set_annotation / resolvespec_get_annotation; runs BeforeHandle hooks (annotate_set / annotate_get) and a transaction.
Filtering
Pass an array of filter objects to the filters argument:
[
{ "column": "status", "operator": "=", "value": "active" },
{ "column": "age", "operator": ">", "value": 18, "logic_operator": "AND" },
{ "column": "role", "operator": "in", "value": ["admin", "editor"], "logic_operator": "OR" }
]
Supported Operators
| Operator | Aliases | Description |
|---|---|---|
= |
eq |
Equal |
!= |
neq, <> |
Not equal |
> |
gt |
Greater than |
>= |
gte |
Greater than or equal |
< |
lt |
Less than |
<= |
lte |
Less than or equal |
like |
SQL LIKE (case-sensitive) | |
ilike |
SQL ILIKE (case-insensitive) | |
in |
Value in list | |
is_null |
Column IS NULL | |
is_not_null |
Column IS NOT NULL |
Logic Operators
"logic_operator": "AND"(default) — filter is AND-chained with the previous condition."logic_operator": "OR"— filter is OR-grouped with the previous condition.
Consecutive OR filters are grouped into a single (cond1 OR cond2 OR ...) clause.
Sorting
[
{ "column": "created_at", "direction": "desc" },
{ "column": "name", "direction": "asc" }
]
Pagination
Offset-Based
{ "limit": 20, "offset": 40 }
Cursor-Based
Cursor pagination uses a SQL EXISTS subquery for stable, efficient paging. Always pair with a sort argument.
// Next page: pass the PK of the last record on the current page
{ "cursor_forward": "42", "limit": 20, "sort": [{"column": "id", "direction": "asc"}] }
// Previous page: pass the PK of the first record on the current page
{ "cursor_backward": "23", "limit": 20, "sort": [{"column": "id", "direction": "asc"}] }
Preloading Relations
[
{ "relation": "Profile" },
{ "relation": "Orders" }
]
Available relations are listed in each tool's description. Only relations defined on the model struct are valid.
Hook System
Hooks let you intercept and modify CRUD operations at well-defined lifecycle points.
Hook Types
| Constant | Fires |
|---|---|
BeforeHandle |
After model resolution, before operation dispatch (all CRUD) |
BeforeRead / AfterRead |
Around read queries |
BeforeCreate / AfterCreate |
Around insert |
BeforeUpdate / AfterUpdate |
Around update |
BeforeDelete / AfterDelete |
Around delete |
BeforeScan |
Row pre-read for update/delete/filter writes |
BeforeCall / AfterCall |
Around call_function |
OnTxBegin |
Start of every transaction |
Registering Hooks
handler.Hooks().Register(resolvemcp.BeforeCreate, func(ctx *resolvemcp.HookContext) error {
// Inject a timestamp before insert
if data, ok := ctx.Data.(map[string]interface{}); ok {
data["created_at"] = time.Now()
}
return nil
})
// Register the same hook for multiple events
handler.Hooks().RegisterMultiple(
[]resolvemcp.HookType{resolvemcp.BeforeCreate, resolvemcp.BeforeUpdate},
auditHook,
)
HookContext Fields
| Field | Type | Description |
|---|---|---|
Context |
context.Context |
Request context |
Handler |
*Handler |
The resolvemcp handler |
Schema |
string |
Database schema name |
Entity |
string |
Entity/table name |
Model |
interface{} |
Registered model instance |
Options |
common.RequestOptions |
Parsed request options (read operations) |
Operation |
string |
"read", "create", "update", "delete", "call", "annotate_set" or "annotate_get" |
ID |
string |
Primary key from request (read/update/delete) |
Data |
interface{} |
Input data (create/update — modifiable) |
Result |
interface{} |
Output data (set by After hooks) |
Error |
error |
Operation error, if any |
Query |
common.SelectQuery |
Live query object (available in BeforeRead) |
Tx |
common.Database |
Database/transaction handle |
Abort |
bool |
Set to true to abort the operation |
AbortMessage |
string |
Error message returned when aborting |
AbortCode |
int |
Optional status code for the abort |
Aborting an Operation
handler.Hooks().Register(resolvemcp.BeforeDelete, func(ctx *resolvemcp.HookContext) error {
ctx.Abort = true
ctx.AbortMessage = "deletion is disabled"
return nil
})
Managing Hooks
registry := handler.Hooks()
registry.HasHooks(resolvemcp.BeforeCreate) // bool
registry.Clear(resolvemcp.BeforeCreate) // remove hooks for one type
registry.ClearAll() // remove all hooks
Context Helpers
The caller's security.UserContext reaches every tool call through the request context. Request metadata is threaded through context.Context during handler execution. Hooks and custom tools can read it:
schema := resolvemcp.GetSchema(ctx)
entity := resolvemcp.GetEntity(ctx)
tableName := resolvemcp.GetTableName(ctx)
model := resolvemcp.GetModel(ctx)
modelPtr := resolvemcp.GetModelPtr(ctx)
You can also set values manually (e.g. in middleware):
ctx = resolvemcp.WithSchema(ctx, "tenant_a")
Adding Custom MCP Tools
Access the underlying *server.MCPServer to register additional tools (they sit behind the same guard). Prefer RegisterFunction for database-backed actions:
mcpServer := handler.MCPServer()
mcpServer.AddTool(myTool, myHandler)
Table Name Resolution
The handler resolves table names in priority order:
TableNameProviderinterface —TableName() string(can return"schema.table")SchemaProviderinterface —SchemaName() string(combined with entity name)- Fallback:
schema.entity(orschema_entityfor SQLite)
Breaking changes
- Per-model tools (
read_/create_/update_/delete_{schema}_{entity}) and per-model resources are gone; use the meta tools. Setup*/NewSSEServer/NewStreamableHTTPHandlertake a*security.SecurityListand require authentication.OptionalAuth*helpers were removed;*Unauthenticatedvariants exist for explicit opt-out.resolvespec_annotateis opt-in viaConfig.EnableAnnotations.Handler.Build()is not needed.- Update is now a partial update by validated keys; reads are capped by the configured limits.