Skip to content

Commit 3b017c2

Browse files
jyxjjjcodex
andauthored
feat(mcp): add mcp endpoint for whole openlist (#2485)
* feat(mcp): add initial MCP tools support - 新增 MCP 路由与 session 初始化流程 - 接入 tools/list 与 tools/call,并开放 openlist.fs.list - 补充 MCP 相关协议与路由测试 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * refactor(mcp): move MCP implementation into server/mcp - 将 MCP 实现与测试迁移到 server/mcp 目录 - 保留 server/mcp.go 作为路由接入包装入口 - 对齐 webdav、s3、ftp、sftp 的目录组织风格 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * fix(mcp): address endpoint review issues - 收紧 Streamable HTTP Accept 头校验 - 为 MCP session 增加过期清理和数量上限 - 简化 fs.list 参数解析并修复分页边界 - 使用独立 Server 实例隔离 MCP 测试状态 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * feat(mcp): add fs get and link tools - 新增 openlist.fs.get 和 openlist.fs.link 工具 - 复用文件详情、代理链接和权限校验逻辑 - 补充工具列表和参数解析测试 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * fix(mcp): improve protocol negotiation and proxy handling - Remove WebDAV proxy URL policy from MCP proxy link detection - Return server protocol version during initialize negotiation - Return JSON-RPC error body for invalid MCP Accept header - Add tests for MCP proxy and HTTP negotiation behavior Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * fix(mcp): set Allow header for GET requests - 为 MCP GET 405 响应添加 Allow 头 - 补充 GET 405 响应头断言 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * refactor(mcp): use mutex for session store - 将 MCP session 锁改为 Mutex - 让锁类型匹配会更新 lastUsedAt 的访问路径 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * fix(mcp): negotiate initialize protocol version - 添加 MCP 协议版本协商函数 - 不支持客户端版本时返回服务端支持版本 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * revert(mcp): drop protocol version negotiation wrapper - 撤回 MCP 协议版本协商包装函数 - 恢复 initialize 直接返回服务端协议版本 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * fix(mcp): limit and reuse sessions - 将 MCP 全局 session 上限调整为 128 - 添加单用户 16 个 session 上限 - initialize 复用同用户已有 session 标识 - 补充 session 复用和上限裁剪测试 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * fix(mcp): require path for fs list - 拒绝 openlist.fs.list 的空参数和 null 参数 - 校验 fs.list 缺失 path 时返回 -32602 - 添加 fs.list 参数解析测试覆盖错误文案 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * fix(mcp): enforce protocol version header - 校验非 initialize 请求的 MCP-Protocol-Version 头 - 拒绝缺失或不支持协议版本的后续 POST 请求 - 添加协议版本头缺失和不匹配测试 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * test(mcp): serialize setting cache mutation - 为修改全局设置缓存的测试添加包级互斥锁 - 保留 ClearAll 清理逻辑,避免并发测试互相影响 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * feat(mcp): add config switch - Add MCP config section with disabled default - Register MCP routes only when enabled Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * fix(mcp): handle missing session explicitly - 区分缺失 MCP session 与未知 MCP session - 为未知 session 返回 not found 错误 - 添加 MCP session 错误处理测试 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * feat(mcp): support latest protocol negotiation - Upgrade default MCP protocol version to 2025-11-25 - Preserve 2025-06-18 compatibility through initialize negotiation - Validate subsequent request protocol version against the negotiated session version - Add tests for latest and older protocol negotiation paths Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * fix(mcp): relax streamable http compatibility - 允许缺失协议版本头时使用会话协商版本 - 放宽 Accept 头兼容 JSON、SSE 和通配类型 - 补充 MCP 初始化和协议版本兼容性测试 Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> --------- Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
1 parent a42e367 commit 3b017c2

14 files changed

Lines changed: 2311 additions & 0 deletions

File tree

‎internal/conf/config.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ type SFTP struct {
106106
Listen string `json:"listen" env:"LISTEN"`
107107
}
108108

109+
type MCP struct {
110+
Enable bool `json:"enable" env:"ENABLE"`
111+
}
112+
109113
type Config struct {
110114
Force bool `json:"force" env:"FORCE"`
111115
SiteURL string `json:"site_url" env:"SITE_URL"`
@@ -131,6 +135,7 @@ type Config struct {
131135
S3 S3 `json:"s3" envPrefix:"S3_"`
132136
FTP FTP `json:"ftp" envPrefix:"FTP_"`
133137
SFTP SFTP `json:"sftp" envPrefix:"SFTP_"`
138+
MCP MCP `json:"mcp" envPrefix:"MCP_"`
134139
LastLaunchedVersion string `json:"last_launched_version"`
135140
ProxyAddress string `json:"proxy_address" env:"PROXY_ADDRESS"`
136141
}
@@ -244,6 +249,9 @@ func DefaultConfig(dataDir string) *Config {
244249
Enable: false,
245250
Listen: ":5222",
246251
},
252+
MCP: MCP{
253+
Enable: false,
254+
},
247255
LastLaunchedVersion: "",
248256
ProxyAddress: "",
249257
}

‎server/mcp.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package server
2+
3+
import (
4+
"github.com/OpenListTeam/OpenList/v4/internal/conf"
5+
"github.com/OpenListTeam/OpenList/v4/server/common"
6+
"github.com/OpenListTeam/OpenList/v4/server/mcp"
7+
"github.com/gin-gonic/gin"
8+
)
9+
10+
func MCP(g *gin.RouterGroup) {
11+
if !conf.Conf.MCP.Enable {
12+
g.Any("/mcp", func(c *gin.Context) {
13+
common.ErrorStrResp(c, "MCP server is not enabled", 403)
14+
})
15+
return
16+
}
17+
mcp.Register(g)
18+
}

‎server/mcp/call.go‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
7+
"github.com/gin-gonic/gin"
8+
)
9+
10+
type toolCallParams struct {
11+
Name string `json:"name"`
12+
Arguments json.RawMessage `json:"arguments"`
13+
}
14+
15+
type toolResultContent struct {
16+
Type string `json:"type"`
17+
Text string `json:"text"`
18+
}
19+
20+
func (s *Server) handleToolsCall(c *gin.Context, req request) (int, response) {
21+
var params toolCallParams
22+
if len(req.Params) == 0 {
23+
return http.StatusBadRequest, response{
24+
JSONRPC: "2.0",
25+
ID: req.ID,
26+
Error: &rpcError{Code: -32602, Message: "invalid tools/call params"},
27+
}
28+
}
29+
if err := json.Unmarshal(req.Params, &params); err != nil || params.Name == "" {
30+
return http.StatusBadRequest, response{
31+
JSONRPC: "2.0",
32+
ID: req.ID,
33+
Error: &rpcError{Code: -32602, Message: "invalid tools/call params"},
34+
}
35+
}
36+
37+
var (
38+
result any
39+
err *rpcError
40+
)
41+
switch params.Name {
42+
case "openlist.fs.list":
43+
result, err = s.callFSList(c, params.Arguments)
44+
case "openlist.fs.get":
45+
result, err = s.callFSGet(c, params.Arguments)
46+
case "openlist.fs.link":
47+
result, err = s.callFSLink(c, params.Arguments)
48+
default:
49+
return http.StatusOK, response{
50+
JSONRPC: "2.0",
51+
ID: req.ID,
52+
Error: &rpcError{Code: -32601, Message: "unknown tool"},
53+
}
54+
}
55+
56+
if err != nil {
57+
return http.StatusOK, response{
58+
JSONRPC: "2.0",
59+
ID: req.ID,
60+
Result: map[string]any{
61+
"content": []toolResultContent{
62+
{Type: "text", Text: err.Message},
63+
},
64+
"isError": true,
65+
},
66+
}
67+
}
68+
69+
resultJSON, marshalErr := json.Marshal(result)
70+
if marshalErr != nil {
71+
return http.StatusInternalServerError, response{
72+
JSONRPC: "2.0",
73+
ID: req.ID,
74+
Error: &rpcError{Code: -32603, Message: "failed to encode tool result"},
75+
}
76+
}
77+
78+
return http.StatusOK, response{
79+
JSONRPC: "2.0",
80+
ID: req.ID,
81+
Result: map[string]any{
82+
"content": []toolResultContent{
83+
{Type: "text", Text: string(resultJSON)},
84+
},
85+
"structuredContent": result,
86+
},
87+
}
88+
}

