feat(resolvemcp): add model descriptions, usage guide and catalogue export

- modelregistry: ModelInfo (description, purpose, tags, column docs) with
  external JSON loader, Describer fallback and gorm/bun/comment tag support
- resolvemcp: surface descriptions in list_tables and describe_table, send a
  usage guide as MCP server instructions, add package docs
- add BuildCatalog/ExportCatalog to write a JSON or Markdown API catalogue
- document the descriptions map and catalogue in the README
This commit is contained in:
Hein
2026-10-07 14:09:37 +02:00
parent 234aac9770
commit 431b674162
12 changed files with 816 additions and 20 deletions
+32
View File
@@ -0,0 +1,32 @@
// Package modelregistry is the shared catalogue of the Go model structs that the
// ResolveSpec front ends (resolvespec, restheadspec, websocketspec, mqttspec,
// resolvemcp, ...) expose as database entities.
//
// A registry maps a model name ("schema.entity") to a struct type and holds:
// - ModelRules: which operations (read/create/update/delete, public or not)
// are allowed, and whether security checks are disabled.
// - ModelInfo: optional documentation (description, purpose, tags, per-column
// descriptions) meant for humans and AI agents. It never affects queries or
// permissions.
//
// Register models on a registry created with NewModelRegistry, or through the
// package-level functions that use the default registry:
//
// reg := modelregistry.NewModelRegistry()
// _ = reg.RegisterModelWithRules("public.users", User{}, modelregistry.DefaultModelRules())
// reg.SetModelInfo("public.users", modelregistry.ModelInfo{
// Description: "Application accounts",
// Purpose: "Look up who a person is; never store credentials here",
// Columns: map[string]string{"email": "Login address, unique"},
// })
//
// Descriptions come from, in priority order:
// 1. ModelInfo set with SetModelInfo or loaded from an external JSON map with
// LoadModelInfoFile (the map can be maintained outside the Go code).
// 2. The model's Describer (ModelDescription() string) for the description.
// 3. Struct tags, read per column by FieldComment: comment, note, desc or
// description tags, then "comment:" inside the gorm or bun tag.
//
// Models must be non-pointer structs; pointers, slices and arrays of structs are
// unwrapped on registration. All registry methods are safe for concurrent use.
package modelregistry
+172
View File
@@ -0,0 +1,172 @@
package modelregistry
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
)
// ModelInfo is human/AI-facing documentation for a registered model: what it
// is for and what its columns mean. It is optional and has no effect on
// permissions or queries.
type ModelInfo struct {
// Description says what the model/table holds.
Description string `json:"description,omitempty"`
// Purpose says why it exists / when an agent should use it.
Purpose string `json:"purpose,omitempty"`
// Tags are free-form labels (e.g. "billing", "pii").
Tags []string `json:"tags,omitempty"`
// Columns maps a JSON column name to its description.
Columns map[string]string `json:"columns,omitempty"`
}
// IsZero reports whether the info carries no documentation.
func (i ModelInfo) IsZero() bool {
return i.Description == "" && i.Purpose == "" && len(i.Tags) == 0 && len(i.Columns) == 0
}
// Describer can be implemented by a model to document itself. It is the
// fallback used when no ModelInfo description was registered or loaded.
// (The method is not called Description so models may keep a Description field.)
type Describer interface {
ModelDescription() string
}
// commentTagKeys are the standalone struct tags read as a column description,
// in priority order.
var commentTagKeys = []string{"comment", "note", "desc", "description"}
// FieldComment returns the description of a struct field from its tags. Order:
// standalone comment/note/desc/description tags, then a "comment:" entry inside
// the gorm tag (semicolon separated), then inside the bun tag (comma separated).
// It returns "" when the field carries none.
func FieldComment(sf reflect.StructField) string {
for _, key := range commentTagKeys {
if v := strings.TrimSpace(sf.Tag.Get(key)); v != "" {
return v
}
}
if v := tagOption(sf.Tag.Get("gorm"), ';', "comment:"); v != "" {
return v
}
return tagOption(sf.Tag.Get("bun"), ',', "comment:")
}
func tagOption(tag string, sep byte, key string) string {
for _, part := range strings.Split(tag, string(sep)) {
part = strings.TrimSpace(part)
if len(part) >= len(key) && strings.EqualFold(part[:len(key)], key) {
return strings.Trim(strings.TrimSpace(part[len(key):]), `'"`)
}
}
return ""
}
// SetModelInfo stores documentation for a model name ("schema.entity"). The
// model does not have to be registered yet, so descriptions can be loaded
// before or after registration. Any previous info for the name is replaced.
func (r *DefaultModelRegistry) SetModelInfo(name string, info ModelInfo) {
r.mutex.Lock()
defer r.mutex.Unlock()
if r.info == nil {
r.info = make(map[string]ModelInfo)
}
r.info[name] = cloneInfo(info)
}
// GetModelInfo returns the documentation stored with SetModelInfo (or loaded
// from a descriptions file), without any fallback.
func (r *DefaultModelRegistry) GetModelInfo(name string) (ModelInfo, bool) {
r.mutex.RLock()
defer r.mutex.RUnlock()
info, ok := r.info[name]
return cloneInfo(info), ok
}
// RegisterModelWithInfo registers a model together with its documentation.
func (r *DefaultModelRegistry) RegisterModelWithInfo(name string, model interface{}, info ModelInfo) error {
if err := r.RegisterModel(name, model); err != nil {
return err
}
r.SetModelInfo(name, info)
return nil
}
// ResolveModelInfo returns the effective documentation for a registered model.
// Stored/loaded info wins; an empty Description falls back to the model's
// Describer. Column descriptions are not resolved here: use the stored map and
// fall back to FieldComment per field.
func (r *DefaultModelRegistry) ResolveModelInfo(name string) ModelInfo {
info, _ := r.GetModelInfo(name)
if info.Description == "" {
if model, err := r.GetModel(name); err == nil {
info.Description = describerText(model)
}
}
return info
}
func describerText(model interface{}) (text string) {
defer func() {
if recover() != nil {
text = ""
}
}()
if d, ok := model.(Describer); ok {
return strings.TrimSpace(d.ModelDescription())
}
if t := reflect.TypeOf(model); t != nil && t.Kind() != reflect.Pointer {
if d, ok := reflect.New(t).Interface().(Describer); ok {
return strings.TrimSpace(d.ModelDescription())
}
}
return ""
}
// LoadModelInfo reads an external descriptions map from r and applies it. The
// JSON is an object keyed by model name:
//
// {"public.users": {"description": "...", "purpose": "...", "tags": ["x"],
// "columns": {"email": "Login address"}}}
//
// Entries replace any existing info for the same name and take precedence over
// the model's Describer and its struct-tag comments. It returns the number of
// models loaded.
func (r *DefaultModelRegistry) LoadModelInfo(src io.Reader) (int, error) {
var m map[string]ModelInfo
dec := json.NewDecoder(src)
dec.DisallowUnknownFields()
if err := dec.Decode(&m); err != nil {
return 0, fmt.Errorf("modelregistry: decode model info: %w", err)
}
for name, info := range m {
r.SetModelInfo(name, info)
}
return len(m), nil
}
// LoadModelInfoFile is LoadModelInfo reading from a JSON file.
func (r *DefaultModelRegistry) LoadModelInfoFile(path string) (int, error) {
f, err := os.Open(filepath.Clean(path)) //nolint:gosec // operator-supplied descriptions file
if err != nil {
return 0, fmt.Errorf("modelregistry: %w", err)
}
defer f.Close()
return r.LoadModelInfo(f)
}
func cloneInfo(in ModelInfo) ModelInfo {
out := in
out.Tags = append([]string(nil), in.Tags...)
if in.Columns != nil {
out.Columns = make(map[string]string, len(in.Columns))
for k, v := range in.Columns {
out.Columns[k] = v
}
}
return out
}
+68
View File
@@ -0,0 +1,68 @@
package modelregistry
import (
"reflect"
"strings"
"testing"
)
type infoModel struct {
ID int `json:"id" gorm:"primaryKey;comment:Row id"`
Email string `json:"email" bun:"email,comment:Login address"`
Name string `json:"name" note:"Display name"`
Plain string `json:"plain"`
}
func (infoModel) ModelDescription() string { return " From the model " }
func TestFieldComment(t *testing.T) {
typ := reflect.TypeOf(infoModel{})
want := map[string]string{"ID": "Row id", "Email": "Login address", "Name": "Display name", "Plain": ""}
for field, exp := range want {
sf, _ := typ.FieldByName(field)
if got := FieldComment(sf); got != exp {
t.Errorf("%s = %q, want %q", field, got, exp)
}
}
}
func TestModelInfoPrecedence(t *testing.T) {
r := NewModelRegistry()
if err := r.RegisterModel("public.items", infoModel{}); err != nil {
t.Fatal(err)
}
if got := r.ResolveModelInfo("public.items").Description; got != "From the model" {
t.Errorf("describer fallback = %q", got)
}
n, err := r.LoadModelInfo(strings.NewReader(
`{"public.items":{"description":"From file","tags":["a"],"columns":{"email":"Mail"}},"public.later":{"purpose":"p"}}`))
if err != nil || n != 2 {
t.Fatalf("load n=%d err=%v", n, err)
}
info := r.ResolveModelInfo("public.items")
if info.Description != "From file" || info.Columns["email"] != "Mail" || len(info.Tags) != 1 {
t.Errorf("file info = %+v", info)
}
if _, ok := r.GetModelInfo("public.later"); !ok {
t.Error("info for a not-yet-registered model must be kept")
}
// returned info is a copy
info.Columns["email"] = "changed"
if got, _ := r.GetModelInfo("public.items"); got.Columns["email"] != "Mail" {
t.Error("GetModelInfo leaked internal map")
}
}
func TestLoadModelInfoRejectsBadInput(t *testing.T) {
r := NewModelRegistry()
for _, in := range []string{`not json`, `{"a":{"descripton":"typo"}}`} {
if _, err := r.LoadModelInfo(strings.NewReader(in)); err == nil {
t.Errorf("expected error for %q", in)
}
}
if _, err := r.LoadModelInfoFile("/nonexistent/x.json"); err == nil {
t.Error("expected error for missing file")
}
}
+1
View File
@@ -41,6 +41,7 @@ func DefaultModelRules() ModelRules {
type DefaultModelRegistry struct {
models map[string]interface{}
rules map[string]ModelRules
info map[string]ModelInfo
mutex sync.RWMutex
}
+50
View File
@@ -392,6 +392,56 @@ handler.SetModelRules("public", "users", modelregistry.ModelRules{
---
## Describing the API for agents
Give agents context about what each table is for:
```go
// 1. Explicitly, in code
handler.SetModelDescription("public", "users", modelregistry.ModelInfo{
Description: "Application accounts",
Purpose: "Look up who a person is",
Tags: []string{"identity"},
Columns: map[string]string{"email": "Login address, unique"},
})
// 2. From an external JSON map (keyed by "schema.entity"); entries here win
n, err := handler.LoadModelDescriptions("docs/model-descriptions.json")
```
Example `docs/model-descriptions.json` (every key is optional; unknown keys are rejected):
```json
{
"public.users": {
"description": "Application accounts, one row per person who can sign in.",
"purpose": "Look up who someone is. Use public.orders for what they bought.",
"tags": ["identity", "pii"],
"columns": {
"id": "Internal account id",
"email": "Login address, unique and lower-cased",
"created_at": "When the account was created (UTC)"
}
},
"public.orders": {
"description": "Customer orders.",
"columns": {
"status": "One of: pending, paid, shipped, cancelled"
}
}
}
```
Keys are `schema.entity` names as registered with `RegisterModel`. Column keys are the JSON column names shown by `describe_table`. An entry replaces any info set earlier for that table, and columns it leaves out still fall back to field tags.
Fallbacks when nothing is set for a table or column, in order: the model's `ModelDescription() string` method (table), then field tags (column): `comment`, `note`, `desc` or `description` tags, then `comment:` inside the `gorm` or `bun` tag.
The text appears in `list_tables` and `describe_table`. The server also sends a short usage guide as MCP `instructions` on connect.
### Catalogue file
`handler.ExportCatalog(path)` writes the usage guide, tools, limits and every table (columns, types, keys, relations, allowed operations, descriptions) to disk, JSON for a `.json` path and Markdown otherwise. The file is replaced atomically. It lists every table with at least one allowed operation, regardless of caller, so keep it out of public directories. Call it after registering models (for example at startup, or from a `go generate` step).
## 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).
+302
View File
@@ -0,0 +1,302 @@
package resolvemcp
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"time"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
// usageGuide is the short agent-facing guide sent as the MCP server instructions and
// embedded in the exported catalogue. It is generic: it never mentions concrete models.
const usageGuide = `This server exposes database tables through a fixed set of tools.
1. Call list_tables to see the tables you may use, what they hold and the operations allowed.
2. Call describe_table for a table before using it: columns, types, primary key, relations (preloadable), writable columns and limits.
3. Read with select_table (filters, sort, columns, preloads). Results are paged; use limit/offset or cursors, and include_count only when you need a total.
4. Write with insert_into_table, update_table, delete_from_table. Address one row by id, or several by filters. A filter-based write first returns a preview; repeat the call with the confirm_token to apply it (dry_run only previews).
5. Use list_functions / call_function for registered functions.
Read the error message when a call fails: it says which argument was wrong.`
// Catalog is a snapshot of what the server offers: the usage guide, the tools, the limits
// and every table with its columns, relations, allowed operations and descriptions.
type Catalog struct {
GeneratedAt time.Time `json:"generated_at"`
Server string `json:"server"`
Version string `json:"version"`
Guide string `json:"guide"`
Limits CatalogLimits `json:"limits"`
Tools []CatalogTool `json:"tools"`
Tables []CatalogTable `json:"tables"`
}
// CatalogLimits mirrors the configured server limits.
type CatalogLimits struct {
DefaultLimit int `json:"default_limit"`
MaxLimit int `json:"max_limit"`
MaxOffset int `json:"max_offset"`
MaxBatch int `json:"max_batch"`
MaxPreloadDepth int `json:"max_preload_depth"`
MaxWriteRows int `json:"max_write_rows"`
}
// CatalogTool is one MCP tool.
type CatalogTool struct {
Name string `json:"name"`
Description string `json:"description"`
}
// CatalogTable is one table in the catalogue.
type CatalogTable struct {
Table string `json:"table"`
Description string `json:"description,omitempty"`
Purpose string `json:"purpose,omitempty"`
Tags []string `json:"tags,omitempty"`
Operations []string `json:"operations"`
PrimaryKey string `json:"primary_key,omitempty"`
Columns []CatalogColumn `json:"columns"`
Relations []string `json:"relations,omitempty"`
}
// CatalogColumn is one column of a table.
type CatalogColumn struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Nullable bool `json:"nullable"`
PrimaryKey bool `json:"primary_key,omitempty"`
Unique bool `json:"unique,omitempty"`
Writable bool `json:"writable"`
Description string `json:"description,omitempty"`
}
// modelDocs returns the effective documentation of a table. Registry info (including a
// loaded descriptions file) wins, then the model's ModelDescription().
func (h *Handler) modelDocs(schema, entity string) modelregistry.ModelInfo {
if reg, ok := h.registry.(*modelregistry.DefaultModelRegistry); ok {
return reg.ResolveModelInfo(buildModelName(schema, entity))
}
return modelregistry.ModelInfo{}
}
// columnDescription picks a column's description: the registry/file map first, then the
// struct-tag comment.
func columnDescription(docs modelregistry.ModelInfo, c columnInfo) string {
if d := docs.Columns[c.jsonName]; d != "" {
return d
}
return c.comment
}
// SetModelDescription stores documentation for a registered or soon-to-be-registered table.
// It returns an error when the handler's registry does not keep model info.
func (h *Handler) SetModelDescription(schema, entity string, info modelregistry.ModelInfo) error {
reg, ok := h.registry.(*modelregistry.DefaultModelRegistry)
if !ok {
return fmt.Errorf("resolvemcp: registry does not support model descriptions (use NewHandlerWithGORM/Bun/DB)")
}
reg.SetModelInfo(buildModelName(schema, entity), info)
return nil
}
// LoadModelDescriptions loads an external JSON map of descriptions keyed by "schema.entity"
// (see modelregistry.LoadModelInfo for the format) and returns how many tables it covered.
// Loaded entries override the model's own comments.
func (h *Handler) LoadModelDescriptions(path string) (int, error) {
reg, ok := h.registry.(*modelregistry.DefaultModelRegistry)
if !ok {
return 0, fmt.Errorf("resolvemcp: registry does not support model descriptions (use NewHandlerWithGORM/Bun/DB)")
}
return reg.LoadModelInfoFile(path)
}
// BuildCatalog snapshots the server's tools and tables. Tables with no allowed operation are
// left out, exactly as list_tables does.
func (h *Handler) BuildCatalog() Catalog {
cat := Catalog{
GeneratedAt: time.Now().UTC(),
Server: h.name,
Version: h.version,
Guide: usageGuide,
Limits: CatalogLimits{
DefaultLimit: h.config.DefaultLimit,
MaxLimit: h.config.MaxLimit,
MaxOffset: h.config.MaxOffset,
MaxBatch: h.config.MaxBatch,
MaxPreloadDepth: h.config.MaxPreloadDepth,
MaxWriteRows: h.config.MaxWriteRows,
},
Tools: []CatalogTool{},
Tables: []CatalogTable{},
}
for name, tool := range h.mcpServer.ListTools() {
cat.Tools = append(cat.Tools, CatalogTool{Name: name, Description: tool.Tool.Description})
}
sort.Slice(cat.Tools, func(i, j int) bool { return cat.Tools[i].Name < cat.Tools[j].Name })
for name, model := range h.registry.GetAllModels() {
schema, entity, _ := splitTable(name)
rules := h.modelRules(schema, entity)
ops := opsFor(rules)
if len(ops) == 0 {
continue
}
info := buildModelInfo(schema, entity, model)
docs := h.modelDocs(schema, entity)
writable := map[string]bool{}
mt := reflect.TypeOf(model)
for mt != nil && (mt.Kind() == reflect.Pointer || mt.Kind() == reflect.Slice) {
mt = mt.Elem()
}
if mt != nil && mt.Kind() == reflect.Struct {
for k := range reflectionJSONColumns(mt) {
writable[k] = true
}
}
t := CatalogTable{
Table: info.fullName,
Description: docs.Description,
Purpose: docs.Purpose,
Tags: docs.Tags,
Operations: ops,
PrimaryKey: info.pkName,
Relations: info.relationNames,
Columns: make([]CatalogColumn, 0, len(info.columns)),
}
for _, c := range info.columns {
typ := c.sqlType
if typ == "" {
typ = c.goType
}
t.Columns = append(t.Columns, CatalogColumn{
Name: c.jsonName, Type: typ, Nullable: c.nullable, PrimaryKey: c.isPrimary,
Unique: c.isUnique, Writable: writable[c.jsonName], Description: columnDescription(docs, c),
})
}
cat.Tables = append(cat.Tables, t)
}
sort.Slice(cat.Tables, func(i, j int) bool { return cat.Tables[i].Table < cat.Tables[j].Table })
return cat
}
// ExportCatalog writes the catalogue to path. A ".json" extension writes JSON; anything
// else writes Markdown. The file is replaced atomically (written to a temp file in the same
// directory, then renamed) and created with mode 0600. It lists every table the registry
// allows any operation on, regardless of caller, so keep it out of public directories.
func (h *Handler) ExportCatalog(path string) error {
cat := h.BuildCatalog()
var data []byte
if strings.EqualFold(filepath.Ext(path), ".json") {
b, err := json.MarshalIndent(cat, "", " ")
if err != nil {
return err
}
data = b
data = append(data, '\n')
} else {
data = []byte(cat.Markdown())
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("resolvemcp: export catalog: %w", err)
}
tmp, err := os.CreateTemp(dir, ".catalog-*")
if err != nil {
return fmt.Errorf("resolvemcp: export catalog: %w", err)
}
tmpName := tmp.Name()
_, werr := tmp.Write(data)
cerr := tmp.Close()
if werr == nil {
werr = cerr
}
if werr == nil {
werr = os.Rename(tmpName, path)
}
if werr != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("resolvemcp: export catalog: %w", werr)
}
return nil
}
// Markdown renders the catalogue as a Markdown document.
func (c Catalog) Markdown() string {
var sb strings.Builder
fmt.Fprintf(&sb, "# %s API catalogue\n\nGenerated %s.\n\n", c.Server, c.GeneratedAt.Format(time.RFC3339))
sb.WriteString("## How to use\n\n" + c.Guide + "\n\n")
fmt.Fprintf(&sb, "## Limits\n\ndefault limit %d, max limit %d, max offset %d, max batch %d, max preload depth %d, max rows per filter write %d.\n\n",
c.Limits.DefaultLimit, c.Limits.MaxLimit, c.Limits.MaxOffset, c.Limits.MaxBatch, c.Limits.MaxPreloadDepth, c.Limits.MaxWriteRows)
sb.WriteString("## Tools\n\n")
for _, t := range c.Tools {
fmt.Fprintf(&sb, "- `%s`: %s\n", t.Name, oneLine(t.Description))
}
sb.WriteString("\n## Tables\n\n")
if len(c.Tables) == 0 {
sb.WriteString("No tables are registered.\n")
}
for i := range c.Tables {
t := &c.Tables[i]
fmt.Fprintf(&sb, "### %s\n\n", t.Table)
if t.Description != "" {
sb.WriteString(t.Description + "\n\n")
}
if t.Purpose != "" {
sb.WriteString("Purpose: " + t.Purpose + "\n\n")
}
if len(t.Tags) > 0 {
sb.WriteString("Tags: " + strings.Join(t.Tags, ", ") + "\n\n")
}
fmt.Fprintf(&sb, "Operations: %s", strings.Join(t.Operations, ", "))
if t.PrimaryKey != "" {
fmt.Fprintf(&sb, " · Primary key: `%s`", t.PrimaryKey)
}
sb.WriteString("\n\n| Column | Type | Flags | Description |\n|---|---|---|---|\n")
for _, col := range t.Columns {
var flags []string
if col.PrimaryKey {
flags = append(flags, "pk")
}
if col.Unique {
flags = append(flags, "unique")
}
if col.Nullable {
flags = append(flags, "nullable")
}
if !col.Writable {
flags = append(flags, "read-only")
}
fmt.Fprintf(&sb, "| `%s` | %s | %s | %s |\n", col.Name, mdCell(col.Type), strings.Join(flags, ", "), mdCell(col.Description))
}
if len(t.Relations) > 0 {
sb.WriteString("\nRelations (preloadable): " + strings.Join(t.Relations, ", ") + "\n")
}
sb.WriteString("\n")
}
return sb.String()
}
func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func mdCell(s string) string {
return strings.ReplaceAll(oneLine(s), "|", `\|`)
}
// reflectionJSONColumns returns the JSON names of the columns a write may set.
func reflectionJSONColumns(t reflect.Type) map[string]string {
return reflection.BuildJSONToDBColumnMap(t)
}
+126
View File
@@ -0,0 +1,126 @@
package resolvemcp
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
)
type docItem struct {
ID int `json:"id" bun:"id,pk"`
Email string `json:"email" bun:"email,comment:Tag comment"`
Name string `json:"name" bun:"name" note:"Name from tag"`
}
func (docItem) ModelDescription() string { return "Model-level fallback" }
func newDocHandler(t *testing.T) *Handler {
t.Helper()
h := NewHandler(database.NewPgSQLAdapter(nil), modelregistry.NewModelRegistry(), Config{})
if err := h.RegisterModel("public", "items", &docItem{}); err != nil {
t.Fatal(err)
}
hidden := modelregistry.ModelRules{} // no operation allowed
if err := h.RegisterModelWithRules("public", "secret", &docItem{}, hidden); err != nil {
t.Fatal(err)
}
return h
}
func TestCatalogDescriptionsPrecedence(t *testing.T) {
h := newDocHandler(t)
cat := h.BuildCatalog()
if len(cat.Tables) != 1 || cat.Tables[0].Table != "public.items" {
t.Fatalf("tables = %+v (hidden table must be left out)", cat.Tables)
}
tb := cat.Tables[0]
if tb.Description != "Model-level fallback" {
t.Errorf("fallback description = %q", tb.Description)
}
col := map[string]string{}
for _, c := range tb.Columns {
col[c.Name] = c.Description
}
if col["email"] != "Tag comment" || col["name"] != "Name from tag" {
t.Errorf("tag comments = %v", col)
}
path := filepath.Join(t.TempDir(), "desc.json")
if err := os.WriteFile(path, []byte(`{"public.items":{"description":"From file","columns":{"email":"File email"}}}`), 0o600); err != nil {
t.Fatal(err)
}
if n, err := h.LoadModelDescriptions(path); err != nil || n != 1 {
t.Fatalf("load n=%d err=%v", n, err)
}
tb = h.BuildCatalog().Tables[0]
if tb.Description != "From file" {
t.Errorf("file must win, got %q", tb.Description)
}
for _, c := range tb.Columns {
switch c.Name {
case "email":
if c.Description != "File email" {
t.Errorf("email = %q", c.Description)
}
case "name":
if c.Description != "Name from tag" {
t.Errorf("name must fall back to tag, got %q", c.Description)
}
}
}
}
func TestExportCatalogFiles(t *testing.T) {
h := newDocHandler(t)
dir := t.TempDir()
md := filepath.Join(dir, "sub", "catalog.md")
if err := h.ExportCatalog(md); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(md)
for _, want := range []string{"# resolvemcp API catalogue", "### public.items", "Model-level fallback", "`list_tables`", "Tag comment"} {
if !strings.Contains(string(b), want) {
t.Errorf("markdown missing %q", want)
}
}
if strings.Contains(string(b), "public.secret") {
t.Error("table without operations leaked into the catalogue")
}
js := filepath.Join(dir, "catalog.json")
if err := h.ExportCatalog(js); err != nil {
t.Fatal(err)
}
var cat Catalog
b, _ = os.ReadFile(js)
if err := json.Unmarshal(b, &cat); err != nil || len(cat.Tables) != 1 || len(cat.Tools) == 0 {
t.Fatalf("json catalog bad: err=%v %+v", err, cat)
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".catalog-") {
t.Errorf("temp file left behind: %s", e.Name())
}
}
}
func TestDescribeAndListIncludeDescriptions(t *testing.T) {
h := newDocHandler(t)
res, _ := h.handleListTables(nil, callReq(nil))
tables, _ := payload(t, res)["tables"].([]any)
if len(tables) != 1 || tables[0].(map[string]any)["description"] != "Model-level fallback" {
t.Errorf("list_tables = %v", tables)
}
res, _ = h.handleDescribeTable(nil, callReq(map[string]any{"table": "public.items"}))
p := payload(t, res)
if p["description"] != "Model-level fallback" {
t.Errorf("describe_table description = %v", p["description"])
}
}
+47
View File
@@ -0,0 +1,47 @@
// Package resolvemcp exposes registered database models as Model Context Protocol (MCP)
// tools over HTTP (SSE and streamable HTTP), so an AI agent can discover, read and
// change data without a tool per table.
//
// # How an agent uses it
//
// The tool set is fixed and does not grow with the models:
//
// list_tables tables the caller may use, their description and allowed operations
// describe_table columns, types, keys, relations, writable fields, limits
// select_table read rows: filters, sort, columns, preloads, paging, cursors
// insert_into_table / update_table / delete_from_table
// writes; filter-based writes are previewed (dry_run) and need the
// confirm_token from the preview
// list_functions / call_function registered stored functions
// resolvespec_annotate optional free-text notes (Config.EnableAnnotations)
//
// The same guide is sent to MCP clients as the server instructions.
//
// # Setting it up
//
// handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{BaseURL: "http://localhost:8080"})
// handler.RegisterModel("public", "users", &User{})
//
// r := mux.NewRouter()
// resolvemcp.SetupMuxRoutes(r, handler, securityList) // requires an authenticated caller
//
// # Describing the API for agents
//
// Models are documented through the model registry (see package modelregistry):
// SetModelDescription / LoadModelDescriptions on the handler, a ModelDescription()
// method on the model, or comment tags on its fields (gorm/bun "comment:" or
// comment/note/desc tags). The descriptions show up in list_tables and
// describe_table.
//
// ExportCatalog writes the whole picture (guide, tools, limits, tables with columns,
// relations, operations and descriptions) to a JSON or Markdown file on disk, so
// agents and developers can learn the API without connecting:
//
// handler.ExportCatalog("docs/mcp-catalog.md")
//
// # Security
//
// Routes must be mounted behind Guard(securityList); the *Unauthenticated setup
// functions exist only for use behind another trusted layer. Per-entity rules come from
// modelregistry.ModelRules, and BeforeHandle/AfterHandle hooks can veto or audit any call.
package resolvemcp
+1 -1
View File
@@ -43,7 +43,7 @@ func NewHandler(db common.Database, registry common.ModelRegistry, cfg Config) *
db: db,
registry: registry,
hooks: NewHookRegistry(),
mcpServer: server.NewMCPServer("resolvemcp", "1.0.0"),
mcpServer: server.NewMCPServer("resolvemcp", "1.0.0", server.WithInstructions(usageGuide)),
config: cfg.withDefaults(),
confirms: newConfirmStore(),
name: "resolvemcp",
+8 -2
View File
@@ -157,13 +157,14 @@ func (h *Handler) resolveTable(args map[string]any, op string) (schema, entity s
func (h *Handler) handleListTables(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
type table struct {
Table string `json:"table"`
Description string `json:"description,omitempty"`
Operations []string `json:"operations"`
}
var tables []table
for name := range h.registry.GetAllModels() {
schema, entity, _ := splitTable(name)
if ops := opsFor(h.modelRules(schema, entity)); len(ops) > 0 {
tables = append(tables, table{Table: name, Operations: ops})
tables = append(tables, table{Table: name, Description: h.modelDocs(schema, entity).Description, Operations: ops})
}
}
sort.Slice(tables, func(i, j int) bool { return tables[i].Table < tables[j].Table })
@@ -184,6 +185,7 @@ func (h *Handler) handleDescribeTable(_ context.Context, req mcp.CallToolRequest
return toolError("describe_table", invalidArg("unknown table %q; see list_tables", buildModelName(schema, entity))), nil
}
info := buildModelInfo(schema, entity, model)
docs := h.modelDocs(schema, entity)
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice) {
@@ -203,6 +205,7 @@ func (h *Handler) handleDescribeTable(_ context.Context, req mcp.CallToolRequest
PrimaryKey bool `json:"primary_key,omitempty"`
Unique bool `json:"unique,omitempty"`
Writable bool `json:"writable"`
Comment string `json:"description,omitempty"`
}
cols := make([]column, 0, len(info.columns))
var writableNames []string
@@ -212,7 +215,7 @@ func (h *Handler) handleDescribeTable(_ context.Context, req mcp.CallToolRequest
typ = c.goType
}
w := writable[c.jsonName]
cols = append(cols, column{Name: c.jsonName, Type: typ, Nullable: c.nullable, PrimaryKey: c.isPrimary, Unique: c.isUnique, Writable: w})
cols = append(cols, column{Name: c.jsonName, Type: typ, Nullable: c.nullable, PrimaryKey: c.isPrimary, Unique: c.isUnique, Writable: w, Comment: columnDescription(docs, c)})
if w && !c.isPrimary {
writableNames = append(writableNames, c.jsonName)
}
@@ -220,6 +223,9 @@ func (h *Handler) handleDescribeTable(_ context.Context, req mcp.CallToolRequest
return marshalResult(map[string]any{
"success": true,
"table": info.fullName,
"description": docs.Description,
"purpose": docs.Purpose,
"tags": docs.Tags,
"primary_key": info.pkName,
"columns": cols,
"relations": info.relationNames,
-15
View File
@@ -1,18 +1,3 @@
// Package resolvemcp exposes registered database models as Model Context Protocol (MCP) tools
// and resources over HTTP/SSE transport.
//
// It mirrors the resolvespec package patterns:
// - Same model registration API
// - Same filter, sort, cursor pagination, preload options
// - Same lifecycle hook system
//
// Usage:
//
// handler := resolvemcp.NewHandlerWithGORM(db, resolvemcp.Config{BaseURL: "http://localhost:8080"})
// handler.RegisterModel("public", "users", &User{})
//
// r := mux.NewRouter()
// resolvemcp.SetupMuxRoutes(r, handler, securityList) // requires an authenticated caller
package resolvemcp
import (
+7
View File
@@ -9,6 +9,7 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
@@ -30,6 +31,7 @@ type columnInfo struct {
isUnique bool
isFK bool
nullable bool
comment string // from struct tags (gorm/bun comment:, comment/note/desc tags)
}
// buildModelInfo extracts column metadata and pre-builds the schema documentation string.
@@ -100,7 +102,12 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo {
isPrimary := d.SQLKey == "primary_key" ||
(info.pkName != "" && (sqlName == info.pkName || jsonName == info.pkName))
comment := ""
if found {
comment = modelregistry.FieldComment(fieldType)
}
ci := columnInfo{
comment: comment,
jsonName: jsonName,
sqlName: sqlName,
goType: goType,