feat: support legacy SSE MCP servers (#117)

This commit is contained in:
RuoJi6 authored and GitHub committed 2026-09-15 00:56:16 +08:00
1 parent 71fa919599
commit 1a388e3e9e
8 files changed
+386 -21

No files matched your search

+5
View File
@@ -101,6 +101,11 @@ docker compose up -d # 拉取 autumn27/artex 镜像 + postgres
镜像已含常用工具(ripgrep/curl/vim/npm/nmap…);`./skills` 与 `./data` 以绑定挂载持久化。
远程 MCP 可在系统设置中选择 `http`(Streamable HTTP)或 `sse`(旧版 SSE)。
旧版 SSE 服务通常使用 `GET /sse` 建立事件流,再通过服务返回的
`/message?sessionId=...` 接收 JSON-RPC 请求;配置时将 URL 填为 `/sse`,请求头按
`Authorization=Bearer <token>` 填写。
### 方式三:下载预编译二进制(Releases)
到 [Releases](https://github.com/Autumn-27/ARTEX/releases) 下载对应平台的 zip,解压后得到 `artex` + `start.sh`(Windows 为 `start.bat`)+ `skills/` + `config.example.json`:
+6 -1
View File
@@ -819,7 +819,7 @@ CREATE TABLE IF NOT EXISTS agent_prompt_vars (
CREATE TABLE IF NOT EXISTS mcp_servers (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
transport TEXT NOT NULL CHECK (transport IN ('stdio','http')),
transport TEXT NOT NULL CHECK (transport IN ('stdio','http','sse')),
command TEXT,
args JSONB NOT NULL DEFAULT '[]',
env JSONB NOT NULL DEFAULT '{}',
@@ -829,6 +829,11 @@ CREATE TABLE IF NOT EXISTS mcp_servers (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Allow legacy MCP SSE servers on databases created before SSE support.
ALTER TABLE mcp_servers DROP CONSTRAINT IF EXISTS mcp_servers_transport_check;
ALTER TABLE mcp_servers ADD CONSTRAINT mcp_servers_transport_check
CHECK (transport IN ('stdio','http','sse'));
-- 旧库补列(schema.sql 每次启动都会 Exec)。
ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS insecure BOOLEAN NOT NULL DEFAULT false;
DROP TRIGGER IF EXISTS trg_mcp_upd ON mcp_servers;
+240 -8
View File
@@ -1,4 +1,4 @@
// Package mcphttp is a remote (Streamable HTTP) MCP client for ARTEX.
// Package mcphttp contains remote Streamable HTTP and legacy SSE MCP clients.
//
// The core mcp package only speaks stdio; this adapts an HTTP/JSON-RPC MCP server
// to the same tool.CoreTool shape (mcp__server__tool) WITHOUT touching the SDK.
@@ -14,9 +14,11 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -27,6 +29,7 @@ import (
)
const protocolVersion = "2025-06-18"
const legacySSEProtocolVersion = "2024-11-05"
// ToolName mirrors mcp.ToolName so remote tools share the mcp__server__tool scheme.
func ToolName(server, name string) string { return "mcp__" + server + "__" + name }
@@ -52,7 +55,8 @@ type rpcError struct {
func (e *rpcError) Error() string { return fmt.Sprintf("mcp rpc error %d: %s", e.Code, e.Message) }
// Client is a Streamable HTTP connection to one remote MCP server.
// Client is a connection to one remote MCP server. New uses Streamable HTTP;
// NewSSE uses the legacy GET /sse + POST /message transport.
type Client struct {
server string
url string
@@ -62,6 +66,30 @@ type Client struct {
mu sync.Mutex
nextID int
sessionID string
// legacySSE is the pre-2025 MCP SSE transport used by servers such as GSL5.
legacySSE bool
messageURL string
streamBody io.ReadCloser
streamCancel context.CancelFunc
streamDone chan struct{}
legacyMu sync.Mutex
pendingMu sync.Mutex
pending map[int]chan *rpcResponse
streamErr chan error
protocol string
}
func normalizeHeaders(headers map[string]string) (map[string]string, error) {
out := make(map[string]string, len(headers))
for key, value := range headers {
clean := strings.TrimSpace(key)
if clean == "" || strings.ContainsAny(clean, "\r\n") {
return nil, fmt.Errorf("invalid HTTP header name %q", key)
}
out[clean] = value
}
return out, nil
}
// New connects to a remote MCP endpoint and performs the initialize handshake.
@@ -69,15 +97,20 @@ type Client struct {
// When insecure is true, TLS certificate verification is skipped so servers that
// present a self-signed certificate can still be reached (issue #108).
func New(ctx context.Context, server, url string, headers map[string]string, insecure bool) (*Client, error) {
cleanHeaders, err := normalizeHeaders(headers)
if err != nil {
return nil, err
}
hc := &http.Client{Timeout: 120 * time.Second}
if insecure {
hc.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
}
c := &Client{
server: server,
url: url,
headers: headers,
http: hc,
server: server,
url: url,
headers: cleanHeaders,
http: hc,
protocol: protocolVersion,
}
if err := c.initialize(ctx); err != nil {
return nil, err
@@ -85,9 +118,134 @@ func New(ctx context.Context, server, url string, headers map[string]string, ins
return c, nil
}
// NewSSE connects to the legacy MCP SSE transport: a long-lived GET /sse
// announces a per-session POST /message endpoint, while JSON-RPC responses are
// delivered asynchronously as SSE message events.
func NewSSE(ctx context.Context, server, sseURL string, headers map[string]string, insecure bool) (*Client, error) {
cleanHeaders, err := normalizeHeaders(headers)
if err != nil {
return nil, err
}
hc := &http.Client{} // the SSE stream is intentionally long-lived.
if insecure {
hc.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
}
streamCtx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(streamCtx, http.MethodGet, sseURL, nil)
if err != nil {
cancel()
return nil, err
}
req.Header.Set("Accept", "text/event-stream")
for k, v := range cleanHeaders {
req.Header.Set(k, v)
}
resp, err := hc.Do(req)
if err != nil {
cancel()
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer resp.Body.Close()
cancel()
return nil, fmt.Errorf("mcp sse http %d: %s", resp.StatusCode, readSnippet(resp.Body))
}
reader := bufio.NewReader(resp.Body)
endpoint, err := readSSEEndpoint(reader, sseURL)
if err != nil {
resp.Body.Close()
cancel()
return nil, err
}
c := &Client{
server: server,
url: sseURL,
headers: cleanHeaders,
http: hc,
legacySSE: true,
messageURL: endpoint,
streamBody: resp.Body,
streamCancel: cancel,
streamDone: make(chan struct{}),
pending: make(map[int]chan *rpcResponse),
streamErr: make(chan error, 1),
protocol: legacySSEProtocolVersion,
}
go c.readLegacySSE(reader)
if err := c.initialize(ctx); err != nil {
_ = c.Close()
return nil, err
}
return c, nil
}
func readSSEEndpoint(r *bufio.Reader, base string) (string, error) {
for {
line, err := r.ReadString('\n')
if err != nil {
return "", fmt.Errorf("mcp sse endpoint: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if !strings.HasPrefix(line, "data:") {
continue
}
candidate := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if candidate == "" {
continue
}
u, err := url.Parse(candidate)
if err != nil {
return "", fmt.Errorf("mcp sse endpoint URL: %w", err)
}
if !u.IsAbs() {
b, err := url.Parse(base)
if err != nil {
return "", err
}
candidate = b.ResolveReference(u).String()
}
return candidate, nil
}
}
func (c *Client) readLegacySSE(r *bufio.Reader) {
defer close(c.streamDone)
for {
line, err := r.ReadString('\n')
if err != nil {
if !errors.Is(err, context.Canceled) {
select {
case c.streamErr <- err:
default:
}
}
return
}
line = strings.TrimRight(line, "\r\n")
if !strings.HasPrefix(line, "data:") {
continue
}
var response rpcResponse
if err := json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), &response); err != nil || response.ID == 0 {
continue
}
c.pendingMu.Lock()
ch := c.pending[response.ID]
delete(c.pending, response.ID)
c.pendingMu.Unlock()
if ch != nil {
ch <- &response
}
}
}
func (c *Client) initialize(ctx context.Context) error {
version := c.protocol
if version == "" {
version = protocolVersion
}
if _, err := c.call(ctx, "initialize", map[string]any{
"protocolVersion": protocolVersion,
"protocolVersion": version,
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "artex", "version": "0.2"},
}); err != nil {
@@ -194,6 +352,19 @@ func (c *Client) Call(ctx context.Context, tool string, args any) (string, error
// Close terminates the MCP session best-effort (DELETE with the session id, per the
// Streamable HTTP spec). Servers that don't track sessions simply ignore it.
func (c *Client) Close() error {
if c.legacySSE {
if c.streamCancel != nil {
c.streamCancel()
}
if c.streamBody != nil {
_ = c.streamBody.Close()
}
select {
case <-c.streamDone:
case <-time.After(time.Second):
}
return nil
}
c.mu.Lock()
sid := c.sessionID
c.mu.Unlock()
@@ -243,6 +414,9 @@ func (c *Client) notify(ctx context.Context, method string, params any) error {
// server replies 202 Accepted with no body. Otherwise the reply is parsed from JSON
// or from an SSE stream, whichever the server chose.
func (c *Client) roundTrip(ctx context.Context, body rpcRequest, expectResp bool) (*rpcResponse, error) {
if c.legacySSE {
return c.legacyRoundTrip(ctx, body, expectResp)
}
data, err := json.Marshal(body)
if err != nil {
return nil, err
@@ -253,7 +427,11 @@ func (c *Client) roundTrip(ctx context.Context, body rpcRequest, expectResp bool
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("MCP-Protocol-Version", protocolVersion)
version := c.protocol
if version == "" {
version = protocolVersion
}
req.Header.Set("MCP-Protocol-Version", version)
for k, v := range c.headers {
req.Header.Set(k, v)
}
@@ -295,6 +473,60 @@ func (c *Client) roundTrip(ctx context.Context, body rpcRequest, expectResp bool
return &out, nil
}
// legacyRoundTrip posts to the endpoint announced by GET /sse. The HTTP POST
// only acknowledges receipt; the JSON-RPC response arrives on the SSE stream.
func (c *Client) legacyRoundTrip(ctx context.Context, body rpcRequest, expectResp bool) (*rpcResponse, error) {
// Serializing calls keeps the first implementation deterministic while the
// shared SSE reader still handles asynchronous delivery.
c.legacyMu.Lock()
defer c.legacyMu.Unlock()
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
var responseCh chan *rpcResponse
if expectResp {
responseCh = make(chan *rpcResponse, 1)
c.pendingMu.Lock()
c.pending[body.ID] = responseCh
c.pendingMu.Unlock()
defer func() {
c.pendingMu.Lock()
delete(c.pending, body.ID)
c.pendingMu.Unlock()
}()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.messageURL, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
for k, v := range c.headers {
req.Header.Set(k, v)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("mcp sse http %d: %s", resp.StatusCode, readSnippet(resp.Body))
}
if !expectResp {
return nil, nil
}
select {
case response := <-responseCh:
return response, nil
case err := <-c.streamErr:
return nil, fmt.Errorf("mcp sse stream: %w", err)
case <-ctx.Done():
return nil, ctx.Err()
}
}
// parseSSE reads an SSE stream and returns the first data frame that is a JSON-RPC
// response matching wantID (server-to-client requests/notifications are skipped).
func parseSSE(r io.Reader, wantID int) (*rpcResponse, error) {
+107
View File
@@ -0,0 +1,107 @@
package mcphttp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func TestLegacySSEClientDiscoverAndCall(t *testing.T) {
var mu sync.Mutex
var stream http.ResponseWriter
var flusher http.Flusher
sessions := make(map[string]bool)
messages := make(chan []byte, 8)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/sse":
id := "test-session"
mu.Lock()
sessions[id] = true
stream, flusher = w, w.(http.Flusher)
mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
fmt.Fprintf(w, "event: endpoint\ndata: %s/message?sessionId=%s\n\n", "http://"+r.Host, id)
flusher.Flush()
for {
select {
case payload := <-messages:
fmt.Fprintf(w, "event: message\ndata: %s\n\n", payload)
flusher.Flush()
case <-r.Context().Done():
return
}
}
case "/message":
mu.Lock()
knownSession := sessions["test-session"]
mu.Unlock()
if r.URL.Query().Get("sessionId") != "test-session" || !knownSession {
http.Error(w, "unknown session", http.StatusBadRequest)
return
}
var req rpcRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusAccepted)
if req.ID == 0 {
return
}
var result any
switch req.Method {
case "initialize":
result = map[string]any{"protocolVersion": "2024-11-05", "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]any{"name": "test", "version": "1"}}
case "tools/list":
result = map[string]any{"tools": []any{map[string]any{"name": "echo", "description": "echo input", "inputSchema": map[string]any{"type": "object"}}}}
case "tools/call":
result = map[string]any{"content": []any{map[string]any{"type": "text", "text": "ok"}}}
default:
result = map[string]any{}
}
resultJSON, err := json.Marshal(result)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload, _ := json.Marshal(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(resultJSON)})
mu.Lock()
streamReady := stream != nil
mu.Unlock()
if streamReady {
messages <- payload
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, err := NewSSE(ctx, "test", srv.URL+"/sse", map[string]string{"Authorization ": "Bearer test"}, false)
if err != nil {
t.Fatal(err)
}
defer c.Close()
tools, err := c.Tools(ctx)
if err != nil {
t.Fatal(err)
}
if len(tools) != 1 || !strings.Contains(tools[0].Name(), "echo") {
t.Fatalf("unexpected tools: %#v", tools)
}
got, err := c.Call(ctx, "echo", map[string]any{"value": "x"})
if err != nil || got != "ok" {
t.Fatalf("Call() = %q, %v", got, err)
}
}
+9 -3
View File
@@ -6,13 +6,14 @@ import (
"log"
"time"
"github.com/Autumn-27/norma/mcp"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/mcphttp"
"github.com/Autumn-27/norma/mcp"
actool "github.com/Autumn-27/norma/tool"
)
// mcpClient is the shared surface of a connected MCP server (stdio or remote http),
// mcpClient is the shared surface of a connected MCP server (stdio, Streamable HTTP,
// or legacy SSE),
// so tools/list and cleanup are handled uniformly regardless of transport.
type mcpClient interface {
Tools(context.Context) ([]actool.CoreTool, error)
@@ -33,6 +34,11 @@ func connectMCP(ctx context.Context, m *db.MCPServer) (mcpClient, error) {
}
// env map doubles as HTTP headers (e.g. Authorization).
return mcphttp.New(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure)
case "sse":
if m.URL == "" {
return nil, fmt.Errorf("sse 传输缺少 URL")
}
return mcphttp.NewSSE(ctx, m.Name, m.URL, jsonStrMap(m.Env), m.Insecure)
default:
return nil, fmt.Errorf("未知传输方式 %q", m.Transport)
}
+1 -1
View File
@@ -292,7 +292,7 @@ func (a mcpToolInput) toDB() *db.MCPServer {
}
func (s *Server) toolCreateMCP() actool.CoreTool {
return wrTool("create_mcp", "创建一个 MCP 服务器(stdio/http)。创建后其工具需按 agent 可见性授权。",
return wrTool("create_mcp", "创建一个 MCP 服务器(stdio/http/sse)。创建后其工具需按 agent 可见性授权。",
mcpSchema(false),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a mcpToolInput
+17 -7
View File
@@ -23,7 +23,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { MCPServer, MCPTool, Agent } from "@/lib/types";
type Transport = "stdio" | "http";
type Transport = "stdio" | "http" | "sse";
type FormState = {
name: string;
transport: Transport;
@@ -86,7 +86,10 @@ export default function MCPPage() {
const trimmed = line.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf("=");
if (idx > 0) out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
if (idx > 0) {
const key = trimmed.slice(0, idx).trim();
if (key) out[key] = trimmed.slice(idx + 1).trim();
}
}
return out;
}
@@ -140,16 +143,16 @@ export default function MCPPage() {
toast.error("请填写命令");
return;
}
if (form.transport === "http" && !form.url.trim()) {
if (form.transport !== "stdio" && !form.url.trim()) {
toast.error("请填写远程 URL");
return;
}
setSaving(true);
try {
const base =
form.transport === "http"
form.transport !== "stdio"
? {
transport: "http" as const,
transport: form.transport,
url: form.url.trim(),
command: "",
args: [] as string[],
@@ -245,6 +248,13 @@ export default function MCPPage() {
>
http(远程)
</Button>
<Button
type="button"
variant={form.transport === "sse" ? "default" : "outline"}
onClick={() => setF({ transport: "sse" })}
>
sse(旧版)
</Button>
</div>
</div>
<div className="grid gap-2">
@@ -300,7 +310,7 @@ export default function MCPPage() {
)}
<div className="grid gap-2">
<Label htmlFor="m-env">
{form.transport === "http"
{form.transport !== "stdio"
? "请求头(每行 KEY=VALUE,如 Authorization=Bearer xxx)"
: "环境变量(每行 KEY=VALUE)"}
</Label>
@@ -308,7 +318,7 @@ export default function MCPPage() {
id="m-env"
className="font-mono"
placeholder={
form.transport === "http" ? "Authorization=Bearer xxxx" : "API_KEY=xxxx\nFOO=bar"
form.transport !== "stdio" ? "Authorization=Bearer xxxx" : "API_KEY=xxxx\nFOO=bar"
}
value={form.env}
onChange={(e) => setF({ env: e.target.value })}
+1 -1
View File
@@ -1064,7 +1064,7 @@ export interface AgentDetail {
export interface MCPServer {
id: number;
name: string;
transport: "stdio" | "http";
transport: "stdio" | "http" | "sse";
command?: string;
args: string[];
env: Record<string, string>;