mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-15 06:33:50 +08:00
Restrict which source addresses may call /mcp with a list of IPs/CIDRs (IPv4 and IPv6). The check runs before token authentication, so a source that is not listed is refused with 403 even when it holds a valid token. An empty list keeps the previous behaviour (any source). Behind a reverse proxy the TCP peer is always the proxy, so proxies can be registered as trusted: for requests from them the client is the rightmost X-Forwarded-For hop that is not itself a trusted proxy. Without a trusted proxy the header is ignored, so a caller cannot claim an allowed address. - A stored configuration that cannot be read or parsed refuses requests instead of falling back to no restriction. - The audit log records the resolved client address. - The settings API reports the address the server sees for the caller. - The allowlist applies to /mcp only; the web UI and admin API are unaffected. Refs RuoJi6/ARTEX#144
786 lines
31 KiB
Go
786 lines
31 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/mcphttp"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// mcpMemoryStore backs the endpoint without PostgreSQL.
|
|
type mcpMemoryStore struct {
|
|
mu sync.Mutex
|
|
enabled bool
|
|
tokens map[string]*db.APIToken
|
|
access string // JSON stored under db.SettingMCPServerAccess
|
|
touched []int64
|
|
audit []db.MCPAuditEntry
|
|
}
|
|
|
|
func (m *mcpMemoryStore) GetBool(key string, def bool) bool {
|
|
if key == db.SettingMCPServerEnabled {
|
|
return m.enabled
|
|
}
|
|
return def
|
|
}
|
|
|
|
func (m *mcpMemoryStore) GetSetting(key string) (string, bool, error) {
|
|
if key == db.SettingMCPServerAccess && m.access != "" {
|
|
return m.access, true, nil
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
func (m *mcpMemoryStore) APITokenByHash(hash string) (*db.APIToken, error) {
|
|
return m.tokens[hash], nil
|
|
}
|
|
|
|
func (m *mcpMemoryStore) TouchAPIToken(id int64) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.touched = append(m.touched, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mcpMemoryStore) InsertMCPAudit(e db.MCPAuditEntry) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.audit = append(m.audit, e)
|
|
return nil
|
|
}
|
|
|
|
func (m *mcpMemoryStore) add(id int64, plaintext string, scopes ...string) *db.APIToken {
|
|
token := &db.APIToken{ID: id, Name: "token-" + strconv.FormatInt(id, 10), Scopes: scopes}
|
|
m.tokens[db.HashAPIToken(plaintext)] = token
|
|
return token
|
|
}
|
|
|
|
const (
|
|
mcpTestReadToken = db.APITokenPrefix + "read-only"
|
|
mcpTestFullToken = db.APITokenPrefix + "full"
|
|
)
|
|
|
|
func mcpTestServer(t *testing.T) (*Server, *mcpMemoryStore) {
|
|
t.Helper()
|
|
store := &mcpMemoryStore{enabled: true, tokens: map[string]*db.APIToken{}}
|
|
store.add(1, mcpTestReadToken, db.APIScopeRead)
|
|
store.add(2, mcpTestFullToken, db.APIScopeRead, db.APIScopeTaskCreate, db.APIScopeTaskControl)
|
|
s := &Server{jwtKey: []byte("test-only-signing-key-32-characters"), mcpStoreOverride: store}
|
|
if err := s.basicAuth.load(&basicAuthMemoryStore{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s, store
|
|
}
|
|
|
|
func mcpPost(s *Server, token, body string, headers ...string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(http.MethodPost, mcpEndpointPath, strings.NewReader(body))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
for i := 0; i+1 < len(headers); i += 2 {
|
|
r.Header.Set(headers[i], headers[i+1])
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
type mcpTestResponse struct {
|
|
ID json.RawMessage `json:"id"`
|
|
Result struct {
|
|
ProtocolVersion string `json:"protocolVersion"`
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
InputSchema map[string]any `json:"inputSchema"`
|
|
Annotations struct {
|
|
ReadOnlyHint bool `json:"readOnlyHint"`
|
|
} `json:"annotations"`
|
|
} `json:"tools"`
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
IsError bool `json:"isError"`
|
|
} `json:"result"`
|
|
Error *mcpRPCError `json:"error"`
|
|
}
|
|
|
|
func decodeMCP(t *testing.T, w *httptest.ResponseRecorder) mcpTestResponse {
|
|
t.Helper()
|
|
var out mcpTestResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode %q: %v", w.Body.String(), err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestMCPServerDisabledByDefault(t *testing.T) {
|
|
s, store := mcpTestServer(t)
|
|
store.enabled = false
|
|
if w := mcpPost(s, mcpTestFullToken, `{"jsonrpc":"2.0","id":1,"method":"ping"}`); w.Code != http.StatusNotFound {
|
|
t.Fatalf("disabled endpoint status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMCPServerAuthentication(t *testing.T) {
|
|
s, store := mcpTestServer(t)
|
|
ping := `{"jsonrpc":"2.0","id":1,"method":"ping"}`
|
|
past := time.Now().Add(-time.Minute)
|
|
store.add(3, db.APITokenPrefix+"revoked", db.APIScopeRead).RevokedAt = &past
|
|
store.add(4, db.APITokenPrefix+"expired", db.APIScopeRead).ExpiresAt = &past
|
|
adminJWT, err := signJWT(s.jwtKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, token := range map[string]string{
|
|
"missing": "", "unknown": db.APITokenPrefix + "nope", "revoked": db.APITokenPrefix + "revoked",
|
|
"expired": db.APITokenPrefix + "expired", "admin jwt": adminJWT,
|
|
} {
|
|
w := mcpPost(s, token, ping)
|
|
if w.Code != http.StatusUnauthorized || !strings.HasPrefix(w.Header().Get("WWW-Authenticate"), "Bearer") {
|
|
t.Errorf("%s: status=%d challenge=%q", name, w.Code, w.Header().Get("WWW-Authenticate"))
|
|
}
|
|
}
|
|
if w := mcpPost(s, mcpTestReadToken, ping); w.Code != http.StatusOK || decodeMCP(t, w).Error != nil {
|
|
t.Fatalf("valid token status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if len(store.touched) != 1 || store.touched[0] != 1 {
|
|
t.Fatalf("touched=%v, want only the valid token", store.touched)
|
|
}
|
|
// The token is accepted from the Authorization header only.
|
|
r := httptest.NewRequest(http.MethodPost, mcpEndpointPath+"?token="+mcpTestReadToken, strings.NewReader(ping))
|
|
r.AddCookie(&http.Cookie{Name: "artex_token", Value: mcpTestReadToken})
|
|
w := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("query/cookie token status=%d", w.Code)
|
|
}
|
|
}
|
|
|
|
// /mcp must stay reachable with an access token while the Basic Auth gate is
|
|
// on, and Basic credentials must not open it.
|
|
func TestMCPServerBypassesBasicAuthGate(t *testing.T) {
|
|
s, _ := mcpTestServer(t)
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("gate-password"), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.basicAuth.cfg = basicAuthConfig{Enabled: true, Username: "gate", PasswordHash: string(hash), Revision: "r1"}
|
|
|
|
health := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/api/health", nil))
|
|
if health.Code != http.StatusUnauthorized || health.Header().Get("X-Artex-Auth") != "basic" {
|
|
t.Fatalf("gate should still protect /api: status=%d", health.Code)
|
|
}
|
|
ping := `{"jsonrpc":"2.0","id":1,"method":"ping"}`
|
|
if w := mcpPost(s, mcpTestReadToken, ping); w.Code != http.StatusOK {
|
|
t.Fatalf("token behind gate status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
r := httptest.NewRequest(http.MethodPost, mcpEndpointPath, strings.NewReader(ping))
|
|
r.SetBasicAuth("gate", "gate-password")
|
|
w := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("basic credentials on /mcp status=%d, want 401", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerTransportRules(t *testing.T) {
|
|
s, _ := mcpTestServer(t)
|
|
ping := `{"jsonrpc":"2.0","id":1,"method":"ping"}`
|
|
if w := mcpPost(s, mcpTestReadToken, ping, "Origin", "https://evil.example"); w.Code != http.StatusForbidden {
|
|
t.Fatalf("cross-origin status=%d", w.Code)
|
|
}
|
|
if w := mcpPost(s, mcpTestReadToken, ping, "Origin", "http://example.com"); w.Code != http.StatusOK {
|
|
t.Fatalf("same-origin status=%d", w.Code)
|
|
}
|
|
if w := mcpPost(s, mcpTestReadToken, ping); w.Header().Get("Access-Control-Allow-Origin") != "" {
|
|
t.Fatal("/mcp must not send CORS headers")
|
|
}
|
|
for _, method := range []string{http.MethodGet, http.MethodDelete} {
|
|
r := httptest.NewRequest(method, mcpEndpointPath, nil)
|
|
r.Header.Set("Authorization", "Bearer "+mcpTestReadToken)
|
|
w := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
if w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != http.MethodPost {
|
|
t.Errorf("%s status=%d allow=%q", method, w.Code, w.Header().Get("Allow"))
|
|
}
|
|
}
|
|
if w := mcpPost(s, mcpTestReadToken, `[`+ping+`]`); w.Code != http.StatusBadRequest || decodeMCP(t, w).Error.Code != rpcInvalidRequest {
|
|
t.Fatalf("batch status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if w := mcpPost(s, mcpTestReadToken, `{not json`); w.Code != http.StatusBadRequest || decodeMCP(t, w).Error.Code != rpcParseError {
|
|
t.Fatalf("bad json status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if w := mcpPost(s, mcpTestReadToken, `{"jsonrpc":"2.0","method":"notifications/initialized"}`); w.Code != http.StatusAccepted || w.Body.Len() != 0 {
|
|
t.Fatalf("notification status=%d body=%q", w.Code, w.Body.String())
|
|
}
|
|
if out := decodeMCP(t, mcpPost(s, mcpTestReadToken, `{"jsonrpc":"2.0","id":"a","method":"resources/list"}`)); out.Error == nil || out.Error.Code != rpcMethodNotFound || string(out.ID) != `"a"` {
|
|
t.Fatalf("unknown method: %+v", out)
|
|
}
|
|
if w := mcpPost(s, mcpTestReadToken, `{"jsonrpc":"2.0","id":1,"method":"ping","params":"`+strings.Repeat("x", mcpMaxBodyBytes)+`"}`); w.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("oversized body status=%d", w.Code)
|
|
}
|
|
}
|
|
|
|
// mcpPostFrom sends a ping from a given TCP peer with optional X-Forwarded-For.
|
|
func mcpPostFrom(s *Server, peer string, forwarded ...string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(http.MethodPost, mcpEndpointPath, strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`))
|
|
r.RemoteAddr = peer
|
|
r.Header.Set("Authorization", "Bearer "+mcpTestReadToken)
|
|
for _, value := range forwarded {
|
|
r.Header.Add("X-Forwarded-For", value)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
func TestMCPServerSourceAllowlist(t *testing.T) {
|
|
s, store := mcpTestServer(t)
|
|
// No allowlist: any source.
|
|
if w := mcpPostFrom(s, "203.0.113.9:4000"); w.Code != http.StatusOK {
|
|
t.Fatalf("no allowlist status=%d", w.Code)
|
|
}
|
|
|
|
store.access = `{"allowed_ips":["198.51.100.7","10.1.0.0/16","2001:db8::/32"]}`
|
|
for peer, want := range map[string]int{
|
|
"198.51.100.7:1": http.StatusOK,
|
|
"10.1.200.3:1": http.StatusOK,
|
|
"[2001:db8::5]:1": http.StatusOK,
|
|
"[::ffff:198.51.100.7]:1": http.StatusOK, // IPv4-mapped peer matches the IPv4 entry
|
|
"198.51.100.8:1": http.StatusForbidden,
|
|
"10.2.0.1:1": http.StatusForbidden,
|
|
"[2001:db9::1]:1": http.StatusForbidden,
|
|
"not-an-address": http.StatusForbidden,
|
|
} {
|
|
if w := mcpPostFrom(s, peer); w.Code != want {
|
|
t.Errorf("peer %s status=%d want %d", peer, w.Code, want)
|
|
}
|
|
}
|
|
// The allowlist is checked before the token: a blocked source gets 403
|
|
// whether or not it holds a valid token, and the token is never touched.
|
|
before := len(store.touched)
|
|
r := httptest.NewRequest(http.MethodPost, mcpEndpointPath, strings.NewReader(`{}`))
|
|
r.RemoteAddr = "198.51.100.8:1"
|
|
w := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "198.51.100.8") {
|
|
t.Fatalf("blocked source without token status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
mcpPostFrom(s, "198.51.100.8:1")
|
|
if len(store.touched) != before {
|
|
t.Fatal("token was looked up for a blocked source")
|
|
}
|
|
|
|
// Without a trusted proxy, X-Forwarded-For is ignored: it cannot be used to
|
|
// claim an allowed address, and an allowed peer is not affected by it.
|
|
if w := mcpPostFrom(s, "203.0.113.9:1", "198.51.100.7"); w.Code != http.StatusForbidden {
|
|
t.Fatalf("spoofed X-Forwarded-For status=%d", w.Code)
|
|
}
|
|
if w := mcpPostFrom(s, "198.51.100.7:1", "203.0.113.9"); w.Code != http.StatusOK {
|
|
t.Fatalf("allowed peer with foreign X-Forwarded-For status=%d", w.Code)
|
|
}
|
|
|
|
// Behind a trusted proxy the rightmost untrusted hop is the client.
|
|
store.access = `{"allowed_ips":["198.51.100.7"],"trusted_proxies":["127.0.0.1","10.9.0.0/24"]}`
|
|
for name, tc := range map[string]struct {
|
|
peer string
|
|
forwarded []string
|
|
want int
|
|
}{
|
|
"client via proxy": {"127.0.0.1:1", []string{"198.51.100.7"}, http.StatusOK},
|
|
"other client via proxy": {"127.0.0.1:1", []string{"203.0.113.9"}, http.StatusForbidden},
|
|
"client forges leftmost entry": {"127.0.0.1:1", []string{"198.51.100.7, 203.0.113.9"}, http.StatusForbidden},
|
|
"two trusted proxies": {"127.0.0.1:1", []string{"203.0.113.1, 198.51.100.7, 10.9.0.4"}, http.StatusOK},
|
|
"header split across lines": {"127.0.0.1:1", []string{"198.51.100.7", "10.9.0.4"}, http.StatusOK},
|
|
"proxy sends no header": {"127.0.0.1:1", nil, http.StatusForbidden},
|
|
"malformed hop stops the chain": {"127.0.0.1:1", []string{"198.51.100.7, garbage, 10.9.0.4"}, http.StatusForbidden},
|
|
"untrusted peer ignores header": {"203.0.113.9:1", []string{"198.51.100.7"}, http.StatusForbidden},
|
|
"allowed client connects direct": {"198.51.100.7:1", nil, http.StatusOK},
|
|
} {
|
|
if w := mcpPostFrom(s, tc.peer, tc.forwarded...); w.Code != tc.want {
|
|
t.Errorf("%s: status=%d want %d", name, w.Code, tc.want)
|
|
}
|
|
}
|
|
|
|
// The audit records the resolved client, not the proxy.
|
|
call := httptest.NewRequest(http.MethodPost, mcpEndpointPath, strings.NewReader(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"spawn_task","arguments":{"goal":"x"}}}`))
|
|
call.RemoteAddr = "127.0.0.1:1"
|
|
call.Header.Set("Authorization", "Bearer "+mcpTestReadToken)
|
|
call.Header.Set("X-Forwarded-For", "198.51.100.7")
|
|
s.Handler().ServeHTTP(httptest.NewRecorder(), call)
|
|
if len(store.audit) != 1 || store.audit[0].RemoteAddr != "198.51.100.7" {
|
|
t.Fatalf("audit=%+v", store.audit)
|
|
}
|
|
|
|
// A corrupt configuration refuses requests instead of opening the endpoint.
|
|
store.access = `{"allowed_ips":["nonsense"]}`
|
|
if w := mcpPostFrom(s, "198.51.100.7:1"); w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("corrupt config status=%d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMCPAccessConfig(t *testing.T) {
|
|
cfg, _, err := normalizeMCPAccessConfig(mcpAccessConfig{
|
|
AllowedIPs: []string{" 10.0.0.5 ", "10.0.0.5/32", "192.168.1.77/24", "", "::ffff:1.2.3.4", "2001:DB8::1"},
|
|
TrustedProxies: []string{"127.0.0.1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := strings.Join(cfg.AllowedIPs, " "); got != "10.0.0.5 192.168.1.0/24 1.2.3.4 2001:db8::1" {
|
|
t.Fatalf("allowed=%q", got)
|
|
}
|
|
if len(cfg.TrustedProxies) != 1 || cfg.TrustedProxies[0] != "127.0.0.1" {
|
|
t.Fatalf("proxies=%v", cfg.TrustedProxies)
|
|
}
|
|
for _, bad := range []string{"example.com", "10.0.0.1/33", "10.0.0", "1.2.3.4:80", "fe80::1%eth0/64"} {
|
|
if _, _, err := normalizeMCPAccessConfig(mcpAccessConfig{AllowedIPs: []string{bad}}); err == nil {
|
|
t.Errorf("%q accepted", bad)
|
|
}
|
|
}
|
|
if _, _, err := normalizeMCPAccessConfig(mcpAccessConfig{TrustedProxies: []string{"bad"}}); err == nil || !strings.Contains(err.Error(), "可信代理") {
|
|
t.Fatalf("proxy error=%v", err)
|
|
}
|
|
tooMany := make([]string, 0, maxMCPAccessEntries+1)
|
|
for i := 0; i <= maxMCPAccessEntries; i++ {
|
|
tooMany = append(tooMany, "10.0."+strconv.Itoa(i/256)+"."+strconv.Itoa(i%256))
|
|
}
|
|
if _, _, err := normalizeMCPAccessConfig(mcpAccessConfig{AllowedIPs: tooMany}); err == nil {
|
|
t.Fatal("oversized list accepted")
|
|
}
|
|
}
|
|
|
|
func TestMCPServerInitializeNegotiatesVersion(t *testing.T) {
|
|
s, _ := mcpTestServer(t)
|
|
for requested, want := range map[string]string{
|
|
"2025-03-26": "2025-03-26", "2024-11-05": "2024-11-05", "1999-01-01": mcpProtocolVersions[0], "": mcpProtocolVersions[0],
|
|
} {
|
|
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"` + requested + `","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`
|
|
if got := decodeMCP(t, mcpPost(s, mcpTestReadToken, body)).Result.ProtocolVersion; got != want {
|
|
t.Errorf("requested %q: got %q want %q", requested, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMCPServerToolsFollowTokenScopes(t *testing.T) {
|
|
s, store := mcpTestServer(t)
|
|
list := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
|
|
names := func(token string) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, tool := range decodeMCP(t, mcpPost(s, token, list)).Result.Tools {
|
|
if tool.InputSchema["type"] != "object" {
|
|
t.Errorf("%s: inputSchema.type=%v", tool.Name, tool.InputSchema["type"])
|
|
}
|
|
out[tool.Name] = tool.Annotations.ReadOnlyHint
|
|
}
|
|
return out
|
|
}
|
|
read := names(mcpTestReadToken)
|
|
for _, name := range []string{"list_tasks", "get_task", "get_task_graph", "list_task_findings", "list_companies", "list_task_categories", "list_llm_profiles"} {
|
|
if readOnly, ok := read[name]; !ok || !readOnly {
|
|
t.Errorf("read token: %s listed=%v readOnly=%v", name, ok, readOnly)
|
|
}
|
|
}
|
|
for _, name := range []string{"spawn_task", "pause_task", "resume_task", "add_task_hint"} {
|
|
if _, ok := read[name]; ok {
|
|
t.Errorf("read token must not list %s", name)
|
|
}
|
|
}
|
|
full := names(mcpTestFullToken)
|
|
if len(full) != 11 || full["spawn_task"] || full["pause_task"] {
|
|
t.Fatalf("full token tools=%v", full)
|
|
}
|
|
// Nothing that edits tools, skills, MCP servers, files or settings is exposed.
|
|
for name := range full {
|
|
for _, banned := range []string{"custom", "skill", "mcp", "workspace", "_file", "setting", "report", "create_", "update_", "delete_"} {
|
|
if strings.Contains(name, banned) {
|
|
t.Errorf("tool %s must not be exposed", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A write tool called with a read-only token is refused and audited.
|
|
call := `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"spawn_task","arguments":{"goal":"https://example.com"}}}`
|
|
out := decodeMCP(t, mcpPost(s, mcpTestReadToken, call))
|
|
if out.Error != nil || !out.Result.IsError || !strings.Contains(out.Result.Content[0].Text, db.APIScopeTaskCreate) {
|
|
t.Fatalf("scope refusal: %+v", out)
|
|
}
|
|
if len(store.audit) != 1 || store.audit[0].OK || store.audit[0].Tool != "spawn_task" || store.audit[0].TokenID != 1 ||
|
|
!strings.Contains(store.audit[0].Args, "example.com") {
|
|
t.Fatalf("audit=%+v", store.audit)
|
|
}
|
|
unknown := `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"create_custom_tool","arguments":{}}}`
|
|
if out := decodeMCP(t, mcpPost(s, mcpTestFullToken, unknown)); out.Error == nil || out.Error.Code != rpcInvalidParams {
|
|
t.Fatalf("unknown tool: %+v", out)
|
|
}
|
|
if len(store.audit) != 1 {
|
|
t.Fatalf("unknown tool must not be audited as a call: %+v", store.audit)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeAPITokenInput(t *testing.T) {
|
|
past := time.Now().Add(-time.Hour)
|
|
for name, in := range map[string]db.APITokenInput{
|
|
"empty name": {Scopes: []string{db.APIScopeRead}},
|
|
"no scope": {Name: "x"},
|
|
"unknown scope": {Name: "x", Scopes: []string{"admin"}},
|
|
"bad company": {Name: "x", Scopes: []string{db.APIScopeRead}, CompanyIDs: []int64{0}},
|
|
"bad limit": {Name: "x", Scopes: []string{db.APIScopeRead}, MaxActiveTasks: -1},
|
|
"past expiry": {Name: "x", Scopes: []string{db.APIScopeRead}, ExpiresAt: &past},
|
|
} {
|
|
if _, err := db.NormalizeAPITokenInput(in); err == nil {
|
|
t.Errorf("%s: accepted", name)
|
|
}
|
|
}
|
|
got, err := db.NormalizeAPITokenInput(db.APITokenInput{
|
|
Name: " my bot ", Scopes: []string{db.APIScopeTaskCreate, db.APIScopeRead, db.APIScopeRead}, CompanyIDs: []int64{7, 3, 7},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Name != "my bot" || strings.Join(got.Scopes, ",") != "read,task:create" || len(got.CompanyIDs) != 2 || got.CompanyIDs[0] != 3 {
|
|
t.Fatalf("normalized=%+v", got)
|
|
}
|
|
}
|
|
|
|
// mcpCallTool calls one tool through the repository's own MCP client.
|
|
func mcpCallTool(t *testing.T, c *mcphttp.Client, name string, args map[string]any, out any) (string, error) {
|
|
t.Helper()
|
|
text, err := c.Call(context.Background(), name, args)
|
|
if err == nil && out != nil {
|
|
if uerr := json.Unmarshal([]byte(text), out); uerr != nil {
|
|
t.Fatalf("%s: decode %q: %v", name, text, uerr)
|
|
}
|
|
}
|
|
return text, err
|
|
}
|
|
|
|
// End to end against PostgreSQL: admin API issues tokens, then the in-repo MCP
|
|
// client drives the endpoint through creation, quota, company restriction,
|
|
// control and revocation.
|
|
func TestMCPServerPostgresEndToEnd(t *testing.T) {
|
|
m, err := NewManager(t.TempDir(), "")
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable: %v", err)
|
|
}
|
|
defer m.Close()
|
|
defer restoreConcurrencySetting(t, m)()
|
|
if err := m.SetConcurrency(false, defaultConcurrencyLimit); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, key := range []string{db.SettingMCPServerEnabled, db.SettingMCPServerAccess, basicAuthKey} {
|
|
old, exists, err := m.pg.GetSetting(key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func(key, old string, exists bool) {
|
|
if exists {
|
|
_ = m.pg.SetSetting(key, old)
|
|
} else {
|
|
_, _ = m.pg.Exec("DELETE FROM settings WHERE key=$1", key)
|
|
}
|
|
}(key, old, exists)
|
|
}
|
|
if err := m.pg.SetSetting(basicAuthKey, `{"enabled":false}`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Start without a source allowlist whatever the database held before.
|
|
if err := m.pg.SetSetting(db.SettingMCPServerAccess, `{}`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := newAdmissionTestServer(m, nil)
|
|
s.jwtKey = []byte("test-only-signing-key-32-characters")
|
|
m.SetTaskControlMutex(&s.concMu)
|
|
if err := s.basicAuth.load(m.pg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
adminJWT, err := signJWT(s.jwtKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := httptest.NewServer(s.Handler())
|
|
defer srv.Close()
|
|
|
|
admin := func(method, path, body string, out any) int {
|
|
t.Helper()
|
|
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+adminJWT)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if out != nil {
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
t.Fatalf("%s %s: decode: %v", method, path, err)
|
|
}
|
|
}
|
|
return resp.StatusCode
|
|
}
|
|
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
|
|
// Company scope attributes by registrable domain, so each company owns its own.
|
|
inRoot, outRoot := "mcp-in-"+suffix+".com", "mcp-out-"+suffix+".org"
|
|
inScope, outOfScope := "app."+inRoot, "app."+outRoot
|
|
allowedID, _, _, _, _, err := m.pg.Companies().CreateCompanyWithScope("mcp-allowed-"+suffix, "", []db.ScopeInput{{Value: inRoot}}, "test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
otherID, _, _, _, _, err := m.pg.Companies().CreateCompanyWithScope("mcp-other-"+suffix, "", []db.ScopeInput{{Value: outRoot}}, "test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var created []string
|
|
var tokenIDs []int64
|
|
defer func() {
|
|
for _, id := range created {
|
|
_, _ = m.DeleteTask(id, DeleteTaskOptions{})
|
|
}
|
|
for _, id := range tokenIDs {
|
|
_ = m.pg.DeleteAPIToken(id)
|
|
_, _ = m.pg.Exec(`DELETE FROM mcp_audit_log WHERE token_id=$1`, id)
|
|
}
|
|
_ = m.pg.Companies().DeleteCompany(allowedID)
|
|
_ = m.pg.Companies().DeleteCompany(otherID)
|
|
}()
|
|
newToken := func(body string) (string, int64) {
|
|
t.Helper()
|
|
var out struct {
|
|
Token string `json:"token"`
|
|
Item db.APIToken `json:"item"`
|
|
}
|
|
if code := admin(http.MethodPost, "/api/mcp-server/tokens", body, &out); code != http.StatusCreated {
|
|
t.Fatalf("create token status=%d", code)
|
|
}
|
|
tokenIDs = append(tokenIDs, out.Item.ID)
|
|
return out.Token, out.Item.ID
|
|
}
|
|
connect := func(token string) (*mcphttp.Client, error) {
|
|
return mcphttp.New(context.Background(), "artex", srv.URL+mcpEndpointPath, map[string]string{"Authorization": "Bearer " + token}, false)
|
|
}
|
|
|
|
// Disabled until an administrator turns it on.
|
|
if code := admin(http.MethodPut, "/api/mcp-server/settings", `{"enabled":false}`, nil); code != http.StatusOK {
|
|
t.Fatalf("disable status=%d", code)
|
|
}
|
|
fullToken, fullID := newToken(`{"name":"full","scopes":["read","task:create","task:control"],"max_tasks_per_hour":2}`)
|
|
if !strings.HasPrefix(fullToken, db.APITokenPrefix) {
|
|
t.Fatalf("token=%q", fullToken)
|
|
}
|
|
if _, err := connect(fullToken); err == nil {
|
|
t.Fatal("connected while the MCP server is disabled")
|
|
}
|
|
if code := admin(http.MethodPut, "/api/mcp-server/settings", `{"enabled":true}`, nil); code != http.StatusOK {
|
|
t.Fatalf("enable status=%d", code)
|
|
}
|
|
// The plaintext is never stored or listed again.
|
|
var listed struct {
|
|
Tokens []map[string]any `json:"tokens"`
|
|
}
|
|
admin(http.MethodGet, "/api/mcp-server/tokens", "", &listed)
|
|
raw, _ := json.Marshal(listed)
|
|
if strings.Contains(string(raw), fullToken) || strings.Contains(string(raw), db.HashAPIToken(fullToken)) {
|
|
t.Fatal("token list leaks the token or its hash")
|
|
}
|
|
if code := admin(http.MethodPost, "/api/mcp-server/tokens", `{"name":"bad","scopes":["read"],"company_ids":[999999999]}`, nil); code != http.StatusBadRequest {
|
|
t.Fatalf("unknown company status=%d", code)
|
|
}
|
|
|
|
full, err := connect(fullToken)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer full.Close()
|
|
tools, err := full.Tools(context.Background())
|
|
if err != nil || len(tools) != 11 {
|
|
t.Fatalf("tools=%d err=%v", len(tools), err)
|
|
}
|
|
|
|
type spawned struct {
|
|
TaskID string `json:"task_id"`
|
|
Status string `json:"status"`
|
|
}
|
|
spawn := func(c *mcphttp.Client, args map[string]any) (spawned, error) {
|
|
var out spawned
|
|
_, err := mcpCallTool(t, c, "spawn_task", args, &out)
|
|
if err == nil {
|
|
created = append(created, out.TaskID)
|
|
}
|
|
return out, err
|
|
}
|
|
parent, err := spawn(full, map[string]any{"description": "mcp parent " + suffix, "goal": "测试 https://" + inScope, "company_ids": []int64{allowedID}})
|
|
if err != nil || parent.TaskID == "" {
|
|
t.Fatalf("spawn parent: %+v err=%v", parent, err)
|
|
}
|
|
child, err := spawn(full, map[string]any{"description": "mcp child " + suffix, "goal": "测试 https://" + outOfScope, "parent_ref": parent.TaskID})
|
|
if err != nil {
|
|
t.Fatalf("spawn child: %v", err)
|
|
}
|
|
if _, err := spawn(full, map[string]any{"goal": "third task"}); err == nil || !strings.Contains(err.Error(), "每小时") {
|
|
t.Fatalf("hourly quota not enforced: %v", err)
|
|
}
|
|
if _, err := spawn(full, map[string]any{"goal": "x", "company_ids": []int64{999999999}}); err == nil {
|
|
t.Fatal("unknown company accepted")
|
|
}
|
|
|
|
var task map[string]any
|
|
if _, err := mcpCallTool(t, full, "get_task", map[string]any{"task_id": child.TaskID}, &task); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if task["parent_ref"] != parent.TaskID || task["id"] != child.TaskID {
|
|
t.Fatalf("get_task=%v", task)
|
|
}
|
|
stored, ok := m.Task(parent.TaskID)
|
|
if !ok || !strings.Contains(stored.lifecycleSnapshot().Remark, "full") {
|
|
t.Fatalf("task remark does not name the token: %+v", stored)
|
|
}
|
|
|
|
var tasks struct {
|
|
Tasks []map[string]any `json:"tasks"`
|
|
Total int `json:"total"`
|
|
}
|
|
if _, err := mcpCallTool(t, full, "list_tasks", map[string]any{"company_id": allowedID}, &tasks); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tasks.Total != 1 || tasks.Tasks[0]["id"] != parent.TaskID {
|
|
t.Fatalf("list_tasks by company=%+v", tasks)
|
|
}
|
|
|
|
// pause → paused, resume → running again, hint accepted.
|
|
var control spawned
|
|
if _, err := mcpCallTool(t, full, "pause_task", map[string]any{"task_id": parent.TaskID}, &control); err != nil || control.Status != "paused" {
|
|
t.Fatalf("pause: %+v err=%v", control, err)
|
|
}
|
|
if _, err := mcpCallTool(t, full, "get_task", map[string]any{"task_id": parent.TaskID}, &task); err != nil || task["status"] != "paused" {
|
|
t.Fatalf("status after pause=%v err=%v", task["status"], err)
|
|
}
|
|
if _, err := mcpCallTool(t, full, "resume_task", map[string]any{"task_id": parent.TaskID}, &control); err != nil || control.Status == "paused" {
|
|
t.Fatalf("resume: %+v err=%v", control, err)
|
|
}
|
|
if _, err := mcpCallTool(t, full, "add_task_hint", map[string]any{"task_id": parent.TaskID, "text": "重点看登录接口"}, nil); err != nil {
|
|
t.Fatalf("add_task_hint: %v", err)
|
|
}
|
|
if _, err := mcpCallTool(t, full, "add_task_hint", map[string]any{"task_id": parent.TaskID}, nil); err == nil {
|
|
t.Fatal("empty hint accepted")
|
|
}
|
|
for _, name := range []string{"get_task_graph", "list_task_findings"} {
|
|
if _, err := mcpCallTool(t, full, name, map[string]any{"task_id": parent.TaskID}, nil); err != nil {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
for _, name := range []string{"list_companies", "list_task_categories", "list_llm_profiles"} {
|
|
if _, err := mcpCallTool(t, full, name, map[string]any{}, nil); err != nil {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// A company-restricted token sees and controls only that company's tasks.
|
|
scopedToken, _ := newToken(`{"name":"scoped","scopes":["read","task:create","task:control"],"company_ids":[` + strconv.FormatInt(allowedID, 10) + `],"max_active_tasks":1}`)
|
|
scoped, err := connect(scopedToken)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer scoped.Close()
|
|
if _, err := mcpCallTool(t, scoped, "list_tasks", map[string]any{}, &tasks); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, row := range tasks.Tasks {
|
|
if row["id"] == child.TaskID {
|
|
t.Fatal("restricted token lists a task outside its companies")
|
|
}
|
|
}
|
|
for _, name := range []string{"get_task", "pause_task", "list_task_findings"} {
|
|
if _, err := mcpCallTool(t, scoped, name, map[string]any{"task_id": child.TaskID}, nil); err == nil || !strings.Contains(err.Error(), "不存在") {
|
|
t.Errorf("%s on hidden task: %v", name, err)
|
|
}
|
|
}
|
|
var companies struct {
|
|
Companies []map[string]any `json:"companies"`
|
|
}
|
|
if _, err := mcpCallTool(t, scoped, "list_companies", map[string]any{}, &companies); err != nil || len(companies.Companies) != 1 {
|
|
t.Fatalf("restricted list_companies=%+v err=%v", companies, err)
|
|
}
|
|
for name, args := range map[string]map[string]any{
|
|
"no company": {"goal": "测试 https://" + inScope},
|
|
"other company": {"goal": "测试 https://" + inScope, "company_ids": []int64{otherID}},
|
|
"target elsewhere": {"goal": "测试 https://" + outOfScope, "company_ids": []int64{allowedID}},
|
|
"hidden parent": {"goal": "测试 https://" + inScope, "company_ids": []int64{allowedID}, "parent_ref": child.TaskID},
|
|
} {
|
|
if _, err := spawn(scoped, args); err == nil {
|
|
t.Errorf("restricted token: %s accepted", name)
|
|
}
|
|
}
|
|
if _, err := spawn(scoped, map[string]any{"goal": "测试 https://" + inScope, "company_ids": []int64{allowedID}}); err != nil {
|
|
t.Fatalf("restricted spawn in scope: %v", err)
|
|
}
|
|
if _, err := spawn(scoped, map[string]any{"goal": "测试 https://" + inScope, "company_ids": []int64{allowedID}}); err == nil || !strings.Contains(err.Error(), "尚未结束") {
|
|
t.Fatalf("active-task quota not enforced: %v", err)
|
|
}
|
|
|
|
// Source allowlist through the admin API: the test client is on loopback.
|
|
var settings struct {
|
|
AllowedIPs []string `json:"allowed_ips"`
|
|
ClientIP string `json:"client_ip"`
|
|
}
|
|
if code := admin(http.MethodPut, "/api/mcp-server/settings", `{"allowed_ips":["not-an-ip"]}`, nil); code != http.StatusBadRequest {
|
|
t.Fatalf("invalid allowlist status=%d", code)
|
|
}
|
|
if code := admin(http.MethodPut, "/api/mcp-server/settings", `{"allowed_ips":["203.0.113.0/24"]}`, &settings); code != http.StatusOK ||
|
|
len(settings.AllowedIPs) != 1 || settings.ClientIP != "127.0.0.1" {
|
|
t.Fatalf("set allowlist status=%d settings=%+v", code, settings)
|
|
}
|
|
if _, err := mcpCallTool(t, full, "list_tasks", map[string]any{}, nil); err == nil || !strings.Contains(err.Error(), "403") {
|
|
t.Fatalf("source outside the allowlist still works: %v", err)
|
|
}
|
|
if code := admin(http.MethodPut, "/api/mcp-server/settings", `{"allowed_ips":["203.0.113.0/24","127.0.0.1"]}`, nil); code != http.StatusOK {
|
|
t.Fatalf("extend allowlist status=%d", code)
|
|
}
|
|
if _, err := mcpCallTool(t, full, "list_tasks", map[string]any{}, nil); err != nil {
|
|
t.Fatalf("allowed source rejected: %v", err)
|
|
}
|
|
if code := admin(http.MethodPut, "/api/mcp-server/settings", `{"allowed_ips":[]}`, &settings); code != http.StatusOK || len(settings.AllowedIPs) != 0 {
|
|
t.Fatalf("clear allowlist status=%d settings=%+v", code, settings)
|
|
}
|
|
|
|
// Every call above was audited, refusals included.
|
|
var audit struct {
|
|
Entries []db.MCPAuditEntry `json:"entries"`
|
|
}
|
|
admin(http.MethodGet, "/api/mcp-server/audit?token_id="+strconv.FormatInt(fullID, 10)+"&limit=500", "", &audit)
|
|
seen, refused := map[string]bool{}, 0
|
|
for _, entry := range audit.Entries {
|
|
seen[entry.Tool] = true
|
|
if !entry.OK {
|
|
refused++
|
|
}
|
|
}
|
|
if !seen["spawn_task"] || !seen["pause_task"] || !seen["get_task"] || refused < 3 {
|
|
t.Fatalf("audit tools=%v refused=%d", seen, refused)
|
|
}
|
|
|
|
// Revocation takes effect on the very next request.
|
|
if code := admin(http.MethodPost, "/api/mcp-server/tokens/"+strconv.FormatInt(fullID, 10)+"/revoke", "", nil); code != http.StatusOK {
|
|
t.Fatalf("revoke status=%d", code)
|
|
}
|
|
if _, err := mcpCallTool(t, full, "list_tasks", map[string]any{}, nil); err == nil || !strings.Contains(err.Error(), "401") {
|
|
t.Fatalf("revoked token still works: %v", err)
|
|
}
|
|
if code := admin(http.MethodDelete, "/api/mcp-server/tokens/"+strconv.FormatInt(fullID, 10), "", nil); code != http.StatusOK {
|
|
t.Fatalf("delete status=%d", code)
|
|
}
|
|
if code := admin(http.MethodDelete, "/api/mcp-server/tokens/"+strconv.FormatInt(fullID, 10), "", nil); code != http.StatusNotFound {
|
|
t.Fatalf("second delete status=%d", code)
|
|
}
|
|
}
|