mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-04-05 07:32:26 +00:00
* Implement hooks for CRUD operations: before/after handle, read, create, update, delete. * Introduce HookContext and HookRegistry for managing hooks. * Allow registration and execution of multiple hooks per operation. feat(resolvemcp): implement MCP tools for CRUD operations * Register tools for reading, creating, updating, and deleting records. * Define tool arguments and handle requests with appropriate responses. * Support for resource registration with metadata. fix(restheadspec): enhance cursor handling for joins * Improve cursor filter generation to support lateral joins. * Update join alias extraction to handle lateral joins correctly. * Ensure cursor filters do not contain empty comparisons. test(restheadspec): add tests for cursor filters and join alias extraction * Create tests for lateral join scenarios in cursor filter generation. * Validate join alias extraction for various join types, including lateral joins.
416 lines
11 KiB
Go
416 lines
11 KiB
Go
package resolvemcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
|
)
|
|
|
|
// toolName builds the MCP tool name for a given operation and model.
|
|
func toolName(operation, schema, entity string) string {
|
|
if schema == "" {
|
|
return fmt.Sprintf("%s_%s", operation, entity)
|
|
}
|
|
return fmt.Sprintf("%s_%s_%s", operation, schema, entity)
|
|
}
|
|
|
|
// registerModelTools registers the four CRUD tools and resource for a model.
|
|
func registerModelTools(h *Handler, schema, entity string, model interface{}) {
|
|
registerReadTool(h, schema, entity)
|
|
registerCreateTool(h, schema, entity)
|
|
registerUpdateTool(h, schema, entity)
|
|
registerDeleteTool(h, schema, entity)
|
|
registerModelResource(h, schema, entity)
|
|
|
|
logger.Info("[resolvemcp] Registered MCP tools for %s.%s", schema, entity)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Read tool
|
|
// --------------------------------------------------------------------------
|
|
|
|
func registerReadTool(h *Handler, schema, entity string) {
|
|
name := toolName("read", schema, entity)
|
|
description := fmt.Sprintf("Read records from %s", buildModelName(schema, entity))
|
|
|
|
tool := mcp.NewTool(name,
|
|
mcp.WithDescription(description),
|
|
mcp.WithString("id",
|
|
mcp.Description("Primary key of a single record to fetch (optional)"),
|
|
),
|
|
mcp.WithNumber("limit",
|
|
mcp.Description("Maximum number of records to return"),
|
|
),
|
|
mcp.WithNumber("offset",
|
|
mcp.Description("Number of records to skip"),
|
|
),
|
|
mcp.WithString("cursor_forward",
|
|
mcp.Description("Cursor value for the next page (primary key of last record on current page)"),
|
|
),
|
|
mcp.WithString("cursor_backward",
|
|
mcp.Description("Cursor value for the previous page"),
|
|
),
|
|
mcp.WithArray("columns",
|
|
mcp.Description("List of column names to include in the result"),
|
|
),
|
|
mcp.WithArray("omit_columns",
|
|
mcp.Description("List of column names to exclude from the result"),
|
|
),
|
|
mcp.WithArray("filters",
|
|
mcp.Description(`Array of filter objects. Each object: {"column":"name","operator":"=","value":"val","logic_operator":"AND|OR"}. Operators: =, !=, >, <, >=, <=, like, ilike, in, is_null, is_not_null`),
|
|
),
|
|
mcp.WithArray("sort",
|
|
mcp.Description(`Array of sort objects. Each object: {"column":"name","direction":"asc|desc"}`),
|
|
),
|
|
mcp.WithArray("preloads",
|
|
mcp.Description(`Array of relation preload objects. Each object: {"relation":"RelationName","columns":["col1"]}`),
|
|
),
|
|
)
|
|
|
|
h.mcpServer.AddTool(tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
id, _ := args["id"].(string)
|
|
options := parseRequestOptions(args)
|
|
|
|
data, metadata, err := h.executeRead(ctx, schema, entity, id, options)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
return marshalResult(map[string]interface{}{
|
|
"success": true,
|
|
"data": data,
|
|
"metadata": metadata,
|
|
})
|
|
})
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Create tool
|
|
// --------------------------------------------------------------------------
|
|
|
|
func registerCreateTool(h *Handler, schema, entity string) {
|
|
name := toolName("create", schema, entity)
|
|
description := fmt.Sprintf("Create one or more records in %s", buildModelName(schema, entity))
|
|
|
|
tool := mcp.NewTool(name,
|
|
mcp.WithDescription(description),
|
|
mcp.WithObject("data",
|
|
mcp.Description("Record fields to create (single object), or pass an array as the 'items' key"),
|
|
mcp.Required(),
|
|
),
|
|
)
|
|
|
|
h.mcpServer.AddTool(tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
data, ok := args["data"]
|
|
if !ok {
|
|
return mcp.NewToolResultError("missing required argument: data"), nil
|
|
}
|
|
|
|
result, err := h.executeCreate(ctx, schema, entity, data)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
return marshalResult(map[string]interface{}{
|
|
"success": true,
|
|
"data": result,
|
|
})
|
|
})
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Update tool
|
|
// --------------------------------------------------------------------------
|
|
|
|
func registerUpdateTool(h *Handler, schema, entity string) {
|
|
name := toolName("update", schema, entity)
|
|
description := fmt.Sprintf("Update an existing record in %s", buildModelName(schema, entity))
|
|
|
|
tool := mcp.NewTool(name,
|
|
mcp.WithDescription(description),
|
|
mcp.WithString("id",
|
|
mcp.Description("Primary key of the record to update"),
|
|
),
|
|
mcp.WithObject("data",
|
|
mcp.Description("Fields to update (non-null fields will be merged into the existing record)"),
|
|
mcp.Required(),
|
|
),
|
|
)
|
|
|
|
h.mcpServer.AddTool(tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
id, _ := args["id"].(string)
|
|
|
|
data, ok := args["data"]
|
|
if !ok {
|
|
return mcp.NewToolResultError("missing required argument: data"), nil
|
|
}
|
|
dataMap, ok := data.(map[string]interface{})
|
|
if !ok {
|
|
return mcp.NewToolResultError("data must be an object"), nil
|
|
}
|
|
|
|
result, err := h.executeUpdate(ctx, schema, entity, id, dataMap)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
return marshalResult(map[string]interface{}{
|
|
"success": true,
|
|
"data": result,
|
|
})
|
|
})
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Delete tool
|
|
// --------------------------------------------------------------------------
|
|
|
|
func registerDeleteTool(h *Handler, schema, entity string) {
|
|
name := toolName("delete", schema, entity)
|
|
description := fmt.Sprintf("Delete a record from %s by primary key", buildModelName(schema, entity))
|
|
|
|
tool := mcp.NewTool(name,
|
|
mcp.WithDescription(description),
|
|
mcp.WithString("id",
|
|
mcp.Description("Primary key of the record to delete"),
|
|
mcp.Required(),
|
|
),
|
|
)
|
|
|
|
h.mcpServer.AddTool(tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
id, _ := args["id"].(string)
|
|
|
|
result, err := h.executeDelete(ctx, schema, entity, id)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
return marshalResult(map[string]interface{}{
|
|
"success": true,
|
|
"data": result,
|
|
})
|
|
})
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Resource registration
|
|
// --------------------------------------------------------------------------
|
|
|
|
func registerModelResource(h *Handler, schema, entity string) {
|
|
resourceURI := buildModelName(schema, entity)
|
|
displayName := entity
|
|
if schema != "" {
|
|
displayName = schema + "." + entity
|
|
}
|
|
|
|
resource := mcp.NewResource(
|
|
resourceURI,
|
|
displayName,
|
|
mcp.WithResourceDescription(fmt.Sprintf("Database table: %s", displayName)),
|
|
mcp.WithMIMEType("application/json"),
|
|
)
|
|
|
|
h.mcpServer.AddResource(resource, func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
|
limit := 100
|
|
options := common.RequestOptions{Limit: &limit}
|
|
|
|
data, metadata, err := h.executeRead(ctx, schema, entity, "", options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"data": data,
|
|
"metadata": metadata,
|
|
}
|
|
jsonBytes, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error marshaling resource: %w", err)
|
|
}
|
|
|
|
return []mcp.ResourceContents{
|
|
mcp.TextResourceContents{
|
|
URI: req.Params.URI,
|
|
MIMEType: "application/json",
|
|
Text: string(jsonBytes),
|
|
},
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Argument parsing helpers
|
|
// --------------------------------------------------------------------------
|
|
|
|
// parseRequestOptions converts raw MCP tool arguments into common.RequestOptions.
|
|
func parseRequestOptions(args map[string]interface{}) common.RequestOptions {
|
|
options := common.RequestOptions{}
|
|
|
|
// limit
|
|
if v, ok := args["limit"]; ok {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
limit := int(n)
|
|
options.Limit = &limit
|
|
case int:
|
|
options.Limit = &n
|
|
}
|
|
}
|
|
|
|
// offset
|
|
if v, ok := args["offset"]; ok {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
offset := int(n)
|
|
options.Offset = &offset
|
|
case int:
|
|
options.Offset = &n
|
|
}
|
|
}
|
|
|
|
// cursor_forward / cursor_backward
|
|
if v, ok := args["cursor_forward"].(string); ok {
|
|
options.CursorForward = v
|
|
}
|
|
if v, ok := args["cursor_backward"].(string); ok {
|
|
options.CursorBackward = v
|
|
}
|
|
|
|
// columns
|
|
options.Columns = parseStringArray(args["columns"])
|
|
|
|
// omit_columns
|
|
options.OmitColumns = parseStringArray(args["omit_columns"])
|
|
|
|
// filters — marshal each item and unmarshal into FilterOption
|
|
options.Filters = parseFilters(args["filters"])
|
|
|
|
// sort
|
|
options.Sort = parseSortOptions(args["sort"])
|
|
|
|
// preloads
|
|
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
|
|
}
|
|
// Normalise logic operator
|
|
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
|
|
}
|