Files
LocalAI/tests/e2e/e2e_mcp_test.go
Ettore Di Giacinto 8818452d85 feat(ui): MCP Apps, mcp streaming and client-side support (#8947)
* Revert "fix: Add timeout-based wait for model deletion completion (#8756)"

This reverts commit 9e1b0d0c82.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat: add mcp prompts and resources

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add client-side MCP

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): allow to authenticate MCP servers

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add MCP Apps

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: update AGENTS

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: allow to collapse navbar, save state in storage

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add MCP button also to home page

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): populate string content

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-11 07:30:49 +01:00

440 lines
15 KiB
Go

package e2e_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/openai/openai-go/v3"
)
// startMockMCPServer creates an in-process MCP HTTP server with a "get_weather" tool
// and returns its URL and a shutdown function.
func startMockMCPServer() (string, func()) {
server := mcp.NewServer(
&mcp.Implementation{Name: "mock-mcp", Version: "v1.0.0"},
nil,
)
server.AddTool(
&mcp.Tool{
Name: "get_weather",
Description: "Get the current weather for a location",
InputSchema: json.RawMessage(`{"type":"object","properties":{"location":{"type":"string","description":"City name"}},"required":["location"]}`),
},
func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args struct {
Location string `json:"location"`
}
if req.Params.Arguments != nil {
data, _ := json.Marshal(req.Params.Arguments)
json.Unmarshal(data, &args)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{
Text: fmt.Sprintf("Weather in %s: sunny, 72°F", args.Location),
},
},
}, nil
},
)
handler := mcp.NewStreamableHTTPHandler(
func(r *http.Request) *mcp.Server { return server },
&mcp.StreamableHTTPOptions{
Stateless: true,
},
)
listener, err := net.Listen("tcp", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred())
httpServer := &http.Server{Handler: handler}
go httpServer.Serve(listener)
url := fmt.Sprintf("http://%s/mcp", listener.Addr().String())
shutdown := func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
httpServer.Shutdown(ctx)
}
return url, shutdown
}
// mcpModelConfig generates a model config YAML that includes MCP remote server config.
func mcpModelConfig(mcpServerURL string) map[string]any {
mcpRemote := fmt.Sprintf(`{"mcpServers":{"weather-api":{"url":"%s"}}}`, mcpServerURL)
return map[string]any{
"name": "mock-model-mcp",
"backend": "mock-backend",
"parameters": map[string]any{
"model": "mock-model-mcp.bin",
},
"mcp": map[string]any{
"remote": mcpRemote,
},
"agent": map[string]any{
// The mock backend returns a tool call on the first inference, then
// a plain text response once tool results appear in the prompt.
// max_iterations=1 is enough for one tool-call round-trip.
"max_iterations": 1,
},
}
}
// httpPost sends a JSON POST request and returns the response.
func httpPost(url string, body any) (*http.Response, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return (&http.Client{Timeout: 60 * time.Second}).Do(req)
}
// readBody reads and returns the response body as a string.
func readBody(resp *http.Response) string {
data, err := io.ReadAll(resp.Body)
Expect(err).ToNot(HaveOccurred())
return string(data)
}
var _ = Describe("MCP Tool Integration E2E Tests", Label("MCP"), func() {
Describe("MCP Server Listing", func() {
It("should list MCP servers and tools for a configured model", func() {
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/v1/mcp/servers/mock-model-mcp", apiPort))
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
Expect(resp.StatusCode).To(Equal(200))
var result struct {
Model string `json:"model"`
Servers []struct {
Name string `json:"name"`
Type string `json:"type"`
Tools []string `json:"tools"`
} `json:"servers"`
}
Expect(json.NewDecoder(resp.Body).Decode(&result)).To(Succeed())
Expect(result.Model).To(Equal("mock-model-mcp"))
Expect(result.Servers).To(HaveLen(1))
Expect(result.Servers[0].Name).To(Equal("weather-api"))
Expect(result.Servers[0].Tools).To(ContainElement("get_weather"))
})
It("should return empty servers for a model without MCP config", func() {
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/v1/mcp/servers/mock-model", apiPort))
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
Expect(resp.StatusCode).To(Equal(200))
var result struct {
Servers []any `json:"servers"`
}
Expect(json.NewDecoder(resp.Body).Decode(&result)).To(Succeed())
Expect(result.Servers).To(BeEmpty())
})
})
Describe("OpenAI Chat Completions with MCP", func() {
Context("Non-streaming", func() {
It("should inject and execute MCP tools when mcp_servers is set", func() {
body := map[string]any{
"model": "mock-model-mcp",
"messages": []map[string]string{{"role": "user", "content": "What is the weather in San Francisco?"}},
"metadata": map[string]string{"mcp_servers": "weather-api"},
}
resp, err := httpPost(apiURL+"/chat/completions", body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
respBody := readBody(resp)
Expect(resp.StatusCode).To(Equal(200), "unexpected status, body: %s", respBody)
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
Expect(json.Unmarshal([]byte(respBody), &result)).To(Succeed())
Expect(result.Choices).To(HaveLen(1))
Expect(result.Choices[0].Message.Content).To(ContainSubstring("weather"))
})
It("should not inject MCP tools when mcp_servers is not set", func() {
resp, err := client.Chat.Completions.New(
context.TODO(),
openai.ChatCompletionNewParams{
Model: "mock-model-mcp",
Messages: []openai.ChatCompletionMessageParamUnion{
openai.UserMessage("Hello"),
},
},
)
Expect(err).ToNot(HaveOccurred())
Expect(len(resp.Choices)).To(Equal(1))
Expect(resp.Choices[0].Message.Content).To(ContainSubstring("mocked response"))
})
})
Context("Streaming", func() {
It("should work with MCP tools in streaming mode", func() {
body := map[string]any{
"model": "mock-model-mcp",
"messages": []map[string]string{{"role": "user", "content": "What is the weather?"}},
"metadata": map[string]string{"mcp_servers": "weather-api"},
"stream": true,
}
resp, err := httpPost(apiURL+"/chat/completions", body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
Expect(resp.StatusCode).To(Equal(200))
Expect(resp.Header.Get("Content-Type")).To(ContainSubstring("text/event-stream"))
data, err := io.ReadAll(resp.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("data:"))
})
})
})
Describe("Anthropic Messages with MCP", func() {
Context("Non-streaming", func() {
It("should inject and execute MCP tools when mcp_servers is set", func() {
body := map[string]any{
"model": "mock-model-mcp",
"max_tokens": 1024,
"messages": []map[string]string{{"role": "user", "content": "What is the weather?"}},
"metadata": map[string]string{"mcp_servers": "weather-api"},
}
resp, err := httpPost(fmt.Sprintf("http://127.0.0.1:%d/v1/messages", apiPort), body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
respBody := readBody(resp)
Expect(resp.StatusCode).To(Equal(200), "unexpected status, body: %s", respBody)
var result map[string]any
Expect(json.Unmarshal([]byte(respBody), &result)).To(Succeed())
content, ok := result["content"].([]any)
Expect(ok).To(BeTrue())
Expect(content).ToNot(BeEmpty())
first, ok := content[0].(map[string]any)
Expect(ok).To(BeTrue())
Expect(first["text"]).To(ContainSubstring("weather"))
})
It("should return standard response without mcp_servers", func() {
body := map[string]any{
"model": "mock-model-mcp",
"max_tokens": 1024,
"messages": []map[string]string{{"role": "user", "content": "Hello"}},
}
resp, err := httpPost(fmt.Sprintf("http://127.0.0.1:%d/v1/messages", apiPort), body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
Expect(resp.StatusCode).To(Equal(200))
var result map[string]any
Expect(json.NewDecoder(resp.Body).Decode(&result)).To(Succeed())
content, ok := result["content"].([]any)
Expect(ok).To(BeTrue())
Expect(content).ToNot(BeEmpty())
first, ok := content[0].(map[string]any)
Expect(ok).To(BeTrue())
Expect(first["text"]).To(ContainSubstring("mocked response"))
})
})
Context("Streaming", func() {
It("should work with MCP tools in streaming mode", func() {
body := map[string]any{
"model": "mock-model-mcp",
"max_tokens": 1024,
"messages": []map[string]string{{"role": "user", "content": "What is the weather?"}},
"metadata": map[string]string{"mcp_servers": "weather-api"},
"stream": true,
}
resp, err := httpPost(fmt.Sprintf("http://127.0.0.1:%d/v1/messages", apiPort), body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
Expect(resp.StatusCode).To(Equal(200))
data, err := io.ReadAll(resp.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("event:"))
})
})
})
Describe("Open Responses with MCP", func() {
Context("Non-streaming", func() {
It("should inject and execute MCP tools when mcp_servers is set", func() {
body := map[string]any{
"model": "mock-model-mcp",
"input": "What is the weather in San Francisco?",
"metadata": map[string]string{"mcp_servers": "weather-api"},
}
resp, err := httpPost(fmt.Sprintf("http://127.0.0.1:%d/v1/responses", apiPort), body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
respBody := readBody(resp)
Expect(resp.StatusCode).To(Equal(200), "unexpected status, body: %s", respBody)
var result map[string]any
Expect(json.Unmarshal([]byte(respBody), &result)).To(Succeed())
// Open Responses wraps output in an "output" array
output, ok := result["output"].([]any)
Expect(ok).To(BeTrue(), "expected output array in response: %s", respBody)
Expect(output).ToNot(BeEmpty())
})
It("should auto-activate MCP tools without mcp_servers (backward compat)", func() {
// Open Responses auto-activates all MCP servers when no metadata
// mcp_servers key is provided and no user tools are set.
body := map[string]any{
"model": "mock-model-mcp",
"input": "Hello",
}
resp, err := httpPost(fmt.Sprintf("http://127.0.0.1:%d/v1/responses", apiPort), body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
respBody := readBody(resp)
Expect(resp.StatusCode).To(Equal(200), "unexpected status, body: %s", respBody)
var result map[string]any
Expect(json.Unmarshal([]byte(respBody), &result)).To(Succeed())
output, ok := result["output"].([]any)
Expect(ok).To(BeTrue(), "expected output array in response: %s", respBody)
Expect(output).ToNot(BeEmpty())
})
})
Context("Streaming", func() {
It("should work with MCP tools in streaming mode", func() {
body := map[string]any{
"model": "mock-model-mcp",
"input": "What is the weather?",
"metadata": map[string]string{"mcp_servers": "weather-api"},
"stream": true,
}
resp, err := httpPost(fmt.Sprintf("http://127.0.0.1:%d/v1/responses", apiPort), body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
Expect(resp.StatusCode).To(Equal(200))
data, err := io.ReadAll(resp.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("event:"))
})
})
})
Describe("Legacy /mcp endpoint", func() {
It("should auto-enable all MCP servers and complete the tool loop", func() {
body := map[string]any{
"model": "mock-model-mcp",
"messages": []map[string]string{{"role": "user", "content": "What is the weather in San Francisco?"}},
}
resp, err := httpPost(fmt.Sprintf("http://127.0.0.1:%d/mcp/v1/chat/completions", apiPort), body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
respBody := readBody(resp)
Expect(resp.StatusCode).To(Equal(200), "unexpected status, body: %s", respBody)
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
Expect(json.Unmarshal([]byte(respBody), &result)).To(Succeed())
Expect(result.Choices).To(HaveLen(1))
Expect(result.Choices[0].Message.Content).To(ContainSubstring("weather"))
})
It("should respect metadata mcp_servers when provided", func() {
body := map[string]any{
"model": "mock-model-mcp",
"messages": []map[string]string{{"role": "user", "content": "Hello"}},
"metadata": map[string]string{"mcp_servers": "non-existent-server"},
}
// Even through the /mcp endpoint, an explicit metadata selection
// should be honoured — a non-existent server means no MCP tools.
resp, err := httpPost(fmt.Sprintf("http://127.0.0.1:%d/mcp/v1/chat/completions", apiPort), body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
respBody := readBody(resp)
Expect(resp.StatusCode).To(Equal(200), "unexpected status, body: %s", respBody)
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
Expect(json.Unmarshal([]byte(respBody), &result)).To(Succeed())
Expect(result.Choices).To(HaveLen(1))
Expect(result.Choices[0].Message.Content).To(ContainSubstring("mocked response"))
})
It("should work in streaming mode", func() {
body := map[string]any{
"model": "mock-model-mcp",
"messages": []map[string]string{{"role": "user", "content": "What is the weather?"}},
"stream": true,
}
resp, err := httpPost(fmt.Sprintf("http://127.0.0.1:%d/mcp/v1/chat/completions", apiPort), body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
Expect(resp.StatusCode).To(Equal(200))
Expect(resp.Header.Get("Content-Type")).To(ContainSubstring("text/event-stream"))
data, err := io.ReadAll(resp.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("data:"))
})
})
Describe("MCP with invalid server name", func() {
It("should work without MCP tools when specifying non-existent server", func() {
body := map[string]any{
"model": "mock-model-mcp",
"messages": []map[string]string{{"role": "user", "content": "Hello"}},
"metadata": map[string]string{"mcp_servers": "non-existent-server"},
}
resp, err := httpPost(apiURL+"/chat/completions", body)
Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close()
Expect(resp.StatusCode).To(Equal(200))
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
Expect(json.NewDecoder(resp.Body).Decode(&result)).To(Succeed())
Expect(result.Choices).To(HaveLen(1))
Expect(result.Choices[0].Message.Content).To(ContainSubstring("mocked response"))
})
})
})