mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-01 13:42:20 -04:00
* 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>
440 lines
15 KiB
Go
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"))
|
|
})
|
|
})
|
|
})
|