Skip to content

Commit 4943433

Browse files
authored
feat(telegram): add /mcp list, add, remove commands (#35)
* feat(telegram): add /mcp list command and dispatch * feat(telegram): add /mcp add with flag parsing * feat(telegram): add /mcp remove with MCP config re-injection * feat(telegram): register /mcp command and add MCP help section * chore(telegram): verify /mcp acceptance criteria * docs: mark MCP upstream commands plan complete * fix(telegram): address review findings Squashed review fixes across multiple iterations: - MCP upstream commands review findings - Iter 2 review findings - Iter 3 review findings - Smells review style fixes - Codex external review findings * fix(telegram): address golangci-lint issues - rename close shadows to closes in approval_test.go - lowercase error string in --header callback per ST1005 - remove unused sortedKVLine helper
1 parent db9bf09 commit 4943433

16 files changed

Lines changed: 2070 additions & 223 deletions

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ sluice policy remove <id>
8888
sluice policy import <path.toml> # seed DB from TOML (merge semantics)
8989
sluice policy export # dump current rules as TOML
9090
91-
sluice mcp add <name> --command <cmd> [--transport stdio|http|websocket] [--args "a,b"] [--env "K=V"] [--timeout 120]
91+
sluice mcp add <name> --command <cmd> [--transport stdio|http|websocket] [--args "a,b"] [--env "K=V"] [--header "K=V"] [--timeout 120]
9292
sluice mcp list
9393
sluice mcp remove <name>
9494
sluice mcp # start MCP gateway
@@ -128,7 +128,9 @@ docker exec openclaw openclaw mcp set sluice '{"url":"http://sluice:3000/mcp"}'
128128

129129
For the hostname `sluice` to resolve inside OpenClaw, the compose file pins sluice's IP on the internal network (172.30.0.2) and adds an `extra_hosts` entry on tun2proxy (which OpenClaw shares). Docker's embedded DNS (127.0.0.11) is not reachable from OpenClaw because its DNS is routed through the TUN device. The `/etc/hosts` entry bypasses DNS entirely.
130130

131-
When new MCP upstreams are added to sluice via `sluice mcp add`, restart sluice so the gateway picks them up. OpenClaw does not need to be restarted - its connection to sluice:3000/mcp remains valid and it re-queries the tool list on subsequent agent runs.
131+
MCP upstreams can be managed via `sluice mcp add|list|remove`, the REST API (`/api/mcp/upstreams`), or the Telegram bot (`/mcp add|list|remove`). All three paths write to the same SQLite store. After any addition or removal, restart sluice so the gateway re-reads the upstream set. OpenClaw does not need to be restarted: its connection to `sluice:3000/mcp` is registered once at sluice startup (via `WireMCPGateway`, which patches `mcp.servers.sluice = {url: ...}` in the agent's openclaw.json) and stays valid across sluice restarts. The agent re-queries the tool list on subsequent agent runs.
132+
133+
The Telegram `/mcp add` path auto-deletes the chat message because `--env KEY=VAL` pairs may contain secrets (use `KEY=vault:name` to keep the plaintext out of the SQLite store and `/mcp list` output entirely).
132134

133135
## Policy Store
134136

@@ -260,7 +262,7 @@ Action names operators commonly grep for: `tool_call` (MCP tool call policy verd
260262

261263
### MCP gateway
262264

263-
Three upstream transports: stdio (child processes), Streamable HTTP, WebSocket. All satisfy `MCPUpstream` interface. Tools namespaced as `<upstream>__<tool>`. Policy evaluation: deny/allow/ask priority. `ContentInspector` blocks arguments and redacts responses using regex (JSON parsed before matching to prevent unicode escape bypass). Per-upstream timeouts (default 120s).
265+
Three upstream transports: stdio (child processes), Streamable HTTP, WebSocket. All satisfy `MCPUpstream` interface. Tools namespaced as `<upstream>__<tool>`. Policy evaluation: deny/allow/ask priority. `ContentInspector` blocks arguments and redacts responses using regex (JSON parsed before matching to prevent unicode escape bypass). Per-upstream timeout defaults are defined by the `mcp.DefaultTimeoutSec` constant (120s) shared across packages that need the fallback. `internal/store.AddMCPUpstream` duplicates the literal 120 because `internal/store` is imported by `internal/mcp` and cannot import it back (circular). A comment in `store.go` flags the duplicate so the two stay in sync.
264266

265267
`MCPHTTPHandler` serves `POST /mcp` and `DELETE /mcp` on port 3000 (alongside `/healthz`). Session tracking via `Mcp-Session-Id` header. SSE response support.
266268

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,14 @@ Manage sluice from your phone. Approve connections and tool calls, add credentia
285285
| `/policy deny <dest>` | Add deny rule |
286286
| `/cred add <name> [--env-var VAR]` | Add credential (value sent as next message, auto-deleted) |
287287
| `/cred rotate <name>` | Replace credential, hot-reload OpenClaw |
288+
| `/mcp list` | List registered MCP upstreams |
289+
| `/mcp add <name> --command <cmd> [flags]` | Register a new MCP upstream (stdio/http/websocket, see `/help`; chat message auto-deleted because `--env` may carry secrets) |
290+
| `/mcp remove <name>` | Remove an MCP upstream |
288291
| `/status` | Proxy stats and pending approvals |
289292
| `/audit recent [N]` | Last N audit entries |
290293

294+
`/mcp add` only writes to the SQLite store. The MCP gateway builds its upstream set at startup, so restart sluice (`docker compose restart sluice`) for a new or removed upstream to take effect. The agent's connection to `sluice:3000/mcp` survives the restart.
295+
291296
### HTTP Webhooks
292297

293298
REST API on port 3000 for programmatic approval integration. `GET /api/approvals` lists pending requests, `POST /api/approvals/{id}/resolve` resolves them. Use this to build custom approval UIs or integrate with existing workflows.

cmd/sluice/flagutil.go

Lines changed: 6 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"fmt"
66
"strconv"
77
"strings"
8+
9+
"github.com/nemirovsky/sluice/internal/flagutil"
810
)
911

1012
// parsePortsList parses a comma-separated string of port numbers into a
@@ -55,69 +57,9 @@ func parseProtocolsList(s string) ([]string, error) {
5557
return protocols, nil
5658
}
5759

58-
// reorderFlagsBeforePositional returns a copy of args with all flag
59-
// arguments moved before any positional arguments, so that Go's stdlib
60-
// flag parser (which stops at the first non-flag) still sees every flag.
61-
//
62-
// The FlagSet is consulted to determine which flags take a value and
63-
// therefore consume the following arg. "--flag=value" form is left as
64-
// a single token. "--" is treated as a terminator; everything after it
65-
// is positional, preserving the stdlib convention.
66-
//
67-
// Example: ["github", "--command", "https://x", "--timeout", "60"]
68-
// becomes ["--command", "https://x", "--timeout", "60", "github"]
69-
//
70-
// Flags defined as bool do not consume the following arg. Everything
71-
// else (string, int, Func, Var) is assumed to.
60+
// reorderFlagsBeforePositional is a thin alias over flagutil.ReorderFlagsBeforePositional
61+
// so existing cmd/sluice callers (and their many tests) keep the old signature
62+
// without churn. New code should import internal/flagutil directly.
7263
func reorderFlagsBeforePositional(args []string, fs *flag.FlagSet) []string {
73-
var flagArgs, positional []string
74-
i := 0
75-
for i < len(args) {
76-
a := args[i]
77-
if a == "--" {
78-
// Terminator: everything after is positional, flag parsing
79-
// should still see "--" to stop.
80-
flagArgs = append(flagArgs, a)
81-
positional = append(positional, args[i+1:]...)
82-
break
83-
}
84-
if !strings.HasPrefix(a, "-") || a == "-" {
85-
positional = append(positional, a)
86-
i++
87-
continue
88-
}
89-
flagArgs = append(flagArgs, a)
90-
// --flag=value form: value is in the same arg.
91-
if strings.Contains(a, "=") {
92-
i++
93-
continue
94-
}
95-
// Otherwise the next arg is the value for non-bool flags.
96-
name := strings.TrimLeft(a, "-")
97-
if isValueFlag(fs, name) && i+1 < len(args) {
98-
flagArgs = append(flagArgs, args[i+1])
99-
i += 2
100-
continue
101-
}
102-
i++
103-
}
104-
return append(flagArgs, positional...)
105-
}
106-
107-
// isValueFlag reports whether the named flag consumes the next argument
108-
// as its value. Bool flags do not; everything else does.
109-
func isValueFlag(fs *flag.FlagSet, name string) bool {
110-
f := fs.Lookup(name)
111-
if f == nil {
112-
// Unknown flag. Assume it takes a value so we don't accidentally
113-
// slurp something that might be a positional arg. fs.Parse will
114-
// then surface the real error.
115-
return true
116-
}
117-
// The stdlib flag package exposes bool flags via an IsBoolFlag method
118-
// on the Value. Non-bool flags don't implement this.
119-
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
120-
return false
121-
}
122-
return true
64+
return flagutil.ReorderFlagsBeforePositional(args, fs)
12365
}

cmd/sluice/mcp.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,12 +261,12 @@ func handleMCPAdd(args []string) error {
261261
command := fs.String("command", "", "command to run (stdio) or URL (http/websocket)")
262262
argsStr := fs.String("args", "", "comma-separated arguments for the command")
263263
envStr := fs.String("env", "", "comma-separated KEY=VAL environment variables (VAL may be vault:<name> for the whole value, or contain {vault:<name>} substrings for templated substitution)")
264-
timeout := fs.Int("timeout", 120, "upstream timeout in seconds")
264+
timeout := fs.Int("timeout", mcp.DefaultTimeoutSec, "upstream timeout in seconds")
265265
transport := fs.String("transport", "stdio", "transport type: stdio, http, or websocket")
266266
headers := make(map[string]string)
267267
fs.Func("header", "HTTP header to send on every request to an http upstream (repeatable, format: KEY=VAL; VAL may be vault:<name> for the whole value, or contain {vault:<name>} substrings for templated substitution, e.g. \"Authorization=Bearer {vault:github_pat}\")", func(s string) error {
268268
parts := strings.SplitN(s, "=", 2)
269-
if len(parts) != 2 {
269+
if len(parts) != 2 || parts[0] == "" {
270270
return fmt.Errorf("invalid header format %q (expected KEY=VAL)", s)
271271
}
272272
headers[parts[0]] = parts[1]
@@ -291,6 +291,10 @@ func handleMCPAdd(args []string) error {
291291
return fmt.Errorf("invalid transport %q: must be stdio, http, or websocket", *transport)
292292
}
293293

294+
if *timeout <= 0 {
295+
return fmt.Errorf("invalid --timeout %d: must be a positive integer (seconds)", *timeout)
296+
}
297+
294298
if len(headers) > 0 && *transport != "http" {
295299
return fmt.Errorf("--header is only valid for --transport http")
296300
}
@@ -304,7 +308,7 @@ func handleMCPAdd(args []string) error {
304308
if *envStr != "" {
305309
for _, kv := range strings.Split(*envStr, ",") {
306310
parts := strings.SplitN(kv, "=", 2)
307-
if len(parts) != 2 {
311+
if len(parts) != 2 || parts[0] == "" {
308312
return fmt.Errorf("invalid env format %q (expected KEY=VAL)", kv)
309313
}
310314
env[parts[0]] = parts[1]
@@ -391,7 +395,7 @@ func handleMCPList(args []string) error {
391395
headersStr = " headers=" + strings.Join(pairs, ",")
392396
}
393397
timeoutStr := ""
394-
if u.TimeoutSec != 120 {
398+
if u.TimeoutSec != mcp.DefaultTimeoutSec {
395399
timeoutStr = fmt.Sprintf(" timeout=%ds", u.TimeoutSec)
396400
}
397401
fmt.Printf("[%d] %s command=%s%s%s%s%s%s\n", u.ID, u.Name, u.Command, transportStr, argsStr, envStr, headersStr, timeoutStr)

cmd/sluice/mcp_test.go

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"os/exec"
88
"path/filepath"
9+
"strings"
910
"testing"
1011

1112
"github.com/nemirovsky/sluice/internal/mcp"
@@ -374,6 +375,56 @@ func TestHandleMCPAddInvalidName(t *testing.T) {
374375
}
375376
}
376377

378+
// TestHandleMCPAddInvalidTimeout verifies that --timeout <= 0 is rejected
379+
// rather than being silently persisted. Previously the CLI accepted any
380+
// integer (including 0 and negatives), but the runtime constructors fall
381+
// back to DefaultTimeoutSec when TimeoutSec <= 0, so the persisted store
382+
// value diverged from the actual runtime behavior. Telegram /mcp add has
383+
// always rejected these values, this test locks in symmetry.
384+
func TestHandleMCPAddInvalidTimeout(t *testing.T) {
385+
cases := []struct {
386+
name string
387+
timeout string
388+
}{
389+
{"zero", "0"},
390+
{"negative", "-5"},
391+
}
392+
393+
for _, tc := range cases {
394+
t.Run(tc.name, func(t *testing.T) {
395+
dir := t.TempDir()
396+
dbPath := filepath.Join(dir, "test.db")
397+
398+
err := handleMCPAdd([]string{
399+
"--db", dbPath,
400+
"--command", "server",
401+
"--timeout", tc.timeout,
402+
"ups",
403+
})
404+
if err == nil {
405+
t.Fatalf("expected error for --timeout %s", tc.timeout)
406+
}
407+
if !strings.Contains(err.Error(), "must be a positive integer") {
408+
t.Errorf("error %q should mention 'must be a positive integer'", err)
409+
}
410+
411+
// Nothing should be persisted when validation fails.
412+
db, err := store.New(dbPath)
413+
if err != nil {
414+
t.Fatal(err)
415+
}
416+
defer func() { _ = db.Close() }()
417+
upstreams, err := db.ListMCPUpstreams()
418+
if err != nil {
419+
t.Fatal(err)
420+
}
421+
if len(upstreams) != 0 {
422+
t.Errorf("expected 0 upstreams after invalid --timeout, got %d", len(upstreams))
423+
}
424+
})
425+
}
426+
}
427+
377428
// TestHandleMCPRemove verifies removing an upstream by name.
378429
func TestHandleMCPRemove(t *testing.T) {
379430
dir := t.TempDir()
@@ -648,18 +699,41 @@ func TestHandleMCPGatewayInvalidChatID(t *testing.T) {
648699
}
649700

650701
// TestHandleMCPAddEnvInvalidFormat verifies that bad env format is rejected.
702+
// Covers both missing "=" (BADFORMAT) and empty-key (=VALUE) cases. The CLI
703+
// rejects empty keys to match the Telegram handler's behavior so both entry
704+
// points reject the same malformed inputs.
651705
func TestHandleMCPAddEnvInvalidFormat(t *testing.T) {
652706
dir := t.TempDir()
653-
dbPath := filepath.Join(dir, "test.db")
654707

655-
err := handleMCPAdd([]string{
656-
"--db", dbPath,
657-
"--command", "server",
658-
"--env", "BADFORMAT",
659-
"myserver",
660-
})
661-
if err == nil {
662-
t.Fatal("expected error for invalid env format")
708+
cases := []struct {
709+
label string
710+
flag string
711+
value string
712+
}{
713+
{"env missing equals", "--env", "BADFORMAT"},
714+
{"env empty key", "--env", "=VALUE"},
715+
{"header missing equals", "--header", "BADFORMAT"},
716+
{"header empty key", "--header", "=VALUE"},
717+
}
718+
719+
for _, tc := range cases {
720+
t.Run(tc.label, func(t *testing.T) {
721+
dbPath := filepath.Join(dir, tc.label+".db")
722+
args := []string{
723+
"--db", dbPath,
724+
"--command", "https://example.com/mcp",
725+
"--transport", "http",
726+
tc.flag, tc.value,
727+
"myserver",
728+
}
729+
// The "http" transport is set so that --header is accepted for
730+
// the header cases. The env cases do not require it, but sharing
731+
// one arg set keeps the table loop uniform.
732+
err := handleMCPAdd(args)
733+
if err == nil {
734+
t.Fatalf("expected error for %s %q", tc.flag, tc.value)
735+
}
736+
})
663737
}
664738
}
665739

0 commit comments

Comments
 (0)