mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-11-09 00:22:38 -06:00
354 lines
10 KiB
Go
354 lines
10 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package mcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
)
|
|
|
|
type toolSpec struct {
|
|
schema *jsonschema.Schema
|
|
resolved *jsonschema.Resolved
|
|
params map[string]*huma.Param
|
|
hasBody bool
|
|
projection projection
|
|
}
|
|
|
|
func falseSchema() *jsonschema.Schema { return &jsonschema.Schema{Not: &jsonschema.Schema{}} }
|
|
|
|
func (s *toolSpec) hasFormatArg() bool { return s.schema.Properties[formatParam] != nil }
|
|
|
|
// Saved filters nest the query under "filters".
|
|
func (s *toolSpec) takesFilter() bool {
|
|
return s.schema.Properties["filter"] != nil || s.schema.Properties["filters"] != nil
|
|
}
|
|
|
|
const maxInlineDepth = 8
|
|
|
|
const formatParam = "format"
|
|
|
|
const formatParamDescription = "Rich-text format for description fields: html (default) or markdown. Updates convert markdown to HTML, so use html unless you wrote the description as markdown."
|
|
|
|
const formatArgHint = "Pass format=markdown to exchange rich-text fields as Markdown."
|
|
|
|
const serverInstructions = `Rich-text fields (task and project descriptions, comments) are HTML by default. Pass format: "markdown" on read, list and update tools to exchange them as GFM Markdown.
|
|
Read and list tools return every field by default; pass fields to get only the ones you need.
|
|
Filter queries combine conditions with && and ||, not "and"/"or". Call ` + toolFilterSyntax + ` for the full filter syntax.`
|
|
|
|
func buildToolSpec(oapi *huma.OpenAPI, op *huma.Operation) (*toolSpec, error) {
|
|
props := map[string]*jsonschema.Schema{}
|
|
params := map[string]*huma.Param{}
|
|
var required []string
|
|
for _, p := range op.Parameters {
|
|
if p.In != "path" && p.In != "query" {
|
|
continue
|
|
}
|
|
ps, err := toJSONSchema(oapi, p.Schema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcp: %s: param %s: %w", op.OperationID, p.Name, err)
|
|
}
|
|
if ps.Description == "" {
|
|
ps.Description = p.Description
|
|
}
|
|
// The API description this parameter refers to is not reachable over MCP.
|
|
if p.In == "query" && p.Name == formatParam {
|
|
ps.Description = formatParamDescription
|
|
}
|
|
if p.In == "query" && p.Name == "filter" {
|
|
ps.Description += " Full syntax: call " + toolFilterSyntax + "."
|
|
}
|
|
props[p.Name] = ps
|
|
params[p.Name] = p
|
|
if p.Required {
|
|
required = append(required, p.Name)
|
|
}
|
|
}
|
|
src := bodySchemaOp(oapi, op)
|
|
if op.Method == http.MethodPatch {
|
|
format, err := patchFormatArg(oapi, src)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcp: %s: param %s: %w", op.OperationID, formatParam, err)
|
|
}
|
|
if format != nil {
|
|
delete(params, formatParam)
|
|
props[formatParam] = format
|
|
}
|
|
}
|
|
hasBody := false
|
|
if _, body := bodyMedia(src); body != nil {
|
|
fromPath := boundToPathParams(oapi, body, params)
|
|
body = inlineRefs(oapi, body, 0)
|
|
hasBody = true
|
|
for name, prop := range body.Properties {
|
|
if prop.ReadOnly || fromPath[name] {
|
|
continue
|
|
}
|
|
if _, clash := props[name]; clash {
|
|
return nil, fmt.Errorf("mcp: %s: body property %q collides with a parameter", op.OperationID, name)
|
|
}
|
|
ps, err := toJSONSchema(oapi, prop)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcp: %s: body property %s: %w", op.OperationID, name, err)
|
|
}
|
|
if op.Method == http.MethodPatch && !mustHaveValue(ps, slices.Contains(body.Required, name)) {
|
|
ps = allowNull(ps)
|
|
}
|
|
props[name] = ps
|
|
}
|
|
if op.Method != http.MethodPatch {
|
|
required = append(required, body.Required...)
|
|
}
|
|
if op.Method == http.MethodPost {
|
|
required = append(required, requiredByValidTag(oapi, op)...)
|
|
// Task creation requires a title in model logic, outside valid tags.
|
|
if op.OperationID == "tasks-create" {
|
|
required = append(required, "title")
|
|
}
|
|
}
|
|
}
|
|
proj := projectionFor(oapi, op)
|
|
if proj != noProjection {
|
|
if _, clash := props[fieldsParam]; clash {
|
|
return nil, fmt.Errorf("mcp: %s: %q collides with the response projection argument", op.OperationID, fieldsParam)
|
|
}
|
|
props[fieldsParam] = fieldsSchema()
|
|
}
|
|
required = slices.DeleteFunc(required, func(name string) bool { return props[name] == nil })
|
|
sort.Strings(required)
|
|
schema := &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: props,
|
|
Required: slices.Compact(required),
|
|
AdditionalProperties: falseSchema(),
|
|
}
|
|
resolved, err := schema.Resolve(nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcp: resolve schema for %s: %w", op.OperationID, err)
|
|
}
|
|
return &toolSpec{
|
|
schema: schema,
|
|
resolved: resolved,
|
|
params: params,
|
|
hasBody: hasBody,
|
|
projection: proj,
|
|
}, nil
|
|
}
|
|
|
|
// AutoPatch's re-dispatch drops the query, so a PATCH tool sends the format as a header; apiv2 strips the query param from PATCH for the same reason.
|
|
func patchFormatArg(oapi *huma.OpenAPI, src *huma.Operation) (*jsonschema.Schema, error) {
|
|
for _, p := range src.Parameters {
|
|
if p.In != "query" || p.Name != formatParam {
|
|
continue
|
|
}
|
|
ps, err := toJSONSchema(oapi, p.Schema)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ps.Description = formatParamDescription
|
|
return ps, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// Handlers bind these from the path and ignore the body value.
|
|
func boundToPathParams(oapi *huma.OpenAPI, body *huma.Schema, params map[string]*huma.Param) map[string]bool {
|
|
out := map[string]bool{}
|
|
if body.Ref == "" {
|
|
return out
|
|
}
|
|
t := oapi.Components.Schemas.TypeFromRef(body.Ref)
|
|
if t == nil {
|
|
return out
|
|
}
|
|
walkFields(t, func(f reflect.StructField) {
|
|
p, ok := params[f.Tag.Get("param")]
|
|
if !ok || p.In != "path" {
|
|
return
|
|
}
|
|
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
|
if name != "" && name != "-" {
|
|
out[name] = true
|
|
}
|
|
})
|
|
return out
|
|
}
|
|
|
|
// Merge-patch deletes a key sent as null, so the field falls back to its zero value instead of failing validation.
|
|
func mustHaveValue(s *jsonschema.Schema, required bool) bool {
|
|
switch {
|
|
case required,
|
|
s.MinLength != nil && *s.MinLength > 0,
|
|
s.Minimum != nil,
|
|
len(s.Enum) > 0:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Merge-patch clears a field by sending null; the shape read from the PUT does not allow it.
|
|
func allowNull(s *jsonschema.Schema) *jsonschema.Schema {
|
|
switch {
|
|
case s.Type == "null" || slices.Contains(s.Types, "null"):
|
|
return s
|
|
case s.Type != "":
|
|
s.Types = []string{
|
|
s.Type,
|
|
"null",
|
|
}
|
|
s.Type = ""
|
|
case len(s.Types) > 0:
|
|
s.Types = append(s.Types, "null")
|
|
case len(s.AnyOf) > 0:
|
|
s.AnyOf = append(s.AnyOf, &jsonschema.Schema{Type: "null"})
|
|
case len(s.OneOf) > 0:
|
|
s.OneOf = append(s.OneOf, &jsonschema.Schema{Type: "null"})
|
|
}
|
|
if len(s.Enum) > 0 {
|
|
s.Enum = append(s.Enum, nil)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// AutoPatch's PATCH body drops refs and nullability, collapsing nested schemas to {}; read the shape from the PUT instead.
|
|
func bodySchemaOp(oapi *huma.OpenAPI, op *huma.Operation) *huma.Operation {
|
|
if op.Method != http.MethodPatch {
|
|
return op
|
|
}
|
|
item := oapi.Paths[op.Path]
|
|
if item == nil || item.Put == nil {
|
|
return op
|
|
}
|
|
return item.Put
|
|
}
|
|
func inlineRefs(oapi *huma.OpenAPI, s *huma.Schema, depth int) *huma.Schema {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
if depth > maxInlineDepth {
|
|
return &huma.Schema{ReadOnly: s.ReadOnly}
|
|
}
|
|
copied := *s
|
|
if s.Ref != "" {
|
|
if target := oapi.Components.Schemas.SchemaFromRef(s.Ref); target != nil {
|
|
copied = *target
|
|
copied.ReadOnly = copied.ReadOnly || s.ReadOnly
|
|
if s.Description != "" {
|
|
copied.Description = s.Description
|
|
}
|
|
}
|
|
}
|
|
s = &copied
|
|
if s.Properties != nil {
|
|
props := make(map[string]*huma.Schema, len(s.Properties))
|
|
for k, v := range s.Properties {
|
|
props[k] = inlineRefs(oapi, v, depth+1)
|
|
}
|
|
s.Properties = props
|
|
}
|
|
s.Items = inlineRefs(oapi, s.Items, depth+1)
|
|
if ap, ok := s.AdditionalProperties.(*huma.Schema); ok {
|
|
s.AdditionalProperties = inlineRefs(oapi, ap, depth+1)
|
|
}
|
|
for _, list := range []*[]*huma.Schema{
|
|
&s.AnyOf,
|
|
&s.OneOf,
|
|
&s.AllOf,
|
|
} {
|
|
if *list == nil {
|
|
continue
|
|
}
|
|
out := make([]*huma.Schema, len(*list))
|
|
for i, v := range *list {
|
|
out[i] = inlineRefs(oapi, v, depth+1)
|
|
}
|
|
*list = out
|
|
}
|
|
return s
|
|
}
|
|
func toJSONSchema(oapi *huma.OpenAPI, s *huma.Schema) (*jsonschema.Schema, error) {
|
|
raw, err := json.Marshal(inlineRefs(oapi, s, 0))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := &jsonschema.Schema{}
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Huma leaves body fields optional; govalidator requires these on creation.
|
|
func requiredByValidTag(oapi *huma.OpenAPI, op *huma.Operation) []string {
|
|
_, body := bodyMedia(op)
|
|
if body == nil || body.Ref == "" {
|
|
return nil
|
|
}
|
|
t := oapi.Components.Schemas.TypeFromRef(body.Ref)
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
walkFields(t, func(f reflect.StructField) {
|
|
if !slices.Contains(strings.Split(f.Tag.Get("valid"), ","), "required") {
|
|
return
|
|
}
|
|
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
|
if name != "" && name != "-" {
|
|
out = append(out, name)
|
|
}
|
|
})
|
|
return out
|
|
}
|
|
func walkFields(t reflect.Type, visit func(reflect.StructField)) {
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
if t.Kind() != reflect.Struct {
|
|
return
|
|
}
|
|
for i := 0; i < t.NumField(); i++ {
|
|
f := t.Field(i)
|
|
if f.Anonymous {
|
|
walkFields(f.Type, visit)
|
|
continue
|
|
}
|
|
if f.IsExported() {
|
|
visit(f)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustResolveSpec(name string, schema *jsonschema.Schema) *toolSpec {
|
|
resolved, err := schema.Resolve(nil)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("mcp: resolve %s schema: %v", name, err))
|
|
}
|
|
return &toolSpec{
|
|
schema: schema,
|
|
resolved: resolved,
|
|
}
|
|
}
|