Skip to content

Commit 98a2fd0

Browse files
authored
Merge pull request #89 from LAA-Software-Engineering/feat/mcp-http-transport-77
feat(tools/mcp): MCP streamable HTTP transport (#77)
2 parents b3c0266 + 417323e commit 98a2fd0

14 files changed

Lines changed: 777 additions & 57 deletions

docs/DESIGN_DOC.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,20 @@ spec:
625625
backoff: exponential
626626
```
627627

628+
### MCP HTTP tool (streamable HTTP)
629+
630+
Remote MCP servers may expose a single JSON-RPC endpoint over HTTP(S). Set **`transport: http`**, **`url`** to that endpoint, and optional **`headers`** (including **`env:`** tokens). **`command`** / **`args`** must not be set together with **`url`** (see validator).
631+
632+
```yaml
633+
spec:
634+
type: mcp
635+
mcp:
636+
transport: http
637+
url: https://mcp.example.com/v1/mcp
638+
headers:
639+
Authorization: env:MCP_TOKEN
640+
```
641+
628642
### Native HTTP tool
629643

630644
```yaml

docs/EXAMPLES.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,32 @@ agentctl run workflow/hello --project my-agent-system
116116

117117
---
118118

119+
## 3b. MCP tool over HTTP (streamable HTTP)
120+
121+
For MCP servers exposed over **HTTP** (streamable HTTP transport: one **POST** per JSON-RPC message), set **`spec.mcp.transport: http`** and **`spec.mcp.url`** to the MCP endpoint (must be **`http://`** or **`https://`**). Optional **`spec.mcp.headers`** use the same patterns as native HTTP tools (literal values or **`env:VAR_NAME`** for secrets).
122+
123+
```yaml
124+
apiVersion: agentic.dev/v0
125+
kind: Tool
126+
metadata:
127+
name: remote_mcp
128+
spec:
129+
type: mcp
130+
mcp:
131+
transport: http
132+
url: https://mcp.example.com/v1/mcp
133+
headers:
134+
Authorization: env:MCP_BEARER_TOKEN
135+
```
136+
137+
**Security**
138+
139+
- Prefer **HTTPS** in production. The default Go client performs **normal TLS certificate verification** against the system trust store; do not disable verification for MCP calls.
140+
- **`stdio`** and **`http`** are mutually exclusive in **`spec.mcp`**: set **`command`** only for stdio, **`url`** only for HTTP (validated at `agentctl validate`).
141+
- Workflow trace events for tool steps record **`uses`** and cost, not HTTP headers or resolved env values; keep custom logging of MCP traffic free of secrets.
142+
143+
---
144+
119145
## 4. Real OpenAI example (`gpt-4o-mini`)
120146

121147
This is a small but **end-to-end** project: a **native echo** step supplies fixed “policy” text, then **`gpt-4o-mini`** drafts a one-line customer reply. You need a valid **[OpenAI API key](https://platform.openai.com/api-keys)** and outbound **HTTPS** to `api.openai.com`.

internal/spec/kinds.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,11 @@ type ToolSpec struct {
8686
}
8787

8888
type ToolMCP struct {
89-
Transport string `yaml:"transport,omitempty" json:"transport,omitempty"`
90-
Command string `yaml:"command,omitempty" json:"command,omitempty"`
91-
Args []string `yaml:"args,omitempty" json:"args,omitempty"`
89+
Transport string `yaml:"transport,omitempty" json:"transport,omitempty"`
90+
Command string `yaml:"command,omitempty" json:"command,omitempty"`
91+
Args []string `yaml:"args,omitempty" json:"args,omitempty"`
92+
URL string `yaml:"url,omitempty" json:"url,omitempty"`
93+
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
9294
}
9395

9496
type ToolHTTP struct {

internal/spec/validator.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ func validateToolSpecs(g *ProjectGraph) []error {
147147
case "mcp":
148148
if tr.Spec.MCP == nil {
149149
errs = append(errs, fmt.Errorf("Tool/%s: type mcp requires spec.mcp", name))
150+
} else {
151+
errs = append(errs, validateToolMCP(name, tr.Spec.MCP)...)
150152
}
151153
case "http":
152154
if tr.Spec.HTTP == nil {
@@ -178,6 +180,34 @@ func validateToolSpecs(g *ProjectGraph) []error {
178180
return errs
179181
}
180182

183+
func validateToolMCP(name string, m *ToolMCP) []error {
184+
var errs []error
185+
trans := strings.ToLower(strings.TrimSpace(m.Transport))
186+
if trans == "" {
187+
errs = append(errs, fmt.Errorf("Tool/%s: spec.mcp.transport is required (stdio or http)", name))
188+
return errs
189+
}
190+
switch trans {
191+
case "stdio":
192+
if strings.TrimSpace(m.URL) != "" {
193+
errs = append(errs, fmt.Errorf("Tool/%s: mcp stdio transport must not set url", name))
194+
}
195+
if strings.TrimSpace(m.Command) == "" {
196+
errs = append(errs, fmt.Errorf("Tool/%s: mcp stdio requires command", name))
197+
}
198+
case "http":
199+
if strings.TrimSpace(m.Command) != "" || len(m.Args) > 0 {
200+
errs = append(errs, fmt.Errorf("Tool/%s: mcp http transport must not set command or args", name))
201+
}
202+
if strings.TrimSpace(m.URL) == "" {
203+
errs = append(errs, fmt.Errorf("Tool/%s: mcp http transport requires url", name))
204+
}
205+
default:
206+
errs = append(errs, fmt.Errorf("Tool/%s: unsupported mcp.transport %q (stdio or http)", name, m.Transport))
207+
}
208+
return errs
209+
}
210+
181211
func validatePolicySpecs(g *ProjectGraph) []error {
182212
var errs []error
183213
for name, pr := range g.Policies {

internal/spec/validator_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,92 @@ func TestValidateProjectGraph_workflowRuntimeUnknown(t *testing.T) {
221221
}
222222
}
223223

224+
func TestValidateProjectGraph_mcpMissingTransport(t *testing.T) {
225+
g := &ProjectGraph{
226+
Tools: map[string]*ToolResource{
227+
"x": {
228+
Kind: KindTool,
229+
Metadata: Metadata{Name: "x"},
230+
Spec: ToolSpec{
231+
Type: "mcp",
232+
MCP: &ToolMCP{Command: "npx"},
233+
},
234+
},
235+
},
236+
}
237+
err := ValidateProjectGraph(g, t.TempDir())
238+
if err == nil || !strings.Contains(err.Error(), "spec.mcp.transport is required") {
239+
t.Fatalf("expected mcp transport error, got %v", err)
240+
}
241+
}
242+
243+
func TestValidateProjectGraph_mcpStdioWithURL(t *testing.T) {
244+
g := &ProjectGraph{
245+
Tools: map[string]*ToolResource{
246+
"x": {
247+
Kind: KindTool,
248+
Metadata: Metadata{Name: "x"},
249+
Spec: ToolSpec{
250+
Type: "mcp",
251+
MCP: &ToolMCP{
252+
Transport: "stdio",
253+
Command: "npx",
254+
URL: "http://bad",
255+
},
256+
},
257+
},
258+
},
259+
}
260+
err := ValidateProjectGraph(g, t.TempDir())
261+
if err == nil || !strings.Contains(err.Error(), "must not set url") {
262+
t.Fatalf("expected stdio/url conflict, got %v", err)
263+
}
264+
}
265+
266+
func TestValidateProjectGraph_mcpHTTPWithCommand(t *testing.T) {
267+
g := &ProjectGraph{
268+
Tools: map[string]*ToolResource{
269+
"x": {
270+
Kind: KindTool,
271+
Metadata: Metadata{Name: "x"},
272+
Spec: ToolSpec{
273+
Type: "mcp",
274+
MCP: &ToolMCP{
275+
Transport: "http",
276+
URL: "https://example.com/mcp",
277+
Command: "npx",
278+
},
279+
},
280+
},
281+
},
282+
}
283+
err := ValidateProjectGraph(g, t.TempDir())
284+
if err == nil || !strings.Contains(err.Error(), "must not set command") {
285+
t.Fatalf("expected http/command conflict, got %v", err)
286+
}
287+
}
288+
289+
func TestValidateProjectGraph_mcpHTTPMissingURL(t *testing.T) {
290+
g := &ProjectGraph{
291+
Tools: map[string]*ToolResource{
292+
"x": {
293+
Kind: KindTool,
294+
Metadata: Metadata{Name: "x"},
295+
Spec: ToolSpec{
296+
Type: "mcp",
297+
MCP: &ToolMCP{
298+
Transport: "http",
299+
},
300+
},
301+
},
302+
},
303+
}
304+
err := ValidateProjectGraph(g, t.TempDir())
305+
if err == nil || !strings.Contains(err.Error(), "http transport requires url") {
306+
t.Fatalf("expected mcp http url error, got %v", err)
307+
}
308+
}
309+
224310
func TestValidateProjectGraph_runtimeLocalAccepted(t *testing.T) {
225311
g := &ProjectGraph{
226312
Spec: ProjectSpec{

internal/tools/mcp/call.go

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package mcp
33
import (
44
"context"
55
"errors"
6+
"fmt"
7+
"net/http"
68
"strings"
79
"time"
810

@@ -15,19 +17,27 @@ type ExecMeta struct {
1517
CostUSD float64
1618
}
1719

18-
// CallStdio runs one MCP tools/call over a fresh stdio subprocess, with optional retries on transport errors (§13.4).
19-
func CallStdio(ctx context.Context, cfg *spec.ToolMCP, retry *spec.ToolRetry, toolName string, arguments map[string]any) (map[string]any, ExecMeta, error) {
20+
// Call runs one MCP tools/call: stdio subprocess or HTTP endpoint per spec.mcp.transport (issue #77).
21+
func Call(ctx context.Context, cfg *spec.ToolMCP, retry *spec.ToolRetry, toolName string, arguments map[string]any) (map[string]any, ExecMeta, error) {
2022
if cfg == nil {
2123
return nil, ExecMeta{}, errors.New("mcp: nil mcp config")
2224
}
23-
if strings.ToLower(strings.TrimSpace(cfg.Transport)) != "stdio" {
24-
return nil, ExecMeta{}, errors.New("mcp: only transport stdio is supported in MVP")
25+
trans := strings.ToLower(strings.TrimSpace(cfg.Transport))
26+
switch trans {
27+
case "stdio":
28+
return callStdioLoop(ctx, cfg, retry, toolName, arguments)
29+
case "http":
30+
return callHTTPLoop(ctx, cfg, retry, toolName, arguments)
31+
default:
32+
return nil, ExecMeta{}, fmt.Errorf("mcp: unsupported transport %q (stdio or http)", cfg.Transport)
2533
}
34+
}
35+
36+
func callStdioLoop(ctx context.Context, cfg *spec.ToolMCP, retry *spec.ToolRetry, toolName string, arguments map[string]any) (map[string]any, ExecMeta, error) {
2637
cmd := strings.TrimSpace(cfg.Command)
2738
if cmd == "" {
28-
return nil, ExecMeta{}, errors.New("mcp: empty command")
39+
return nil, ExecMeta{}, errors.New("mcp: stdio transport requires command")
2940
}
30-
3141
attempts := 1
3242
if retry != nil && retry.MaxAttempts > 0 {
3343
attempts = retry.MaxAttempts
@@ -36,7 +46,6 @@ func CallStdio(ctx context.Context, cfg *spec.ToolMCP, retry *spec.ToolRetry, to
3646
if retry != nil {
3747
backoff = retry.Backoff
3848
}
39-
4049
startAll := time.Now()
4150
var lastErr error
4251
for attempt := 0; attempt < attempts; attempt++ {
@@ -62,10 +71,54 @@ func oneStdioAttempt(ctx context.Context, command string, args []string, toolNam
6271
}
6372
defer tr.Close()
6473

65-
if err := tr.Initialize(ctx); err != nil {
74+
if err := Initialize(ctx, tr); err != nil {
75+
return nil, err
76+
}
77+
return CallTool(ctx, tr, toolName, arguments)
78+
}
79+
80+
func callHTTPLoop(ctx context.Context, cfg *spec.ToolMCP, retry *spec.ToolRetry, toolName string, arguments map[string]any) (map[string]any, ExecMeta, error) {
81+
u := strings.TrimSpace(cfg.URL)
82+
if u == "" {
83+
return nil, ExecMeta{}, errors.New("mcp: http transport requires url")
84+
}
85+
attempts := 1
86+
if retry != nil && retry.MaxAttempts > 0 {
87+
attempts = retry.MaxAttempts
88+
}
89+
backoff := ""
90+
if retry != nil {
91+
backoff = retry.Backoff
92+
}
93+
startAll := time.Now()
94+
var lastErr error
95+
for attempt := 0; attempt < attempts; attempt++ {
96+
if attempt > 0 {
97+
sleepBackoff(ctx, attempt, backoff)
98+
}
99+
out, err := oneHTTPAttempt(ctx, cfg, nil, toolName, arguments)
100+
if err == nil {
101+
return out, ExecMeta{DurationMs: time.Since(startAll).Milliseconds(), CostUSD: 0}, nil
102+
}
103+
lastErr = err
104+
if !retryableTransportErr(err) {
105+
break
106+
}
107+
}
108+
return nil, ExecMeta{DurationMs: time.Since(startAll).Milliseconds(), CostUSD: 0}, lastErr
109+
}
110+
111+
func oneHTTPAttempt(ctx context.Context, cfg *spec.ToolMCP, client *http.Client, toolName string, arguments map[string]any) (map[string]any, error) {
112+
tr, err := NewHTTPTransport(cfg.URL, cfg.Headers, client)
113+
if err != nil {
114+
return nil, err
115+
}
116+
defer tr.Close()
117+
118+
if err := Initialize(ctx, tr); err != nil {
66119
return nil, err
67120
}
68-
return tr.CallTool(ctx, toolName, arguments)
121+
return CallTool(ctx, tr, toolName, arguments)
69122
}
70123

71124
func retryableTransportErr(err error) bool {

internal/tools/mcp/client.go

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ import (
66
"fmt"
77
)
88

9+
// Connector is an MCP session that can exchange JSON-RPC over a transport (stdio or HTTP).
10+
type Connector interface {
11+
RoundTrip(ctx context.Context, method string, params any) (json.RawMessage, error)
12+
Notify(ctx context.Context, method string, params map[string]any) error
13+
}
14+
915
// Initialize performs the MCP initialize + notifications/initialized handshake.
10-
func (t *StdioTransport) Initialize(ctx context.Context) error {
16+
func Initialize(ctx context.Context, c Connector) error {
1117
params := map[string]any{
1218
"protocolVersion": "2024-11-05",
1319
"capabilities": map[string]any{},
@@ -16,25 +22,18 @@ func (t *StdioTransport) Initialize(ctx context.Context) error {
1622
"version": "0",
1723
},
1824
}
19-
if _, err := t.RoundTrip(ctx, "initialize", params); err != nil {
25+
if _, err := c.RoundTrip(ctx, "initialize", params); err != nil {
2026
return err
2127
}
22-
// Notification (no response).
23-
t.mu.Lock()
24-
defer t.mu.Unlock()
25-
return t.writeMessage(map[string]any{
26-
"jsonrpc": "2.0",
27-
"method": "notifications/initialized",
28-
"params": map[string]any{},
29-
})
28+
return c.Notify(ctx, "notifications/initialized", map[string]any{})
3029
}
3130

3231
// CallTool invokes tools/call and maps the MCP result into a plain map for §13.2 output.
33-
func (t *StdioTransport) CallTool(ctx context.Context, name string, arguments map[string]any) (map[string]any, error) {
32+
func CallTool(ctx context.Context, c Connector, name string, arguments map[string]any) (map[string]any, error) {
3433
if arguments == nil {
3534
arguments = map[string]any{}
3635
}
37-
raw, err := t.RoundTrip(ctx, "tools/call", map[string]any{
36+
raw, err := c.RoundTrip(ctx, "tools/call", map[string]any{
3837
"name": name,
3938
"arguments": arguments,
4039
})

internal/tools/mcp/doc.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
// Package mcp implements MCP tool transport (MVP: stdio JSON-RPC) per design doc §7.3.
1+
// Package mcp implements MCP tool transport per design doc §7.3.
22
//
3-
// [CallStdio] runs a subprocess, performs initialize / notifications/initialized, then tools/call.
3+
// Call runs tools/call over stdio (subprocess) or streamable HTTP per spec.mcp.transport (issue #77):
4+
// initialize + notifications/initialized, then tools/call. HTTPS uses the Go default HTTP transport
5+
// with standard TLS certificate verification.
46
package mcp

0 commit comments

Comments
 (0)