mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-08 06:16:28 +00:00
feat(resolvemcp)!: make the server read-only by default
Tests / Integration Tests (push) Skipped
Build , Vet Test, and Lint / Build (push) Successful in 1m33s
Tests / Unit Tests (push) Successful in 2m1s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m36s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m38s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m49s
Tests / Race Detector (push) Successful in 4m31s
Tests / Integration Tests (push) Skipped
Build , Vet Test, and Lint / Build (push) Successful in 1m33s
Tests / Unit Tests (push) Successful in 2m1s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m36s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m38s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m49s
Tests / Race Detector (push) Successful in 4m31s
BREAKING CHANGE: Config.ReadOnly is now a *bool and unset means read-only. Use ReadOnly: resolvemcp.Bool(false) to enable insert/update/delete, annotations and function calls. Adds the Bool helper and updates docs.
This commit is contained in:
@@ -16,6 +16,8 @@ import (
|
|||||||
handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{
|
handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{
|
||||||
BaseURL: "http://localhost:8080",
|
BaseURL: "http://localhost:8080",
|
||||||
BasePath: "/mcp",
|
BasePath: "/mcp",
|
||||||
|
// Read-only by default; uncomment to allow writes:
|
||||||
|
// ReadOnly: resolvemcp.Bool(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
securityList, _ := security.NewSecurityList(provider)
|
securityList, _ := security.NewSecurityList(provider)
|
||||||
@@ -444,12 +446,14 @@ The text appears in `list_tables` and `describe_table`. The server also sends a
|
|||||||
|
|
||||||
## Read-only mode
|
## Read-only mode
|
||||||
|
|
||||||
Set `Config.ReadOnly: true` to disable every write:
|
The server is **read-only unless you enable writes**: `Config.ReadOnly` is a `*bool` and an unset (nil) value means on. To allow inserts, updates and deletes:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{ReadOnly: true})
|
handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{ReadOnly: resolvemcp.Bool(false)})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
While read-only is on:
|
||||||
|
|
||||||
- The insert, update, delete and annotation tools are not registered, so the agent never sees them. `list_functions`/`call_function` are off too, because a registered function may change data, unless you set `AllowFunctionCalls` (below).
|
- The insert, update, delete and annotation tools are not registered, so the agent never sees them. `list_functions`/`call_function` are off too, because a registered function may change data, unless you set `AllowFunctionCalls` (below).
|
||||||
- `list_tables` and `describe_table` report only `select`; `describe_table` also sets `read_only: true` and lists no writable columns.
|
- `list_tables` and `describe_table` report only `select`; `describe_table` also sets `read_only: true` and lists no writable columns.
|
||||||
- The MCP server instructions (and the exported catalogue) say the server is read-only and tell the agent not to attempt writes.
|
- The MCP server instructions (and the exported catalogue) say the server is read-only and tell the agent not to attempt writes.
|
||||||
@@ -460,14 +464,14 @@ handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{ReadOnly: true})
|
|||||||
```go
|
```go
|
||||||
// Read-only server that may still run two named functions
|
// Read-only server that may still run two named functions
|
||||||
resolvemcp.Config{
|
resolvemcp.Config{
|
||||||
ReadOnly: true,
|
// ReadOnly is on by default
|
||||||
AllowFunctionCalls: true, // keep list_functions / call_function on a read-only server
|
AllowFunctionCalls: true, // keep list_functions / call_function on a read-only server
|
||||||
AllowedFunctions: []string{"report_totals", "search_customers"},
|
AllowedFunctions: []string{"report_totals", "search_customers"},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `AllowFunctionCalls` only matters with `ReadOnly`; without it, functions are always available. Set it only for functions that do not change data.
|
- `AllowFunctionCalls` only matters while read-only is on; with writes enabled (`ReadOnly: resolvemcp.Bool(false)`), functions are always available. Set it only for functions that do not change data.
|
||||||
- `AllowedFunctions` works with or without `ReadOnly`. When empty, every registered function is allowed. When set, only the named functions are listed and callable; any other is reported as `unknown function`, so its existence is not revealed. Per-function `Authorize` still applies on top.
|
- `AllowedFunctions` works in either mode. When empty, every registered function is allowed. When set, only the named functions are listed and callable; any other is reported as `unknown function`, so its existence is not revealed. Per-function `Authorize` still applies on top.
|
||||||
|
|
||||||
## MCP Tools
|
## MCP Tools
|
||||||
|
|
||||||
@@ -729,6 +733,7 @@ The handler resolves table names in priority order:
|
|||||||
|
|
||||||
## Breaking changes
|
## Breaking changes
|
||||||
|
|
||||||
|
- The server is read-only by default. Writes (insert/update/delete), annotations and function calls need `Config{ReadOnly: resolvemcp.Bool(false)}` (function calls can also be kept on a read-only server with `AllowFunctionCalls`).
|
||||||
- Per-model tools (`read_/create_/update_/delete_{schema}_{entity}`) and per-model resources are gone; use the meta tools.
|
- Per-model tools (`read_/create_/update_/delete_{schema}_{entity}`) and per-model resources are gone; use the meta tools.
|
||||||
- `Setup*` / `NewSSEServer` / `NewStreamableHTTPHandler` take a `*security.SecurityList` and require authentication. `OptionalAuth*` helpers were removed; `*Unauthenticated` variants exist for explicit opt-out.
|
- `Setup*` / `NewSSEServer` / `NewStreamableHTTPHandler` take a `*security.SecurityList` and require authentication. `OptionalAuth*` helpers were removed; `*Unauthenticated` variants exist for explicit opt-out.
|
||||||
- `resolvespec_annotate` is opt-in via `Config.EnableAnnotations`.
|
- `resolvespec_annotate` is opt-in via `Config.EnableAnnotations`.
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ func (h *Handler) BuildCatalog() Catalog {
|
|||||||
GeneratedAt: time.Now().UTC(),
|
GeneratedAt: time.Now().UTC(),
|
||||||
Server: h.name,
|
Server: h.name,
|
||||||
Version: h.version,
|
Version: h.version,
|
||||||
ReadOnly: h.config.ReadOnly,
|
ReadOnly: h.config.readOnly,
|
||||||
Guide: guideFor(h.config.ReadOnly, h.config.AllowFunctionCalls),
|
Guide: guideFor(h.config.readOnly, h.config.AllowFunctionCalls),
|
||||||
Limits: CatalogLimits{
|
Limits: CatalogLimits{
|
||||||
DefaultLimit: h.config.DefaultLimit,
|
DefaultLimit: h.config.DefaultLimit,
|
||||||
MaxLimit: h.config.MaxLimit,
|
MaxLimit: h.config.MaxLimit,
|
||||||
@@ -179,7 +179,7 @@ func (h *Handler) BuildCatalog() Catalog {
|
|||||||
for mt != nil && (mt.Kind() == reflect.Pointer || mt.Kind() == reflect.Slice) {
|
for mt != nil && (mt.Kind() == reflect.Pointer || mt.Kind() == reflect.Slice) {
|
||||||
mt = mt.Elem()
|
mt = mt.Elem()
|
||||||
}
|
}
|
||||||
if !h.config.ReadOnly && mt != nil && mt.Kind() == reflect.Struct {
|
if !h.config.readOnly && mt != nil && mt.Kind() == reflect.Struct {
|
||||||
for k := range reflectionJSONColumns(mt) {
|
for k := range reflectionJSONColumns(mt) {
|
||||||
writable[k] = true
|
writable[k] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
//
|
//
|
||||||
// The same guide is sent to MCP clients as the server instructions.
|
// The same guide is sent to MCP clients as the server instructions.
|
||||||
//
|
//
|
||||||
|
// The server is read-only by default (Config.ReadOnly nil means on); set
|
||||||
|
// ReadOnly: resolvemcp.Bool(false) to enable the write tools.
|
||||||
|
//
|
||||||
// # Setting it up
|
// # Setting it up
|
||||||
//
|
//
|
||||||
// handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{BaseURL: "http://localhost:8080"})
|
// handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{BaseURL: "http://localhost:8080"})
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func NewHandler(db common.Database, registry common.ModelRegistry, cfg Config) *
|
|||||||
db: db,
|
db: db,
|
||||||
registry: registry,
|
registry: registry,
|
||||||
hooks: NewHookRegistry(),
|
hooks: NewHookRegistry(),
|
||||||
mcpServer: server.NewMCPServer("resolvemcp", "1.0.0", server.WithInstructions(guideFor(cfg.ReadOnly, cfg.AllowFunctionCalls))),
|
mcpServer: server.NewMCPServer("resolvemcp", "1.0.0", server.WithInstructions(guideFor(cfg.withDefaults().readOnly, cfg.AllowFunctionCalls))),
|
||||||
config: cfg.withDefaults(),
|
config: cfg.withDefaults(),
|
||||||
confirms: newConfirmStore(),
|
confirms: newConfirmStore(),
|
||||||
name: "resolvemcp",
|
name: "resolvemcp",
|
||||||
@@ -57,7 +57,7 @@ func NewHandler(db common.Database, registry common.ModelRegistry, cfg Config) *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
registerMetaTools(h)
|
registerMetaTools(h)
|
||||||
if cfg.EnableAnnotations && !cfg.ReadOnly {
|
if cfg.EnableAnnotations && !h.config.readOnly {
|
||||||
registerAnnotationTool(h)
|
registerAnnotationTool(h)
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ func registerMetaTools(h *Handler) {
|
|||||||
mcp.WithBoolean("include_count", mcp.Description("Also return the total number of matching rows (slower on large tables).")),
|
mcp.WithBoolean("include_count", mcp.Description("Also return the total number of matching rows (slower on large tables).")),
|
||||||
), h.handleSelect)
|
), h.handleSelect)
|
||||||
|
|
||||||
if !h.config.ReadOnly {
|
if !h.config.readOnly {
|
||||||
registerWriteTools(h, tableArg, idArg, filtersArg, dryRunArg, confirmArg)
|
registerWriteTools(h, tableArg, idArg, filtersArg, dryRunArg, confirmArg)
|
||||||
}
|
}
|
||||||
if !h.config.ReadOnly || h.config.AllowFunctionCalls {
|
if !h.config.readOnly || h.config.AllowFunctionCalls {
|
||||||
registerFunctionTools(h, readOnly)
|
registerFunctionTools(h, readOnly)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func (h *Handler) opsFor(r modelregistry.ModelRules) []string {
|
|||||||
if r.CanRead {
|
if r.CanRead {
|
||||||
ops = append(ops, opSelect)
|
ops = append(ops, opSelect)
|
||||||
}
|
}
|
||||||
if h.config.ReadOnly {
|
if h.config.readOnly {
|
||||||
return ops
|
return ops
|
||||||
}
|
}
|
||||||
if r.CanCreate {
|
if r.CanCreate {
|
||||||
@@ -157,7 +157,7 @@ func (h *Handler) resolveTable(args map[string]any, op string) (schema, entity s
|
|||||||
if _, err := h.registry.GetModelByEntity(schema, entity); err != nil {
|
if _, err := h.registry.GetModelByEntity(schema, entity); err != nil {
|
||||||
return "", "", invalidArg("unknown table %q; see list_tables", truncate(table))
|
return "", "", invalidArg("unknown table %q; see list_tables", truncate(table))
|
||||||
}
|
}
|
||||||
if op != "" && op != opSelect && h.config.ReadOnly {
|
if op != "" && op != opSelect && h.config.readOnly {
|
||||||
return "", "", NewClientError(CodeForbidden, "this server is read-only: writes are disabled")
|
return "", "", NewClientError(CodeForbidden, "this server is read-only: writes are disabled")
|
||||||
}
|
}
|
||||||
if op != "" {
|
if op != "" {
|
||||||
@@ -212,7 +212,7 @@ func (h *Handler) handleDescribeTable(_ context.Context, req mcp.CallToolRequest
|
|||||||
modelType = modelType.Elem()
|
modelType = modelType.Elem()
|
||||||
}
|
}
|
||||||
writable := map[string]bool{}
|
writable := map[string]bool{}
|
||||||
if !h.config.ReadOnly && modelType != nil && modelType.Kind() == reflect.Struct {
|
if !h.config.readOnly && modelType != nil && modelType.Kind() == reflect.Struct {
|
||||||
for jsonKey := range reflection.BuildJSONToDBColumnMap(modelType) {
|
for jsonKey := range reflection.BuildJSONToDBColumnMap(modelType) {
|
||||||
writable[jsonKey] = true
|
writable[jsonKey] = true
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,7 @@ func (h *Handler) handleDescribeTable(_ context.Context, req mcp.CallToolRequest
|
|||||||
"relations": info.relationNames,
|
"relations": info.relationNames,
|
||||||
"writable_columns": writableNames,
|
"writable_columns": writableNames,
|
||||||
"operations": h.opsFor(rules),
|
"operations": h.opsFor(rules),
|
||||||
"read_only": h.config.ReadOnly,
|
"read_only": h.config.readOnly,
|
||||||
"filter_operators": filterOperators,
|
"filter_operators": filterOperators,
|
||||||
"limits": map[string]any{
|
"limits": map[string]any{
|
||||||
"default_limit": h.config.DefaultLimit,
|
"default_limit": h.config.DefaultLimit,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
func newReadOnlyHandler(t *testing.T) *Handler {
|
func newReadOnlyHandler(t *testing.T) *Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
h := NewHandler(database.NewPgSQLAdapter(nil), modelregistry.NewModelRegistry(),
|
h := NewHandler(database.NewPgSQLAdapter(nil), modelregistry.NewModelRegistry(),
|
||||||
Config{ReadOnly: true, EnableAnnotations: true})
|
Config{EnableAnnotations: true})
|
||||||
if err := h.RegisterModel("public", "items", &docItem{}); err != nil {
|
if err := h.RegisterModel("public", "items", &docItem{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ func newFnHandler(t *testing.T, cfg Config) *Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReadOnlyAllowFunctionCalls(t *testing.T) {
|
func TestReadOnlyAllowFunctionCalls(t *testing.T) {
|
||||||
h := newFnHandler(t, Config{ReadOnly: true, AllowFunctionCalls: true})
|
h := newFnHandler(t, Config{AllowFunctionCalls: true})
|
||||||
tools := h.mcpServer.ListTools()
|
tools := h.mcpServer.ListTools()
|
||||||
if tools["list_functions"] == nil || tools["call_function"] == nil {
|
if tools["list_functions"] == nil || tools["call_function"] == nil {
|
||||||
t.Error("function tools must be registered")
|
t.Error("function tools must be registered")
|
||||||
@@ -147,3 +147,22 @@ func TestAllowedFunctions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadOnlyDefaultsOnAndCanBeDisabled(t *testing.T) {
|
||||||
|
if !(Config{}).withDefaults().readOnly {
|
||||||
|
t.Error("ReadOnly must default to on")
|
||||||
|
}
|
||||||
|
if !(Config{ReadOnly: Bool(true)}).withDefaults().readOnly {
|
||||||
|
t.Error("explicit true")
|
||||||
|
}
|
||||||
|
if (Config{ReadOnly: Bool(false)}).withDefaults().readOnly {
|
||||||
|
t.Error("Bool(false) must enable writes")
|
||||||
|
}
|
||||||
|
h := NewHandler(database.NewPgSQLAdapter(nil), modelregistry.NewModelRegistry(), Config{ReadOnly: Bool(false)})
|
||||||
|
if h.mcpServer.ListTools()["insert_into_table"] == nil {
|
||||||
|
t.Error("write tools must register when ReadOnly is Bool(false)")
|
||||||
|
}
|
||||||
|
if strings.Contains(h.BuildCatalog().Guide, "READ-ONLY") {
|
||||||
|
t.Error("guide must not claim read-only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,17 +51,20 @@ type Config struct {
|
|||||||
// host, with at most 32 distinct base URLs cached; prefer setting BaseURL.
|
// host, with at most 32 distinct base URLs cached; prefer setting BaseURL.
|
||||||
AllowedHosts []string
|
AllowedHosts []string
|
||||||
|
|
||||||
// ReadOnly disables every write. The insert, update, delete and annotation tools are not
|
// ReadOnly disables every write and is ON when left nil: set it to Bool(false) to allow
|
||||||
// registered, list_tables and describe_table report only the select operation (no
|
// writes. When on, the insert, update, delete and annotation tools are not registered,
|
||||||
// writable columns), a write attempted anyway is refused with a "forbidden" error, and
|
// list_tables and describe_table report only the select operation (no writable columns),
|
||||||
// the server instructions tell the agent it cannot write. list_functions/call_function
|
// a write attempted anyway is refused with a "forbidden" error, and the server
|
||||||
// are also off, because a registered function may change data, unless AllowFunctionCalls
|
// instructions tell the agent it cannot write. list_functions/call_function are also
|
||||||
// is set.
|
// off, because a registered function may change data, unless AllowFunctionCalls is set.
|
||||||
ReadOnly bool
|
ReadOnly *bool
|
||||||
|
|
||||||
|
// readOnly is ReadOnly after defaults (nil means true).
|
||||||
|
readOnly bool
|
||||||
|
|
||||||
// AllowFunctionCalls keeps list_functions and call_function available on a ReadOnly
|
// AllowFunctionCalls keeps list_functions and call_function available on a ReadOnly
|
||||||
// server. Only set it for functions that do not change data; pair it with
|
// server. Only set it for functions that do not change data; pair it with
|
||||||
// AllowedFunctions to name them. It has no effect when ReadOnly is false (functions are
|
// AllowedFunctions to name them. It has no effect when writes are enabled (ReadOnly set to Bool(false)) (functions are
|
||||||
// always available then).
|
// always available then).
|
||||||
AllowFunctionCalls bool
|
AllowFunctionCalls bool
|
||||||
|
|
||||||
@@ -77,6 +80,9 @@ type Config struct {
|
|||||||
EnableAnnotations bool
|
EnableAnnotations bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bool returns a pointer to v, for the optional boolean fields of Config.
|
||||||
|
func Bool(v bool) *bool { return &v }
|
||||||
|
|
||||||
// withDefaults fills the zero limit fields.
|
// withDefaults fills the zero limit fields.
|
||||||
func (c Config) withDefaults() Config {
|
func (c Config) withDefaults() Config {
|
||||||
def := func(v *int, d int) {
|
def := func(v *int, d int) {
|
||||||
@@ -93,6 +99,7 @@ func (c Config) withDefaults() Config {
|
|||||||
if c.DefaultLimit > c.MaxLimit {
|
if c.DefaultLimit > c.MaxLimit {
|
||||||
c.DefaultLimit = c.MaxLimit
|
c.DefaultLimit = c.MaxLimit
|
||||||
}
|
}
|
||||||
|
c.readOnly = c.ReadOnly == nil || *c.ReadOnly
|
||||||
if c.QueryTimeout <= 0 {
|
if c.QueryTimeout <= 0 {
|
||||||
c.QueryTimeout = 30 * time.Second
|
c.QueryTimeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ func TestAnnotationToolIsOptIn(t *testing.T) {
|
|||||||
if h.mcpServer.GetTool(annotationToolName) != nil {
|
if h.mcpServer.GetTool(annotationToolName) != nil {
|
||||||
t.Fatal("annotation tool must be off by default")
|
t.Fatal("annotation tool must be off by default")
|
||||||
}
|
}
|
||||||
on := NewHandler(h.db, modelregistry.NewModelRegistry(), Config{EnableAnnotations: true})
|
on := NewHandler(h.db, modelregistry.NewModelRegistry(), Config{EnableAnnotations: true, ReadOnly: Bool(false)})
|
||||||
if on.mcpServer.GetTool(annotationToolName) == nil {
|
if on.mcpServer.GetTool(annotationToolName) == nil {
|
||||||
t.Fatal("annotation tool missing when enabled")
|
t.Fatal("annotation tool missing when enabled")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func newTxHarness(t *testing.T) (*Handler, sqlmock.Sqlmock, context.Context) {
|
|||||||
// connection and fails on the context timeout.
|
// connection and fails on the context timeout.
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
h := NewHandler(database.NewPgSQLAdapter(db), modelregistry.NewModelRegistry(), Config{})
|
h := NewHandler(database.NewPgSQLAdapter(db), modelregistry.NewModelRegistry(), Config{ReadOnly: Bool(false)})
|
||||||
if err := h.RegisterModel("public", "items", &txItem{}); err != nil {
|
if err := h.RegisterModel("public", "items", &txItem{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user