diff --git a/register.go b/register.go index 3d33d93..bff09f9 100644 --- a/register.go +++ b/register.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "os" "slices" "strings" @@ -30,8 +31,9 @@ func getParameterValue(args map[string]any, paramName string, paramNameMapping m } // formatParameterValue converts a parameter value to a string, formatting integers without decimals -func formatParameterValue(val any, isInteger bool) string { - if isInteger { +// when the associated OpenAPI schema declares an integer type. +func formatParameterValue(val any, schema *openapi3.Schema) string { + if schema != nil && schema.Type != nil && schema.Type.Is("integer") { // Handle integer formatting switch v := val.(type) { case float64: @@ -52,6 +54,251 @@ func formatParameterValue(val any, isInteger bool) string { return fmt.Sprintf("%v", val) } +// arrayItemSchema returns the schema for items in an array parameter, or nil if not applicable. +func arrayItemSchema(schema *openapi3.Schema) *openapi3.Schema { + if schema == nil || schema.Items == nil { + return nil + } + return schema.Items.Value +} + +// getSeparator returns the separator for a given style +func getSeparator(style string) string { + switch style { + case "spaceDelimited": + return " " + case "pipeDelimited": + return "|" + default: + return "," + } +} + +// objectPropertySchema returns the schema for a property in an object parameter, or nil if not applicable. +func objectPropertySchema(schema *openapi3.Schema, key string) *openapi3.Schema { + if schema == nil { + return nil + } + propRef, ok := schema.Properties[key] + if !ok || propRef == nil { + return nil + } + return propRef.Value +} + +// serializeParameter is the main serialization function for all OpenAPI parameter types. +// It returns url.Values to handle all cases including multiple values for the same key. +// Ref. https://spec.openapis.org/oas/latest.html#parameter-object +// +// Styles: +// - simple: comma-separated values (path, header) +// - label: dot-prefixed values (path only) +// - matrix: semicolon-prefixed name-value pairs (path only) +// - form: ampersand-separated values (query, cookie) +// - spaceDelimited: space-separated values (query only) +// - pipeDelimited: pipe-separated values (query only) +// - deepObject: nested object notation like id[role]=admin (query only) +// +// Note: it does NOT support allowReserved parameter, all values are percent-encoded as needed. +func serializeParameter(paramName string, val any, schema *openapi3.Schema, style string, explode, escapePath bool) url.Values { + result := url.Values{} + + // Path params are percent-encoded per segment; other locations are encoded later by url.Values.Encode. + escape := func(s string) string { return s } + if escapePath { + escape = url.PathEscape + } + + // Check value type + arr, isArray := val.([]any) + obj, isObject := val.(map[string]any) + + // Helper to format and escape a value + formatValue := func(v any, valueSchema *openapi3.Schema) string { + return escape(formatParameterValue(v, valueSchema)) + } + + // Primitives - simple formatting + // Example: id=5 + if !isArray && !isObject { + formatted := formatValue(val, schema) + switch style { + case "label": + // Label style: prefix with dot (e.g., .5) + result.Set(paramName, "."+formatted) + case "matrix": + // Matrix style: semicolon-prefixed name=value (e.g., ;id=5) + result.Set(paramName, ";"+escape(paramName)+"="+formatted) + default: // form, simple, etc. + // Default: just the value (e.g., 5) + result.Set(paramName, formatted) + } + return result + } + + // Arrays + // Example: id=[3,4,5] + if isArray { + itemSchema := arrayItemSchema(schema) + var parts []string + for _, item := range arr { + parts = append(parts, formatValue(item, itemSchema)) + } + + switch style { + case "label": + if explode { + // Label exploded: dot-separated with dot prefix (e.g., .3.4.5) + result.Set(paramName, "."+strings.Join(parts, ".")) + } else { + // Label: comma-separated with dot prefix (e.g., .3,4,5) + result.Set(paramName, "."+strings.Join(parts, ",")) + } + case "matrix": + if explode { + // Matrix exploded: repeated name=value pairs (e.g., ;id=3;id=4;id=5) + var matrixParts []string + for _, part := range parts { + matrixParts = append(matrixParts, escape(paramName)+"="+part) + } + result.Set(paramName, ";"+strings.Join(matrixParts, ";")) + } else { + // Matrix: single name with comma-separated values (e.g., ;id=3,4,5) + result.Set(paramName, ";"+escape(paramName)+"="+strings.Join(parts, ",")) + } + case "form", "spaceDelimited", "pipeDelimited": + if explode { + // Exploded: multiple parameters with same name (e.g., id=3&id=4&id=5) + for _, part := range parts { + result.Add(paramName, part) + } + } else { + // Non-exploded: delimited values + result.Set(paramName, strings.Join(parts, getSeparator(style))) + } + default: // simple, and any unlisted style: comma-separated + result.Set(paramName, strings.Join(parts, ",")) + } + return result + } + + // Objects + // Example: id={role:"admin",firstName:"Alex"} + if isObject { + // Sort keys for deterministic output + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + slices.Sort(keys) + + // Build key-value pairs for most styles + buildParts := func() []string { + var parts []string + for _, k := range keys { + propSchema := objectPropertySchema(schema, k) + if explode { + parts = append(parts, escape(k)+"="+formatValue(obj[k], propSchema)) + } else { + parts = append(parts, escape(k), formatValue(obj[k], propSchema)) + } + } + return parts + } + + switch style { + case "label": + sep := "," + if explode { + sep = "." + } + result.Set(paramName, "."+strings.Join(buildParts(), sep)) + case "matrix": + if explode { + // buildParts already yields k=v pairs when explode is true + result.Set(paramName, ";"+strings.Join(buildParts(), ";")) + } else { + result.Set(paramName, ";"+escape(paramName)+"="+strings.Join(buildParts(), ",")) + } + case "deepObject": + // Deep object: bracket notation for nested properties (e.g., id[role]=admin&id[firstName]=Alex) + for _, k := range keys { + result.Set(paramName+"["+k+"]", formatValue(obj[k], objectPropertySchema(schema, k))) + } + case "form", "spaceDelimited", "pipeDelimited": + if explode { + // Exploded: each property as separate param + for _, k := range keys { + result.Set(k, formatValue(obj[k], objectPropertySchema(schema, k))) + } + } else { + // Non-exploded: delimited key-value pairs + var parts []string + for _, k := range keys { + parts = append(parts, k, formatValue(obj[k], objectPropertySchema(schema, k))) + } + result.Set(paramName, strings.Join(parts, getSeparator(style))) + } + default: // simple, and any unlisted style: comma-separated + result.Set(paramName, strings.Join(buildParts(), ",")) + } + return result + } + + return result +} + +// serializeQueryParameter serializes a query parameter (form/spaceDelimited/pipeDelimited/deepObject styles). +// NOTE: This implementation does NOT support the allowReserved parameter, all values are percent-encoded. +func serializeQueryParameter(paramName string, val any, schema *openapi3.Schema, style string, explode bool) url.Values { + return serializeParameter(paramName, val, schema, style, explode, false) +} + +// serializePathParameter serializes a path parameter (simple/label/matrix styles). +func serializePathParameter(paramName string, val any, schema *openapi3.Schema, style string, explode bool) string { + // Use serializeParameter with URL path escaping for each scalar value + result := serializeParameter(paramName, val, schema, style, explode, true) + return result.Get(paramName) +} + +// serializeHeaderParameter serializes a header parameter (simple style only). +// Headers always use simple style and are NOT URL-encoded. +func serializeHeaderParameter(val any, schema *openapi3.Schema, explode bool) string { + values := serializeParameter("", val, schema, "simple", explode, false) + return values.Get("") +} + +// serializeCookieParameter serializes a cookie parameter (form style only). +// Uses semicolon-space ("; ") as separator per RFC6265 cookie format. +func serializeCookieParameter(paramName string, val any, schema *openapi3.Schema, explode bool) string { + values := serializeParameter(paramName, val, schema, "form", explode, false) + + // Collect all key=value pairs + var pairs []string + + // Check if val is an array to preserve order + _, isArray := val.([]any) + + if isArray && explode { + // For exploded arrays, preserve insertion order from url.Values + if vals, ok := values[paramName]; ok { + for _, v := range vals { + pairs = append(pairs, paramName+"="+v) + } + } + } else { + // For objects or non-exploded values, collect and sort for determinism + for key, vals := range values { + for _, v := range vals { + pairs = append(pairs, key+"="+v) + } + } + slices.Sort(pairs) + } + + return strings.Join(pairs, "; ") +} + // generateAIFriendlyDescription creates a comprehensive, AI-optimized description for an operation // that includes all the information an AI agent needs to understand how to use the tool. func generateAIFriendlyDescription(op OpenAPIOperation, inputSchema jsonschema.Schema) string { diff --git a/register_test.go b/register_test.go index 30c67cc..7ab745e 100644 --- a/register_test.go +++ b/register_test.go @@ -625,3 +625,151 @@ func TestGetParameterValue(t *testing.T) { t.Errorf("Expected to not find non-existent parameter, but found: %v", val) } } + +// Examples from https://swagger.io/docs/specification/v3_0/serialization/ +func TestSerializeParameter(t *testing.T) { + testCases := []struct { + name string + paramName string + val any + schema *openapi3.Schema + cases map[bool]map[string]map[string]string + }{ + { + name: "primitive id=5", + paramName: "id", + val: float64(5), + schema: &openapi3.Schema{Type: typesPtr("integer")}, + cases: map[bool]map[string]map[string]string{ + false: { + "simple": {"path": "5", "header": "5"}, + "label": {"path": ".5"}, + "matrix": {"path": ";id=5"}, + "form": {"query": "id=5", "cookie": "id=5"}, + }, + true: { + "simple": {"path": "5", "header": "5"}, + "label": {"path": ".5"}, + "matrix": {"path": ";id=5"}, + "form": {"query": "id=5", "cookie": "id=5"}, + }, + }, + }, + { + name: "array id=[3,4,5]", + paramName: "id", + val: []any{float64(3), float64(4), float64(5)}, + schema: &openapi3.Schema{ + Type: typesPtr("array"), + Items: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: typesPtr("integer")}}, + }, + cases: map[bool]map[string]map[string]string{ + false: { + "simple": {"path": "3,4,5", "header": "3,4,5"}, + "label": {"path": ".3,4,5"}, + "matrix": {"path": ";id=3,4,5"}, + "form": {"query": "id=3%2C4%2C5", "cookie": "id=3,4,5"}, + "spaceDelimited": {"query": "id=3+4+5"}, + "pipeDelimited": {"query": "id=3%7C4%7C5"}, + }, + true: { + "simple": {"path": "3,4,5", "header": "3,4,5"}, + "label": {"path": ".3.4.5"}, + "matrix": {"path": ";id=3;id=4;id=5"}, + "form": {"query": "id=3&id=4&id=5"}, + "spaceDelimited": {"query": "id=3&id=4&id=5"}, + "pipeDelimited": {"query": "id=3&id=4&id=5"}, + }, + }, + }, + { + name: "array id=[a/b,c d]", + paramName: "id", + val: []any{"a/b", "c d"}, + schema: &openapi3.Schema{ + Type: typesPtr("array"), + Items: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: typesPtr("string")}}, + }, + cases: map[bool]map[string]map[string]string{ + false: { + "simple": {"path": "a%2Fb,c%20d", "header": "a/b,c d"}, + "label": {"path": ".a%2Fb,c%20d"}, + "matrix": {"path": ";id=a%2Fb,c%20d"}, + }, + true: { + "simple": {"path": "a%2Fb,c%20d", "header": "a/b,c d"}, + "label": {"path": ".a%2Fb.c%20d"}, + "matrix": {"path": ";id=a%2Fb;id=c%20d"}, + }, + }, + }, + { + name: "object id={role:admin,firstName:Alex}", + paramName: "id", + val: map[string]any{"role": "admin", "firstName": "Alex"}, + schema: &openapi3.Schema{ + Type: typesPtr("object"), + Properties: openapi3.Schemas{ + "role": {Value: &openapi3.Schema{Type: typesPtr("string")}}, + "firstName": {Value: &openapi3.Schema{Type: typesPtr("string")}}, + }, + }, + cases: map[bool]map[string]map[string]string{ + false: { + "simple": {"path": "firstName,Alex,role,admin", "header": "firstName,Alex,role,admin"}, + "label": {"path": ".firstName,Alex,role,admin"}, + "matrix": {"path": ";id=firstName,Alex,role,admin"}, + "form": {"query": "id=firstName%2CAlex%2Crole%2Cadmin", "cookie": "id=firstName,Alex,role,admin"}, + }, + true: { + "simple": {"path": "firstName=Alex,role=admin", "header": "firstName=Alex,role=admin"}, + "label": {"path": ".firstName=Alex.role=admin"}, + "matrix": {"path": ";firstName=Alex;role=admin"}, + "form": {"query": "firstName=Alex&role=admin", "cookie": "firstName=Alex; role=admin"}, + "deepObject": {"query": "id%5BfirstName%5D=Alex&id%5Brole%5D=admin"}, + }, + }, + }, + } + + for _, tc := range testCases { + for explode, styleMap := range tc.cases { + for style, paramTypeMap := range styleMap { + testName := tc.name + " " + style + if explode { + testName += " explode=true" + } else { + testName += " explode=false" + } + + t.Run(testName, func(t *testing.T) { + for paramType, want := range paramTypeMap { + switch paramType { + case "path": + got := serializePathParameter(tc.paramName, tc.val, tc.schema, style, explode) + if got != want { + t.Errorf("[path] got %v, want %v", got, want) + } + case "header": + got := serializeHeaderParameter(tc.val, tc.schema, explode) + if got != want { + t.Errorf("[header] got %v, want %v", got, want) + } + case "cookie": + got := serializeCookieParameter(tc.paramName, tc.val, tc.schema, explode) + if got != want { + t.Errorf("[cookie] got %v, want %v", got, want) + } + case "query": + gotValues := serializeQueryParameter(tc.paramName, tc.val, tc.schema, style, explode) + got := gotValues.Encode() + if got != want { + t.Errorf("[query] got %v, want %v", got, want) + } + } + } + }) + } + } + } +} diff --git a/tool.go b/tool.go index bc25569..5e49427 100644 --- a/tool.go +++ b/tool.go @@ -34,6 +34,24 @@ func toolHandler( return func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Build parameter name mapping for escaped parameter names paramNameMapping := buildParameterNameMapping(op.Parameters) + getParameterSchema := func(param *openapi3.Parameter) *openapi3.Schema { + if param == nil { + return nil + } + // Try schema first + if param.Schema != nil { + return param.Schema.Value + } + // Fall back to content (OpenAPI allows either schema OR content) + if param.Content != nil && len(param.Content) > 0 { + for _, mediaType := range param.Content { + if mediaType.Schema != nil { + return mediaType.Schema.Value + } + } + } + return nil + } // Build URL path with path parameters path := op.Path @@ -45,12 +63,19 @@ func toolHandler( p := paramRef.Value if p.In == "path" { if val, ok := getParameterValue(args, p.Name, paramNameMapping); ok { - // Check if parameter is integer type - isInteger := false - if p.Schema != nil && p.Schema.Value != nil && p.Schema.Value.Type != nil { - isInteger = p.Schema.Value.Type.Is("integer") + // Get style and explode from parameter + style := p.Style + if style == "" { + style = "simple" // default for path parameters + } + explode := false // default for path parameters + if p.Explode != nil { + explode = *p.Explode } - path = strings.ReplaceAll(path, "{"+p.Name+"}", formatParameterValue(val, isInteger)) + + // Serialize the path parameter value + serialized := serializePathParameter(p.Name, val, getParameterSchema(p), style, explode) + path = strings.ReplaceAll(path, "{"+p.Name+"}", serialized) } } } @@ -65,12 +90,38 @@ func toolHandler( p := paramRef.Value if p.In == "query" { if val, ok := getParameterValue(args, p.Name, paramNameMapping); ok { - // Check if parameter is integer type - isInteger := false - if p.Schema != nil && p.Schema.Value != nil && p.Schema.Value.Type != nil { - isInteger = p.Schema.Value.Type.Is("integer") + parameterSchema := getParameterSchema(p) + + // Handle arrays and objects with OpenAPI serialization styles + // Check if val is an array or object + _, isArray := val.([]any) + _, isObject := val.(map[string]any) + + if isArray || isObject { + // Get style and explode from parameter + style := p.Style + if style == "" { + style = "form" // default for query parameters + } + explode := true // default for form style + if p.Explode != nil { + explode = *p.Explode + } else if style != "form" { + // For non-form styles, default explode is false + explode = false + } + + // Serialize according to OpenAPI spec + serialized := serializeQueryParameter(p.Name, val, parameterSchema, style, explode) + for key, values := range serialized { + for _, value := range values { + query.Add(key, value) + } + } + } else { + // Handle primitive values + query.Set(p.Name, formatParameterValue(val, parameterSchema)) } - query.Set(p.Name, formatParameterValue(val, isInteger)) } } } @@ -153,12 +204,15 @@ func toolHandler( p := paramRef.Value if p.In == "header" { if val, ok := getParameterValue(args, p.Name, paramNameMapping); ok { - // Check if parameter is integer type - isInteger := false - if p.Schema != nil && p.Schema.Value != nil && p.Schema.Value.Type != nil { - isInteger = p.Schema.Value.Type.Is("integer") + // Get explode from parameter (simple style is the only option for headers) + explode := false // default for simple style + if p.Explode != nil { + explode = *p.Explode } - httpReq.Header.Set(p.Name, formatParameterValue(val, isInteger)) + + // Serialize the header parameter value + serialized := serializeHeaderParameter(val, getParameterSchema(p), explode) + httpReq.Header.Set(p.Name, serialized) } } } @@ -173,12 +227,15 @@ func toolHandler( p := paramRef.Value if p.In == "cookie" { if val, ok := getParameterValue(args, p.Name, paramNameMapping); ok { - // Check if parameter is integer type - isInteger := false - if p.Schema != nil && p.Schema.Value != nil && p.Schema.Value.Type != nil { - isInteger = p.Schema.Value.Type.Is("integer") + // Get explode from parameter (form style is the only option for cookies) + explode := true // default for form style + if p.Explode != nil { + explode = *p.Explode } - cookiePairs = append(cookiePairs, fmt.Sprintf("%s=%s", p.Name, formatParameterValue(val, isInteger))) + + // Serialize according to OpenAPI spec + serialized := serializeCookieParameter(p.Name, val, getParameterSchema(p), explode) + cookiePairs = append(cookiePairs, serialized) } } }