‎server/mcp/call_test.go‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
10+
"github.com/OpenListTeam/OpenList/v4/internal/conf"
11+
"github.com/OpenListTeam/OpenList/v4/internal/model"
12+
"github.com/OpenListTeam/OpenList/v4/server/common"
13+
"github.com/gin-gonic/gin"
14+
)
15+
16+
func TestToolsListRequiresInitializedSession(t *testing.T) {
17+
gin.SetMode(gin.TestMode)
18+
srv := newTestServer(map[string]*session{
19+
"s1": {id: "s1", userID: 1},
20+
})
21+
22+
r := gin.New()
23+
r.POST("/mcp", func(c *gin.Context) {
24+
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
25+
srv.handlePost(c)
26+
})
27+
28+
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
29+
"jsonrpc":"2.0",
30+
"id":1,
31+
"method":"tools/list"
32+
}`))
33+
req.Header.Set("Accept", "application/json, text/event-stream")
34+
req.Header.Set("Origin", "http://example.com")
35+
req.Header.Set(ProtocolVersionHeader, ProtocolVersion)
36+
req.Header.Set(SessionHeader, "s1")
37+
38+
w := httptest.NewRecorder()
39+
r.ServeHTTP(w, req)
40+
41+
if w.Code != http.StatusBadRequest {
42+
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusBadRequest)
43+
}
44+
resp := decodeResponse(t, w)
45+
if resp.Error == nil || resp.Error.Code != -32002 {
46+
t.Fatalf("unexpected error response: %+v", resp.Error)
47+
}
48+
}
49+
50+
func TestToolsListSuccess(t *testing.T) {
51+
gin.SetMode(gin.TestMode)
52+
srv := newTestServer(map[string]*session{
53+
"s2": {id: "s2", userID: 1, initialized: true},
54+
})
55+
56+
r := gin.New()
57+
r.POST("/mcp", func(c *gin.Context) {
58+
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
59+
srv.handlePost(c)
60+
})
61+
62+
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
63+
"jsonrpc":"2.0",
64+
"id":2,
65+
"method":"tools/list"
66+
}`))
67+
req.Header.Set("Accept", "application/json, text/event-stream")
68+
req.Header.Set("Origin", "http://example.com")
69+
req.Header.Set(ProtocolVersionHeader, ProtocolVersion)
70+
req.Header.Set(SessionHeader, "s2")
71+
72+
w := httptest.NewRecorder()
73+
r.ServeHTTP(w, req)
74+
75+
if w.Code != http.StatusOK {
76+
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK)
77+
}
78+
resp := decodeResponse(t, w)
79+
if resp.Error != nil {
80+
t.Fatalf("unexpected error response: %+v", resp.Error)
81+
}
82+
83+
result, ok := resp.Result.(map[string]any)
84+
if !ok {
85+
t.Fatalf("unexpected result type: %T", resp.Result)
86+
}
87+
tools, ok := result["tools"].([]any)
88+
if !ok || len(tools) != 3 {
89+
t.Fatalf("unexpected tools payload: %#v", result["tools"])
90+
}
91+
names := map[string]bool{}
92+
for _, rawTool := range tools {
93+
currentTool, ok := rawTool.(map[string]any)
94+
if !ok {
95+
t.Fatalf("unexpected tool payload: %#v", rawTool)
96+
}
97+
name, _ := currentTool["name"].(string)
98+
names[name] = true
99+
}
100+
if !names["openlist.fs.list"] || !names["openlist.fs.get"] || !names["openlist.fs.link"] {
101+
t.Fatalf("unexpected tool names: %#v", names)
102+
}
103+
}
104+
105+
func TestToolsCallUnknownTool(t *testing.T) {
106+
gin.SetMode(gin.TestMode)
107+
srv := newTestServer(map[string]*session{
108+
"s3": {id: "s3", userID: 1, initialized: true},
109+
})
110+
111+
r := gin.New()
112+
r.POST("/mcp", func(c *gin.Context) {
113+
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
114+
srv.handlePost(c)
115+
})
116+
117+
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
118+
"jsonrpc":"2.0",
119+
"id":3,
120+
"method":"tools/call",
121+
"params":{"name":"openlist.fs.unknown","arguments":{"path":"/"}}
122+
}`))
123+
req.Header.Set("Accept", "application/json, text/event-stream")
124+
req.Header.Set("Origin", "http://example.com")
125+
req.Header.Set(ProtocolVersionHeader, ProtocolVersion)
126+
req.Header.Set(SessionHeader, "s3")
127+
128+
w := httptest.NewRecorder()
129+
r.ServeHTTP(w, req)
130+
131+
if w.Code != http.StatusOK {
132+
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK)
133+
}
134+
resp := decodeResponse(t, w)
135+
if resp.Error == nil || resp.Error.Code != -32601 {
136+
t.Fatalf("unexpected error response: %+v", resp.Error)
137+
}
138+
}
139+
140+
func TestToolsCallInvalidParams(t *testing.T) {
141+
gin.SetMode(gin.TestMode)
142+
srv := newTestServer(map[string]*session{
143+
"s4": {id: "s4", userID: 1, initialized: true},
144+
})
145+
146+
r := gin.New()
147+
r.POST("/mcp", func(c *gin.Context) {
148+
common.GinAppendValues(c, conf.UserKey, &model.User{ID: 1, Role: model.ADMIN})
149+
srv.handlePost(c)
150+
})
151+
152+
req := httptest.NewRequest(http.MethodPost, "http://example.com/mcp", strings.NewReader(`{
153+
"jsonrpc":"2.0",
154+
"id":4,
155+
"method":"tools/call",
156+
"params":{"name":"openlist.fs.list","arguments":"bad"}
157+
}`))
158+
req.Header.Set("Accept", "application/json, text/event-stream")
159+
req.Header.Set("Origin", "http://example.com")
160+
req.Header.Set(ProtocolVersionHeader, ProtocolVersion)
161+
req.Header.Set(SessionHeader, "s4")
162+
163+
w := httptest.NewRecorder()
164+
r.ServeHTTP(w, req)
165+
166+
if w.Code != http.StatusOK {
167+
t.Fatalf("unexpected status: got %d want %d", w.Code, http.StatusOK)
168+
}
169+
resp := decodeResponse(t, w)
170+
if resp.Error != nil {
171+
t.Fatalf("expected tool error result, got protocol error: %+v", resp.Error)
172+
}
173+
}
174+
175+
func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) response {
176+
t.Helper()
177+
178+
var resp response
179+
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
180+
t.Fatalf("failed to decode response: %v", err)
181+
}
182+
return resp
183+
}

0 commit comments

Comments
 (0)