Skip to content

Commit 316157b

Browse files
feat(http): support non object JSON payloads in http bodies (#248)
* feat(http): support non object JSON payloads in http bodies Signed-off-by: Calum Murray <cmurray@redhat.com> * Update http_invocation.go Co-authored-by: Nader Ziada <nziada@redhat.com> --------- Signed-off-by: Calum Murray <cmurray@redhat.com> Co-authored-by: Nader Ziada <nziada@redhat.com>
1 parent da5ed01 commit 316157b

File tree

9 files changed

+326
-8
lines changed

9 files changed

+326
-8
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [v0.2.1]
1111

12+
### Added
13+
- Support for calling HTTP endpoints which require non-object top level JSON types (e.g. JSON array, strings, etc.). (#238)
14+
1215
### Fixed
1316
- HTTP invocations which have headers do not also include those variables in the request body/query. (#247)
1417

pkg/invocation/http/config.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ type HttpInvocationConfig struct {
3838

3939
// The HTTP method to be used for the request (e.g., "GET", "POST").
4040
Method string `json:"method,omitempty" jsonschema:"required,enum=GET,enum=POST,enum=PUT,enum=PATCH,enum=DELETE,enum=HEAD"`
41+
42+
// BodyRoot specifies a dot-separated path to a property whose value should be used as the HTTP request body.
43+
// This allows sending non-object request bodies, despite MCP requiring all tool call params to be rooted in
44+
// a top-level object.
45+
// Mutually exclusive with BodyAsArray
46+
BodyRoot string `json:"bodyRoot,omitempty" jsonschema:"optional"`
47+
48+
// BodyAsArray wraps the entire request body into a JSON array.
49+
// For example, if the arguments are {"name": "foo"}, the HTTP body will be [{"name": "foo"}].
50+
// This is useful for APIs that expect array inputs but you want to expose a simpler single-item interface.
51+
// Mutually exclusive with BodyRoot.
52+
BodyAsArray bool `json:"bodyAsArray,omitempty" jsonschema:"optional"`
4153
}
4254

4355
var _ invocation.InvocationConfig = &HttpInvocationConfig{}
@@ -52,6 +64,11 @@ func (hic *HttpInvocationConfig) Validate() error {
5264
return fmt.Errorf("invalid http request method: '%s'", hic.Method)
5365
}
5466

67+
// BodyRoot and BodyAsArray are mutually exclusive
68+
if hic.BodyRoot != "" && hic.BodyAsArray {
69+
return fmt.Errorf("bodyRoot and bodyAsArray are mutually exclusive")
70+
}
71+
5572
return nil
5673
}
5774

@@ -62,9 +79,11 @@ func (hic *HttpInvocationConfig) DeepCopy() invocation.InvocationConfig {
6279
}
6380

6481
return &HttpInvocationConfig{
65-
URL: hic.URL,
66-
Headers: headers,
67-
Method: hic.Method,
82+
URL: hic.URL,
83+
Headers: headers,
84+
Method: hic.Method,
85+
BodyRoot: hic.BodyRoot,
86+
BodyAsArray: hic.BodyAsArray,
6887
}
6988
}
7089

pkg/invocation/http/factory.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ func (f *InvokerFactory) CreateInvoker(config invocation.InvocationConfig, primi
6767
Method: hic.Method,
6868
InputSchema: primitive.GetResolvedInputSchema(),
6969
URITemplate: uriTemplate,
70+
BodyRoot: hic.BodyRoot,
71+
BodyAsArray: hic.BodyAsArray,
7072
}
7173

7274
return invoker, nil

pkg/invocation/http/http_invocation.go

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io"
99
nethttp "net/http"
1010
neturl "net/url"
11+
"strconv"
1112
"strings"
1213

1314
"github.com/google/jsonschema-go/jsonschema"
@@ -29,6 +30,8 @@ type HttpInvoker struct {
2930
Method string // Http request method
3031
InputSchema *jsonschema.Resolved // InputSchema for the tool
3132
URITemplate string // MCP URI template (for resource templates only)
33+
BodyRoot string // Dot-separated path to extract as the request body
34+
BodyAsArray bool // Wrap the entire body in a JSON array
3235
}
3336

3437
var _ invocation.Invoker = &HttpInvoker{}
@@ -372,6 +375,8 @@ func (hi *HttpInvoker) executeHTTPRequest(
372375

373376
// prepareRequestBody creates a JSON body from the parsed arguments,
374377
// excluding any variables that are used in the URL template or header templates
378+
// if BodyRoot is set, it extracts that property's value as the body
379+
// if BodyAsArray is set, it wraps the entire body in a JSON array
375380
func (hi *HttpInvoker) prepareRequestBody(parsed map[string]any) ([]byte, error) {
376381
varNames := make([]string, 0, len(hi.ParsedTemplate.Variables))
377382
for _, v := range hi.ParsedTemplate.Variables {
@@ -384,7 +389,20 @@ func (hi *HttpInvoker) prepareRequestBody(parsed map[string]any) ([]byte, error)
384389
}
385390
}
386391

387-
return json.Marshal(deletePathsFromMap(parsed, varNames))
392+
var body any = deletePathsFromMap(parsed, varNames)
393+
394+
if hi.BodyRoot != "" {
395+
val, ok := getValueByPath(parsed, hi.BodyRoot)
396+
if !ok {
397+
return nil, fmt.Errorf("bodyRoot property %q not found in arguments", hi.BodyRoot)
398+
}
399+
body = val
400+
} else if hi.BodyAsArray {
401+
// wrap the body in an array
402+
body = []any{body}
403+
}
404+
405+
return json.Marshal(body)
388406
}
389407

390408
// buildRequestComponents builds the URL and headers from request arguments and incoming headers.
@@ -653,3 +671,81 @@ func deletePathFromMap(m map[string]any, path string) {
653671
delete(parentMap, keys[len(keys)-2])
654672
}
655673
}
674+
675+
type pathSegment struct {
676+
key string
677+
index int
678+
isIndex bool
679+
}
680+
681+
func getValueByPath(m map[string]any, path string) (any, bool) {
682+
segments, err := parsePathSegments(path)
683+
if err != nil {
684+
return nil, false
685+
}
686+
687+
var current any = m
688+
689+
for _, seg := range segments {
690+
switch v := current.(type) {
691+
case map[string]any:
692+
if seg.isIndex {
693+
return nil, false
694+
}
695+
var ok bool
696+
current, ok = v[seg.key]
697+
if !ok {
698+
return nil, false
699+
}
700+
case []any:
701+
if !seg.isIndex {
702+
return nil, false
703+
}
704+
if seg.index < 0 || seg.index >= len(v) {
705+
return nil, false
706+
}
707+
current = v[seg.index]
708+
default:
709+
return nil, false
710+
}
711+
}
712+
713+
return current, true
714+
}
715+
716+
func parsePathSegments(path string) ([]pathSegment, error) {
717+
var segments []pathSegment
718+
var err error
719+
720+
for part := range strings.SplitSeq(path, ".") {
721+
for len(part) > 0 {
722+
bracketStart := strings.Index(part, "[")
723+
if bracketStart == -1 {
724+
if part != "" {
725+
segments = append(segments, pathSegment{key: part})
726+
}
727+
break
728+
}
729+
730+
if bracketStart > 0 {
731+
segments = append(segments, pathSegment{key: part[:bracketStart]})
732+
}
733+
734+
bracketEnd := strings.Index(part, "]")
735+
if bracketEnd == -1 || bracketEnd <= bracketStart+1 {
736+
return nil, fmt.Errorf("failed to find matching closing bracket for opening bracket")
737+
}
738+
739+
indexStr := part[bracketStart+1 : bracketEnd]
740+
var index int64
741+
if index, err = strconv.ParseInt(indexStr, 10, 0); err != nil {
742+
return nil, fmt.Errorf("failed to parse array index as int: %w", err)
743+
}
744+
745+
segments = append(segments, pathSegment{index: int(index), isIndex: true})
746+
part = part[bracketEnd+1:]
747+
}
748+
}
749+
750+
return segments, nil
751+
}

0 commit comments

Comments
 (0)