mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat: support legacy SSE MCP servers (#117)
This commit is contained in:
1 parent
71fa919599
commit
1a388e3e9e
8 files changed
+386
-21
No files matched your search
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user