mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +00:00
353 lines
8.2 KiB
Go
353 lines
8.2 KiB
Go
package resolvemcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
|
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
|
)
|
|
|
|
// modelInfo holds pre-computed metadata for a model used in tool descriptions.
|
|
type modelInfo struct {
|
|
fullName string // e.g. "public.users"
|
|
pkName string // e.g. "id"
|
|
columns []columnInfo
|
|
relationNames []string
|
|
schemaDoc string // formatted multi-line schema listing
|
|
}
|
|
|
|
type columnInfo struct {
|
|
jsonName string
|
|
sqlName string
|
|
goType string
|
|
sqlType string
|
|
isPrimary bool
|
|
isUnique bool
|
|
isFK bool
|
|
nullable bool
|
|
}
|
|
|
|
// buildModelInfo extracts column metadata and pre-builds the schema documentation string.
|
|
func buildModelInfo(schema, entity string, model interface{}) modelInfo {
|
|
info := modelInfo{
|
|
fullName: buildModelName(schema, entity),
|
|
pkName: reflection.GetPrimaryKeyName(model),
|
|
}
|
|
|
|
// Unwrap to base struct type
|
|
modelType := reflect.TypeOf(model)
|
|
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice) {
|
|
modelType = modelType.Elem()
|
|
}
|
|
if modelType == nil || modelType.Kind() != reflect.Struct {
|
|
return info
|
|
}
|
|
|
|
details := reflection.GetModelColumnDetail(reflect.New(modelType).Elem())
|
|
|
|
for _, d := range details {
|
|
// Derive the JSON name from the struct field
|
|
jsonName := fieldJSONName(modelType, d.Name)
|
|
if jsonName == "" || jsonName == "-" {
|
|
continue
|
|
}
|
|
|
|
// Skip relation fields (slice or user-defined struct that isn't time.Time).
|
|
fieldType, found := modelType.FieldByName(d.Name)
|
|
var unwrappedType reflect.Type
|
|
isSQLType := false
|
|
if found {
|
|
ft := fieldType.Type
|
|
if sqlType, ok := unwrapSQLType(ft); ok {
|
|
unwrappedType = sqlType
|
|
ft = sqlType
|
|
isSQLType = true
|
|
} else if ft.Kind() == reflect.Pointer {
|
|
ft = ft.Elem()
|
|
}
|
|
isUserStruct := ft.Kind() == reflect.Struct && ft.Name() != "Time" && ft.PkgPath() != ""
|
|
if !isSQLType && (ft.Kind() == reflect.Slice || isUserStruct) {
|
|
info.relationNames = append(info.relationNames, jsonName)
|
|
continue
|
|
}
|
|
}
|
|
|
|
sqlName := d.SQLName
|
|
if sqlName == "" {
|
|
sqlName = jsonName
|
|
}
|
|
|
|
// Derive Go type name, unwrapping pointer if needed.
|
|
goType := d.DataType
|
|
if isSQLType {
|
|
goType = unwrappedType.Name()
|
|
}
|
|
if goType == "" && found {
|
|
ft := fieldType.Type
|
|
for ft.Kind() == reflect.Pointer {
|
|
ft = ft.Elem()
|
|
}
|
|
goType = ft.Name()
|
|
}
|
|
|
|
// isPrimary: use both the GORM-tag detection and a name comparison against
|
|
// the known primary key (handles camelCase "primaryKey" tags correctly).
|
|
isPrimary := d.SQLKey == "primary_key" ||
|
|
(info.pkName != "" && (sqlName == info.pkName || jsonName == info.pkName))
|
|
|
|
ci := columnInfo{
|
|
jsonName: jsonName,
|
|
sqlName: sqlName,
|
|
goType: goType,
|
|
sqlType: d.SQLDataType,
|
|
isPrimary: isPrimary,
|
|
isUnique: d.SQLKey == "unique" || d.SQLKey == "uniqueindex",
|
|
isFK: d.SQLKey == "foreign_key",
|
|
nullable: isSQLType || d.Nullable,
|
|
}
|
|
info.columns = append(info.columns, ci)
|
|
}
|
|
|
|
info.schemaDoc = buildSchemaDoc(info)
|
|
return info
|
|
}
|
|
|
|
// unwrapSQLType returns the value type wrapped by a spectypes SQL value. These
|
|
// types are scalar columns even when their Go representation is a struct or a
|
|
// slice (for example, SqlNull[string] and SqlJSONB).
|
|
func unwrapSQLType(t reflect.Type) (reflect.Type, bool) {
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
|
|
if t.PkgPath() != "github.com/bitechdev/ResolveSpec/pkg/spectypes" {
|
|
return nil, false
|
|
}
|
|
if t.Kind() == reflect.Struct {
|
|
if value, ok := t.FieldByName("Val"); ok {
|
|
return value.Type, true
|
|
}
|
|
}
|
|
return t, true
|
|
}
|
|
|
|
// fieldJSONName returns the JSON tag name for a struct field, falling back to the field name.
|
|
func fieldJSONName(modelType reflect.Type, fieldName string) string {
|
|
field, ok := modelType.FieldByName(fieldName)
|
|
if !ok {
|
|
return fieldName
|
|
}
|
|
tag := field.Tag.Get("json")
|
|
if tag == "" {
|
|
return fieldName
|
|
}
|
|
parts := strings.SplitN(tag, ",", 2)
|
|
if parts[0] == "" {
|
|
return fieldName
|
|
}
|
|
return parts[0]
|
|
}
|
|
|
|
// buildSchemaDoc builds a human-readable column listing for inclusion in tool descriptions.
|
|
func buildSchemaDoc(info modelInfo) string {
|
|
if len(info.columns) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("Columns:\n")
|
|
for _, c := range info.columns {
|
|
line := fmt.Sprintf(" • %s", c.jsonName)
|
|
|
|
typeDesc := c.goType
|
|
if c.sqlType != "" {
|
|
typeDesc = c.sqlType
|
|
}
|
|
if typeDesc != "" {
|
|
line += fmt.Sprintf(" (%s)", typeDesc)
|
|
}
|
|
|
|
var flags []string
|
|
if c.isPrimary {
|
|
flags = append(flags, "primary key")
|
|
}
|
|
if c.isUnique {
|
|
flags = append(flags, "unique")
|
|
}
|
|
if c.isFK {
|
|
flags = append(flags, "foreign key")
|
|
}
|
|
if !c.nullable && !c.isPrimary {
|
|
flags = append(flags, "not null")
|
|
} else if c.nullable {
|
|
flags = append(flags, "nullable")
|
|
}
|
|
if len(flags) > 0 {
|
|
line += " — " + strings.Join(flags, ", ")
|
|
}
|
|
|
|
sb.WriteString(line + "\n")
|
|
}
|
|
|
|
if len(info.relationNames) > 0 {
|
|
sb.WriteString("Relations (preloadable): " + strings.Join(info.relationNames, ", ") + "\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// parseRequestOptions reads the paging, filter, sort, column and preload arguments shared
|
|
// by the read tools.
|
|
func parseRequestOptions(args map[string]interface{}) common.RequestOptions {
|
|
options := common.RequestOptions{}
|
|
|
|
if v, ok := args["limit"]; ok {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
limit := int(n)
|
|
options.Limit = &limit
|
|
case int:
|
|
options.Limit = &n
|
|
}
|
|
}
|
|
|
|
if v, ok := args["offset"]; ok {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
offset := int(n)
|
|
options.Offset = &offset
|
|
case int:
|
|
options.Offset = &n
|
|
}
|
|
}
|
|
|
|
if v, ok := args["cursor_forward"].(string); ok {
|
|
options.CursorForward = v
|
|
}
|
|
if v, ok := args["cursor_backward"].(string); ok {
|
|
options.CursorBackward = v
|
|
}
|
|
|
|
options.Columns = parseStringArray(args["columns"])
|
|
options.OmitColumns = parseStringArray(args["omit_columns"])
|
|
options.Filters = parseFilters(args["filters"])
|
|
options.Sort = parseSortOptions(args["sort"])
|
|
options.Preload = parsePreloadOptions(args["preloads"])
|
|
|
|
return options
|
|
}
|
|
|
|
func parseStringArray(raw interface{}) []string {
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
items, ok := raw.([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
if s, ok := item.(string); ok {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseFilters(raw interface{}) []common.FilterOption {
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
items, ok := raw.([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
result := make([]common.FilterOption, 0, len(items))
|
|
for _, item := range items {
|
|
b, err := json.Marshal(item)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var f common.FilterOption
|
|
if err := json.Unmarshal(b, &f); err != nil {
|
|
continue
|
|
}
|
|
if f.Column == "" || f.Operator == "" {
|
|
continue
|
|
}
|
|
if strings.EqualFold(f.LogicOperator, "or") {
|
|
f.LogicOperator = "OR"
|
|
} else {
|
|
f.LogicOperator = "AND"
|
|
}
|
|
result = append(result, f)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseSortOptions(raw interface{}) []common.SortOption {
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
items, ok := raw.([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
result := make([]common.SortOption, 0, len(items))
|
|
for _, item := range items {
|
|
b, err := json.Marshal(item)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var s common.SortOption
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
continue
|
|
}
|
|
if s.Column == "" {
|
|
continue
|
|
}
|
|
result = append(result, s)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parsePreloadOptions(raw interface{}) []common.PreloadOption {
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
items, ok := raw.([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
result := make([]common.PreloadOption, 0, len(items))
|
|
for _, item := range items {
|
|
b, err := json.Marshal(item)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var p common.PreloadOption
|
|
if err := json.Unmarshal(b, &p); err != nil {
|
|
continue
|
|
}
|
|
if p.Relation == "" {
|
|
continue
|
|
}
|
|
result = append(result, p)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// marshalResult marshals a value to JSON and returns it as an MCP text result.
|
|
func marshalResult(v interface{}) (*mcp.CallToolResult, error) {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("error marshaling result: %v", err)), nil
|
|
}
|
|
return mcp.NewToolResultText(string(b)), nil
|
|
}
|