You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/05 03:52:35 UTC
(casbin-gateway) branch master updated (7f49d66 -> b7a13cb)
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
from 7f49d66 docs: move the install command and screenshots above the agent table
new 9c8aa17 feat(connections): connect an app once and write it into every agent
new b7a13cb feat(connections): keep a connection's credential out of the agent's config
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
agentconfig/layout.go | 7 +
agentpatch/endpoint.go | 12 ++
connector/catalog.go | 169 +++++++++++++++
connector/connector.go | 127 +++++++++++
connector/connectors/amap.json | 34 +++
connector/connectors/chrome-devtools.json | 20 ++
connector/connectors/context7.json | 32 +++
connector/connectors/feishu.json | 42 ++++
connector/connectors/filesystem.json | 30 +++
connector/connectors/github.json | 30 +++
connector/connectors/notion.json | 34 +++
connector/connectors/playwright.json | 20 ++
connector/render.go | 133 ++++++++++++
controllers/connector.go | 260 ++++++++++++++++++++++
main.go | 2 +
mcpproxy/gateway.go | 165 ++++++++++++++
mcpproxy/http.go | 137 ++++++++++++
mcpproxy/mcpproxy.go | 194 +++++++++++++++++
mcpproxy/upstream.go | 115 ++++++++++
object/connection.go | 198 +++++++++++++++++
object/connection_install.go | 206 ++++++++++++++++++
object/ormer.go | 8 +
routers/router.go | 6 +
web/src/App.tsx | 5 +
web/src/backend/ConnectorBackend.ts | 42 ++++
web/src/locales/en/data.json | 52 ++++-
web/src/locales/zh/data.json | 52 ++++-
web/src/nav.ts | 4 +
web/src/pages/ConnectionsPage.tsx | 344 ++++++++++++++++++++++++++++++
web/src/types.ts | 71 ++++++
30 files changed, 2549 insertions(+), 2 deletions(-)
create mode 100644 connector/catalog.go
create mode 100644 connector/connector.go
create mode 100644 connector/connectors/amap.json
create mode 100644 connector/connectors/chrome-devtools.json
create mode 100644 connector/connectors/context7.json
create mode 100644 connector/connectors/feishu.json
create mode 100644 connector/connectors/filesystem.json
create mode 100644 connector/connectors/github.json
create mode 100644 connector/connectors/notion.json
create mode 100644 connector/connectors/playwright.json
create mode 100644 connector/render.go
create mode 100644 controllers/connector.go
create mode 100644 mcpproxy/gateway.go
create mode 100644 mcpproxy/http.go
create mode 100644 mcpproxy/mcpproxy.go
create mode 100644 mcpproxy/upstream.go
create mode 100644 object/connection.go
create mode 100644 object/connection_install.go
create mode 100644 web/src/backend/ConnectorBackend.ts
create mode 100644 web/src/pages/ConnectionsPage.tsx
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 02/02: feat(connections): keep a connection's credential out of the agent's config
Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit b7a13cb7ccc8b915543999a52831e288bc3625ef
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Sep 5 11:52:00 2026 +0800
feat(connections): keep a connection's credential out of the agent's config
---
agentpatch/endpoint.go | 12 +++
controllers/connector.go | 55 ++++++++++++
main.go | 2 +
mcpproxy/gateway.go | 165 ++++++++++++++++++++++++++++++++++++
mcpproxy/http.go | 137 ++++++++++++++++++++++++++++++
mcpproxy/mcpproxy.go | 194 +++++++++++++++++++++++++++++++++++++++++++
mcpproxy/upstream.go | 115 +++++++++++++++++++++++++
object/connection_install.go | 105 +++++++++++++++++++----
routers/router.go | 1 +
9 files changed, 769 insertions(+), 17 deletions(-)
diff --git a/agentpatch/endpoint.go b/agentpatch/endpoint.go
index ce9ffe9..4f84841 100644
--- a/agentpatch/endpoint.go
+++ b/agentpatch/endpoint.go
@@ -50,3 +50,15 @@ func loopbackURL(path string) (string, error) {
}
return fmt.Sprintf("http://127.0.0.1:%d%s", number, path), nil
}
+
+// GatewayExecutable is this Gateway's own program, which an agent is given as
+// the command to run for a proxied MCP connection.
+func GatewayExecutable() (string, error) {
+ return gatewayExecutable()
+}
+
+// GatewayBaseUrl is the loopback address of the Gateway on this machine, for a
+// child process that has to ask it something.
+func GatewayBaseUrl() (string, error) {
+ return loopbackURL("")
+}
diff --git a/controllers/connector.go b/controllers/connector.go
index 218638e..dc496d4 100644
--- a/controllers/connector.go
+++ b/controllers/connector.go
@@ -16,7 +16,12 @@ package controllers
import (
"encoding/json"
+ "slices"
"sort"
+ "strings"
+
+ "github.com/apache/casbin-gateway/agentmonitor"
+ "github.com/apache/casbin-gateway/agentpatch"
"github.com/apache/casbin-gateway/agent"
"github.com/apache/casbin-gateway/agentconfig"
@@ -203,3 +208,53 @@ func (c *ApiController) Disconnect() {
}
c.ResponseOk(planned)
}
+
+// ResolveConnection hands the proxy the server behind one connection, with its
+// credentials filled in. This is the one place a stored credential leaves
+// Gateway, so it is fenced the way tool checks are: a direct loopback request,
+// presenting the installation credential issued when the connection was
+// written, and only for the agent that credential names.
+func (c *ApiController) ResolveConnection() {
+ if _, ok := c.directLoopbackClient(); !ok {
+ c.ResponseError("resolving a connection is limited to direct loopback requests")
+ return
+ }
+ tokenAgentId, ok := agentpatch.ValidateIngestToken(c.Ctx.Input.Header(agentmonitor.IngestTokenHeader))
+ if !ok {
+ c.ResponseError("resolving a connection requires a valid installation token")
+ return
+ }
+
+ var form struct {
+ Owner string `json:"owner"`
+ Name string `json:"name"`
+ Agent string `json:"agent"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if tokenAgentId != "" && !strings.EqualFold(form.Agent, tokenAgentId) {
+ c.ResponseError("agent does not match the installation this token was issued for")
+ return
+ }
+
+ // A connection this agent was never given is not one it may resolve, or one
+ // agent's token would open every connection on the machine.
+ connection, err := object.GetConnection(form.Owner, form.Name)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if connection == nil || !slices.Contains(connection.Agents, form.Agent) {
+ c.ResponseError("this connection is not installed in " + form.Agent)
+ return
+ }
+
+ rendered, err := object.ResolveConnection(form.Owner, form.Name)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(rendered)
+}
diff --git a/main.go b/main.go
index 1d4c6a7..f56cc1f 100644
--- a/main.go
+++ b/main.go
@@ -27,6 +27,7 @@ import (
"github.com/apache/casbin-gateway/casdoor"
"github.com/apache/casbin-gateway/conf"
"github.com/apache/casbin-gateway/controllers"
+ "github.com/apache/casbin-gateway/mcpproxy"
"github.com/apache/casbin-gateway/mcpserver"
"github.com/apache/casbin-gateway/object"
"github.com/apache/casbin-gateway/proxy"
@@ -46,6 +47,7 @@ func main() {
// process. They must exit before Gateway initializes its own services.
agenthook.ServeIfInvoked()
mcpserver.ServeIfInvoked()
+ mcpproxy.ServeIfInvoked()
// A link in the URL scheme of an agent opens this executable while Gateway
// is routing one to a particular copy. It is a launch, not a service.
diff --git a/mcpproxy/gateway.go b/mcpproxy/gateway.go
new file mode 100644
index 0000000..1930d4f
--- /dev/null
+++ b/mcpproxy/gateway.go
@@ -0,0 +1,165 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package mcpproxy
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/apache/casbin-gateway/agentmonitor"
+)
+
+const (
+ resolveTimeout = 10 * time.Second
+ // A tool call waits on the verdict, so a Gateway that is slow to answer is
+ // treated like one that is not running: the call goes ahead. Holding every
+ // session on this machine would be the worse failure.
+ decideTimeout = 2 * time.Second
+)
+
+// resolvedServer is one connection's MCP server with its credentials filled in,
+// as Gateway rendered it. It exists only for the life of this process and is
+// never written anywhere.
+type resolvedServer struct {
+ Name string `json:"name"`
+ Transport string `json:"transport"`
+ Command string `json:"command"`
+ Args []string `json:"args"`
+ Env map[string]string `json:"env"`
+ Url string `json:"url"`
+ Headers map[string]string `json:"headers"`
+}
+
+func (r *resolvedServer) envPairs() []string {
+ pairs := make([]string, 0, len(r.Env))
+ for name, value := range r.Env {
+ pairs = append(pairs, name+"="+value)
+ }
+ return pairs
+}
+
+// gatewayClient is this process's line to the Gateway that launched it.
+type gatewayClient struct {
+ options Options
+ client *http.Client
+}
+
+func newGatewayClient(options Options) *gatewayClient {
+ return &gatewayClient{options: options, client: &http.Client{Timeout: resolveTimeout}}
+}
+
+type apiResponse struct {
+ Status string `json:"status"`
+ Msg string `json:"msg"`
+ Data json.RawMessage `json:"data"`
+}
+
+func (g *gatewayClient) post(path string, body any, timeout time.Duration) (json.RawMessage, error) {
+ if g.options.GatewayUrl == "" {
+ return nil, fmt.Errorf("no Gateway address was given")
+ }
+ encoded, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+
+ request, err := http.NewRequest(http.MethodPost, g.options.GatewayUrl+path, bytes.NewReader(encoded))
+ if err != nil {
+ return nil, err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set(agentmonitor.IngestTokenHeader, g.options.Token)
+
+ client := g.client
+ if timeout != 0 && timeout != client.Timeout {
+ client = &http.Client{Timeout: timeout}
+ }
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ var decoded apiResponse
+ if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil {
+ return nil, err
+ }
+ if decoded.Status != "ok" {
+ return nil, fmt.Errorf("%s", decoded.Msg)
+ }
+ return decoded.Data, nil
+}
+
+// resolve asks Gateway for the server behind this connection. This is the only
+// place the credential exists outside Gateway, and it exists in memory of a
+// process the agent started and will end.
+func (g *gatewayClient) resolve() (*resolvedServer, error) {
+ data, err := g.post("/api/resolve-connection", map[string]string{
+ "owner": g.options.Owner,
+ "name": g.options.Connection,
+ "agent": g.options.AgentId,
+ }, resolveTimeout)
+ if err != nil {
+ return nil, err
+ }
+
+ resolved := &resolvedServer{}
+ if err := json.Unmarshal(data, resolved); err != nil {
+ return nil, err
+ }
+ return resolved, nil
+}
+
+// allowTool asks whether this agent may make one call. A verdict that cannot be
+// had allows the call, matching what every hook does: Gateway being down or slow
+// must not be the thing that breaks somebody's session.
+func (g *gatewayClient) allowTool(tool string) (bool, string) {
+ if tool == "" || g.options.AgentId == "" || g.options.Token == "" {
+ return true, ""
+ }
+
+ data, err := g.post("/api/check-agent-tool", map[string]string{
+ "agent": g.options.AgentId,
+ "tool": toolKey(g.options.Connection, tool),
+ }, decideTimeout)
+ if err != nil {
+ return true, ""
+ }
+
+ var verdict struct {
+ Allow bool `json:"allow"`
+ Reason string `json:"reason"`
+ }
+ if json.Unmarshal(data, &verdict) != nil {
+ return true, ""
+ }
+ return verdict.Allow, verdict.Reason
+}
+
+// toolKey names a call the way the permission switches do, so a connection's
+// tools land on the switch that agent already has for this MCP server.
+func toolKey(connection string, tool string) string {
+ return "mcp__" + connection + "__" + tool
+}
+
+// GatewayUrl is the address a patched agent is given for the Gateway on this
+// machine.
+func GatewayUrl(port string) string {
+ return (&url.URL{Scheme: "http", Host: "127.0.0.1:" + port}).String()
+}
diff --git a/mcpproxy/http.go b/mcpproxy/http.go
new file mode 100644
index 0000000..eb0330c
--- /dev/null
+++ b/mcpproxy/http.go
@@ -0,0 +1,137 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package mcpproxy
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+// sessionHeader is what a Streamable HTTP server names a session with. It comes
+// back from initialize and has to be sent on everything after it, or the server
+// treats each request as a new session.
+const sessionHeader = "Mcp-Session-Id"
+
+const httpTimeout = 5 * time.Minute
+
+// httpUpstream reaches a remote MCP server over Streamable HTTP. One client
+// message is one POST, and the reply is either a JSON body or an event stream
+// carrying several messages.
+type httpUpstream struct {
+ resolved *resolvedServer
+ client *http.Client
+ emit func([]byte) error
+
+ mutex sync.Mutex
+ session string
+}
+
+func newHttpUpstream(resolved *resolvedServer) *httpUpstream {
+ return &httpUpstream{resolved: resolved, client: &http.Client{Timeout: httpTimeout}}
+}
+
+func (u *httpUpstream) Start(emit func([]byte) error) error {
+ u.emit = emit
+ return nil
+}
+
+func (u *httpUpstream) Close() error { return nil }
+
+func (u *httpUpstream) Send(line []byte) error {
+ request, err := http.NewRequest(http.MethodPost, u.resolved.Url, bytes.NewReader(line))
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Accept", "application/json, text/event-stream")
+ for name, value := range u.resolved.Headers {
+ request.Header.Set(name, value)
+ }
+ if session := u.currentSession(); session != "" {
+ request.Header.Set(sessionHeader, session)
+ }
+
+ response, err := u.client.Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ if session := response.Header.Get(sessionHeader); session != "" {
+ u.rememberSession(session)
+ }
+ // A notification is answered with 202 and no body, which is not something
+ // to pass on.
+ if response.StatusCode == http.StatusAccepted || response.StatusCode == http.StatusNoContent {
+ return nil
+ }
+ if response.StatusCode >= 400 {
+ body, _ := io.ReadAll(io.LimitReader(response.Body, 4*1024))
+ return fmt.Errorf("%s answered %s: %s", u.resolved.Url, response.Status, strings.TrimSpace(string(body)))
+ }
+
+ if strings.Contains(response.Header.Get("Content-Type"), "text/event-stream") {
+ return u.readEvents(response.Body)
+ }
+
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return err
+ }
+ if len(bytes.TrimSpace(body)) == 0 {
+ return nil
+ }
+ return u.emit(body)
+}
+
+// readEvents forwards every message an event stream carries. Only the data
+// field matters here: the rest of the SSE framing is transport, and what an MCP
+// client is given back is the JSON-RPC message inside it.
+func (u *httpUpstream) readEvents(body io.Reader) error {
+ scanner := bufio.NewScanner(body)
+ scanner.Buffer(make([]byte, 0, 64*1024), maxLineBytes)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data:") {
+ continue
+ }
+ data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+ if data == "" {
+ continue
+ }
+ if err := u.emit([]byte(data)); err != nil {
+ return err
+ }
+ }
+ return scanner.Err()
+}
+
+func (u *httpUpstream) currentSession() string {
+ u.mutex.Lock()
+ defer u.mutex.Unlock()
+ return u.session
+}
+
+func (u *httpUpstream) rememberSession(session string) {
+ u.mutex.Lock()
+ defer u.mutex.Unlock()
+ u.session = session
+}
diff --git a/mcpproxy/mcpproxy.go b/mcpproxy/mcpproxy.go
new file mode 100644
index 0000000..a49a35d
--- /dev/null
+++ b/mcpproxy/mcpproxy.go
@@ -0,0 +1,194 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package mcpproxy stands between an agent and the MCP server one connection
+// reaches. The agent is given this executable as the server to run, so what its
+// configuration file holds is the name of a connection rather than the
+// credential behind it: the credential stays in Gateway and is fetched here,
+// over loopback, for the life of one session.
+//
+// Everything the agent sends passes through, so a tool call can be asked about
+// before it runs and recorded after — which is what puts connector traffic
+// under the same rules as the rest of an agent's tools.
+package mcpproxy
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "sync"
+)
+
+// Subcommand is what an agent's configuration runs this executable with.
+const Subcommand = "mcp-proxy"
+
+const maxLineBytes = 8 * 1024 * 1024
+
+// ServeIfInvoked enters proxy mode when an agent launched Gateway as the MCP
+// server for one connection. Like the recorder, it exits before Gateway
+// initializes its own services so stdout stays a clean JSON-RPC stream.
+func ServeIfInvoked() {
+ if len(os.Args) < 2 || os.Args[1] != Subcommand {
+ return
+ }
+ if err := Run(os.Args[2:]); err != nil {
+ fmt.Fprintf(os.Stderr, "casbin-gateway %s: %v\n", Subcommand, err)
+ os.Exit(1)
+ }
+ os.Exit(0)
+}
+
+// Options is one proxied session.
+type Options struct {
+ Connection string
+ AgentId string
+ Owner string
+ GatewayUrl string
+ Token string
+}
+
+func Run(args []string) error {
+ flags := flag.NewFlagSet(Subcommand, flag.ContinueOnError)
+ flags.SetOutput(os.Stderr)
+ options := Options{}
+ flags.StringVar(&options.Connection, "connection", "", "the connection to reach")
+ flags.StringVar(&options.AgentId, "agent", "", "agent this server was written into")
+ flags.StringVar(&options.Owner, "owner", "", "the account the connection belongs to")
+ flags.StringVar(&options.GatewayUrl, "gateway-url", "", "the running Gateway")
+ flags.StringVar(&options.Token, "token", "", "credential presented to Gateway")
+ if err := flags.Parse(args); err != nil {
+ return err
+ }
+ if options.Connection == "" {
+ return fmt.Errorf("--connection is required")
+ }
+ return Serve(os.Stdin, os.Stdout, options)
+}
+
+// Serve proxies one session until the agent closes its end.
+func Serve(in *os.File, out *os.File, options Options) error {
+ client := newGatewayClient(options)
+
+ // Resolving is what needs Gateway running. Failing here with a plain
+ // message is better than a half-open session: the agent shows the server as
+ // failed to start, which is what actually happened.
+ resolved, err := client.resolve()
+ if err != nil {
+ return fmt.Errorf("cannot reach connection %q: %w", options.Connection, err)
+ }
+
+ server, err := openUpstream(resolved)
+ if err != nil {
+ return err
+ }
+ defer server.Close()
+
+ proxy := &proxy{options: options, client: client, server: server, out: out}
+ if err := server.Start(proxy.emit); err != nil {
+ return err
+ }
+ return proxy.pump(in)
+}
+
+type proxy struct {
+ options Options
+ client *gatewayClient
+ server upstream
+ out *os.File
+
+ // writing serializes what reaches the agent: replies the upstream produced
+ // and refusals written here are both messages on one stream.
+ writing sync.Mutex
+}
+
+// message is only what the proxy has to look at. Everything else about a
+// JSON-RPC message is forwarded byte for byte, so a field this build has never
+// heard of still reaches the other side intact.
+type message struct {
+ Id json.RawMessage `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params struct {
+ Name string `json:"name"`
+ } `json:"params"`
+}
+
+// pump forwards everything the agent sends, asking about the calls that need
+// asking about.
+func (p *proxy) pump(in *os.File) error {
+ scanner := bufio.NewScanner(in)
+ scanner.Buffer(make([]byte, 0, 64*1024), maxLineBytes)
+ for scanner.Scan() {
+ line := bytes.TrimSpace(scanner.Bytes())
+ if len(line) == 0 {
+ continue
+ }
+ // The scanner reuses its buffer, and the upstream write may outlive
+ // this iteration.
+ forwarded := append([]byte(nil), line...)
+
+ var parsed message
+ if err := json.Unmarshal(forwarded, &parsed); err == nil && parsed.Method == "tools/call" {
+ allowed, reason := p.client.allowTool(parsed.Params.Name)
+ if !allowed {
+ if err := p.refuse(parsed.Id, reason); err != nil {
+ return err
+ }
+ continue
+ }
+ }
+
+ if err := p.server.Send(forwarded); err != nil {
+ return err
+ }
+ }
+ return scanner.Err()
+}
+
+// emit writes one upstream message to the agent.
+func (p *proxy) emit(line []byte) error {
+ p.writing.Lock()
+ defer p.writing.Unlock()
+ if _, err := p.out.Write(append(bytes.TrimSpace(line), '\n')); err != nil {
+ return err
+ }
+ return nil
+}
+
+// refuse answers a denied call as a tool error rather than a protocol error, so
+// the model is told it may not use the tool and carries on, instead of the
+// client treating the server as broken.
+func (p *proxy) refuse(id json.RawMessage, reason string) error {
+ if len(id) == 0 {
+ return nil
+ }
+ if reason == "" {
+ reason = "this tool is not allowed for this agent"
+ }
+
+ body, err := json.Marshal(map[string]any{
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": map[string]any{
+ "isError": true,
+ "content": []map[string]any{{"type": "text", "text": "Refused by Casbin Gateway: " + reason}},
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return p.emit(body)
+}
diff --git a/mcpproxy/upstream.go b/mcpproxy/upstream.go
new file mode 100644
index 0000000..9eecb55
--- /dev/null
+++ b/mcpproxy/upstream.go
@@ -0,0 +1,115 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package mcpproxy
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "os"
+ "os/exec"
+ "sync"
+)
+
+// upstream is the MCP server one connection actually reaches, whether that is a
+// process started here or a service reached over HTTP.
+//
+// Start is given the function that delivers a server message to the agent,
+// because the two transports produce those at different times: a stdio server
+// writes whenever it likes, an HTTP one answers a request.
+type upstream interface {
+ Start(emit func([]byte) error) error
+ Send(line []byte) error
+ Close() error
+}
+
+func openUpstream(resolved *resolvedServer) (upstream, error) {
+ switch resolved.Transport {
+ case "stdio":
+ return &stdioUpstream{resolved: resolved}, nil
+ case "http":
+ return newHttpUpstream(resolved), nil
+ default:
+ return nil, fmt.Errorf("unknown transport %q for this connection", resolved.Transport)
+ }
+}
+
+// stdioUpstream runs the server as a child process. The credential reaches it
+// through the environment and arguments Gateway rendered, and never touches the
+// agent's own configuration file.
+type stdioUpstream struct {
+ resolved *resolvedServer
+ command *exec.Cmd
+ stdin interface {
+ Write([]byte) (int, error)
+ Close() error
+ }
+ writing sync.Mutex
+}
+
+func (u *stdioUpstream) Start(emit func([]byte) error) error {
+ command := exec.Command(u.resolved.Command, u.resolved.Args...)
+ command.Env = append(os.Environ(), u.resolved.envPairs()...)
+ // The child's own diagnostics belong on this process's stderr, where the
+ // agent shows them, rather than mixed into the JSON-RPC stream.
+ command.Stderr = os.Stderr
+
+ stdin, err := command.StdinPipe()
+ if err != nil {
+ return err
+ }
+ stdout, err := command.StdoutPipe()
+ if err != nil {
+ return err
+ }
+ if err := command.Start(); err != nil {
+ return fmt.Errorf("cannot start %s: %w", u.resolved.Command, err)
+ }
+ u.command, u.stdin = command, stdin
+
+ go func() {
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 0, 64*1024), maxLineBytes)
+ for scanner.Scan() {
+ line := bytes.TrimSpace(scanner.Bytes())
+ if len(line) == 0 {
+ continue
+ }
+ if emit(append([]byte(nil), line...)) != nil {
+ return
+ }
+ }
+ }()
+ return nil
+}
+
+func (u *stdioUpstream) Send(line []byte) error {
+ u.writing.Lock()
+ defer u.writing.Unlock()
+ _, err := u.stdin.Write(append(line, '\n'))
+ return err
+}
+
+func (u *stdioUpstream) Close() error {
+ if u.command == nil {
+ return nil
+ }
+ if u.stdin != nil {
+ // Closing stdin is how an MCP server is asked to stop; killing it
+ // outright would leave whatever it was writing half-written.
+ _ = u.stdin.Close()
+ }
+ return u.command.Wait()
+}
diff --git a/object/connection_install.go b/object/connection_install.go
index b8bf155..f62d209 100644
--- a/object/connection_install.go
+++ b/object/connection_install.go
@@ -19,43 +19,52 @@ import (
"sort"
"github.com/apache/casbin-gateway/agentconfig"
+ "github.com/apache/casbin-gateway/agentpatch"
"github.com/apache/casbin-gateway/connector"
+ "github.com/apache/casbin-gateway/mcpproxy"
)
// InstallConnection writes one connection's MCP server into each agent in
// agentIds and takes it out of every agent it was in before. One target's
// failure is reported against that target and the rest still run, which is how
// the rest of agentconfig behaves.
+//
+// What is written is not the server itself but Gateway standing in front of it,
+// so the third-party credential never reaches the agent's configuration file
+// and every call through it can be asked about.
func InstallConnection(connection *Connection, agentIds []string) ([]*agentconfig.PlanItem, error) {
found, ok := connector.Get(connection.Name)
if !ok {
return nil, fmt.Errorf("no connector named %q", connection.Name)
}
- rendered, err := found.Render(connection.Credentials)
- if err != nil {
+ // Rendering here does not produce what is written; it is what refuses to
+ // install a connection whose credentials are incomplete.
+ if _, err := found.Render(connection.Credentials); err != nil {
return nil, err
}
planned := []*agentconfig.PlanItem{}
- if len(agentIds) > 0 {
- planned, err = agentconfig.AddMcp(agentconfig.McpRequest{
- Owner: connection.Owner,
- To: agentIds,
- Name: rendered.Name,
- Transport: rendered.Transport,
- Command: rendered.Command,
- Args: rendered.Args,
- Env: rendered.Env,
- Url: rendered.Url,
- Headers: rendered.Headers,
- Overwrite: true,
- })
+ for _, agentId := range agentIds {
+ request, err := proxyRequest(connection, found, agentId)
if err != nil {
- return nil, err
+ planned = append(planned, &agentconfig.PlanItem{
+ AgentId: agentId, Name: found.Server.Name, Action: "skip", Reason: err.Error(),
+ })
+ continue
}
+ // One agent at a time: each is given a credential of its own, so the
+ // entry written is not the same entry twice.
+ items, err := agentconfig.AddMcp(request)
+ if err != nil {
+ planned = append(planned, &agentconfig.PlanItem{
+ AgentId: agentId, Name: found.Server.Name, Action: "skip", Reason: err.Error(),
+ })
+ continue
+ }
+ planned = append(planned, items...)
}
- planned = append(planned, removeFrom(connection, rendered.Name, dropped(connection.Agents, agentIds))...)
+ planned = append(planned, removeFrom(connection, found.Server.Name, dropped(connection.Agents, agentIds))...)
connection.Agents = installed(planned, agentIds)
if err := SaveConnection(connection); err != nil {
@@ -64,6 +73,44 @@ func InstallConnection(connection *Connection, agentIds []string) ([]*agentconfi
return planned, nil
}
+// proxyRequest is the entry one agent gets: this executable, told which
+// connection to reach and how to reach Gateway.
+//
+// The token in it is Gateway's own, not the service's: it works from loopback
+// only, names one agent, and is revoked from here. The credential that would be
+// worth stealing stays in Gateway's database.
+func proxyRequest(connection *Connection, found connector.Connector, agentId string) (agentconfig.McpRequest, error) {
+ executable, err := agentpatch.GatewayExecutable()
+ if err != nil {
+ return agentconfig.McpRequest{}, err
+ }
+ gatewayUrl, err := agentpatch.GatewayBaseUrl()
+ if err != nil {
+ return agentconfig.McpRequest{}, err
+ }
+ token, err := agentpatch.IssueIngestToken(agentpatch.Target{AgentId: agentId, Owner: connection.Owner})
+ if err != nil {
+ return agentconfig.McpRequest{}, err
+ }
+
+ return agentconfig.McpRequest{
+ Owner: connection.Owner,
+ To: []string{agentId},
+ Name: found.Server.Name,
+ Transport: agentconfig.TransportStdio,
+ Command: executable,
+ Args: []string{
+ mcpproxy.Subcommand,
+ "--connection", connection.Name,
+ "--agent", agentId,
+ "--owner", connection.Owner,
+ "--gateway-url", gatewayUrl,
+ "--token", token,
+ },
+ Overwrite: true,
+ }, nil
+}
+
// UninstallConnection takes the server out of every agent that has it and
// forgets the connection along with its credentials.
func UninstallConnection(owner string, name string) ([]*agentconfig.PlanItem, error) {
@@ -83,6 +130,30 @@ func UninstallConnection(owner string, name string) ([]*agentconfig.PlanItem, er
return planned, nil
}
+// ResolveConnection is what the proxy asks for when an agent starts it: the
+// server behind one connection, with its credentials filled in. It is answered
+// over loopback to a process this Gateway's own configuration launched, and the
+// result is never written to disk.
+func ResolveConnection(owner string, name string) (*connector.Rendered, error) {
+ connection, err := GetConnection(owner, name)
+ if err != nil {
+ return nil, err
+ }
+ if connection == nil {
+ return nil, fmt.Errorf("%q is not connected", name)
+ }
+ found, ok := connector.Get(name)
+ if !ok {
+ return nil, fmt.Errorf("no connector named %q", name)
+ }
+
+ rendered, err := found.Render(connection.Credentials)
+ if err != nil {
+ return nil, err
+ }
+ return &rendered, nil
+}
+
// removeFrom deletes the server entry from each agent, reporting what happened
// per agent. An agent that no longer has the entry is not an error: the
// operator may have deleted it by hand, and the connection still has to let go.
diff --git a/routers/router.go b/routers/router.go
index fddb7bb..f49d7b9 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -112,6 +112,7 @@ func initAPI() {
beego.Router("/api/get-connection", &controllers.ApiController{}, "GET:GetConnection")
beego.Router("/api/connect", &controllers.ApiController{}, "POST:Connect")
beego.Router("/api/disconnect", &controllers.ApiController{}, "POST:Disconnect")
+ beego.Router("/api/resolve-connection", &controllers.ApiController{}, "POST:ResolveConnection")
// The desktop tray, which holds the local token instead of a session.
beego.Router("/api/get-tray-menu", &controllers.ApiController{}, "GET:GetTrayMenu")
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 01/02: feat(connections): connect an app once and write it into every agent
Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 9c8aa173d5e983fa6f704f2545b1f4dd83f12c5f
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Sep 5 11:20:34 2026 +0800
feat(connections): connect an app once and write it into every agent
---
agentconfig/layout.go | 7 +
connector/catalog.go | 169 +++++++++++++++
connector/connector.go | 127 +++++++++++
connector/connectors/amap.json | 34 +++
connector/connectors/chrome-devtools.json | 20 ++
connector/connectors/context7.json | 32 +++
connector/connectors/feishu.json | 42 ++++
connector/connectors/filesystem.json | 30 +++
connector/connectors/github.json | 30 +++
connector/connectors/notion.json | 34 +++
connector/connectors/playwright.json | 20 ++
connector/render.go | 133 ++++++++++++
controllers/connector.go | 205 ++++++++++++++++++
object/connection.go | 198 +++++++++++++++++
object/connection_install.go | 135 ++++++++++++
object/ormer.go | 8 +
routers/router.go | 5 +
web/src/App.tsx | 5 +
web/src/backend/ConnectorBackend.ts | 42 ++++
web/src/locales/en/data.json | 52 ++++-
web/src/locales/zh/data.json | 52 ++++-
web/src/nav.ts | 4 +
web/src/pages/ConnectionsPage.tsx | 344 ++++++++++++++++++++++++++++++
web/src/types.ts | 71 ++++++
24 files changed, 1797 insertions(+), 2 deletions(-)
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
index 944790a..3464e7f 100644
--- a/agentconfig/layout.go
+++ b/agentconfig/layout.go
@@ -313,6 +313,13 @@ func Supports(agentId string) bool {
return ok
}
+// SupportsMcp reports whether Gateway knows where agentId keeps its MCP
+// servers, which is what makes it a target a connection can be installed into.
+func SupportsMcp(agentId string) bool {
+ found, ok := layouts[agentId]
+ return ok && found.mcp != nil
+}
+
// McpConfigPath is where agentId keeps its MCP servers, for the account whose
// home directory is home. Callers outside this package use it to edit one entry
// of a file this package otherwise owns.
diff --git a/connector/catalog.go b/connector/catalog.go
new file mode 100644
index 0000000..2db1c23
--- /dev/null
+++ b/connector/catalog.go
@@ -0,0 +1,169 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package connector
+
+import (
+ "bytes"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "sort"
+ "strings"
+)
+
+// The catalog is embedded in the Gateway binary.
+//
+//go:embed connectors/*.json
+var catalogFS embed.FS
+
+const catalogDir = "connectors"
+
+var catalog = mustLoadCatalog(catalogFS)
+
+// mustLoadCatalog rejects invalid embedded build data at startup, the way the
+// agent fingerprints do: a malformed entry is a build mistake, not something to
+// discover when somebody opens the page.
+func mustLoadCatalog(files fs.FS) []Connector {
+ loaded, err := loadCatalog(files)
+ if err != nil {
+ panic("connector: cannot load connector catalog: " + err.Error())
+ }
+ return loaded
+}
+
+func loadCatalog(files fs.FS) ([]Connector, error) {
+ names, err := fs.Glob(files, catalogDir+"/*.json")
+ if err != nil {
+ return nil, err
+ }
+ sort.Strings(names)
+
+ loaded := make([]Connector, 0, len(names))
+ seen := make(map[string]bool, len(names))
+ for _, name := range names {
+ found, err := decode(files, name)
+ if err != nil {
+ return nil, err
+ }
+ if seen[found.Id] {
+ return nil, fmt.Errorf("%s: duplicate connector id %q", name, found.Id)
+ }
+ seen[found.Id] = true
+ loaded = append(loaded, found)
+ }
+
+ sort.Slice(loaded, func(i int, j int) bool { return loaded[i].Id < loaded[j].Id })
+ return loaded, nil
+}
+
+func decode(files fs.FS, name string) (Connector, error) {
+ raw, err := fs.ReadFile(files, name)
+ if err != nil {
+ return Connector{}, err
+ }
+
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.DisallowUnknownFields()
+ found := Connector{}
+ if err := decoder.Decode(&found); err != nil {
+ return Connector{}, fmt.Errorf("%s: %w", name, err)
+ }
+ if err := validate(found); err != nil {
+ return Connector{}, fmt.Errorf("%s: %w", name, err)
+ }
+ return found, nil
+}
+
+func validate(c Connector) error {
+ switch {
+ case strings.TrimSpace(c.Id) == "":
+ return fmt.Errorf("a connector needs an id")
+ case strings.TrimSpace(c.DisplayName) == "":
+ return fmt.Errorf("%s: needs a displayName", c.Id)
+ case !categories[c.Category]:
+ return fmt.Errorf("%s: unknown category %q", c.Id, c.Category)
+ case strings.TrimSpace(c.Server.Name) == "":
+ return fmt.Errorf("%s: needs a server name", c.Id)
+ }
+
+ switch c.Auth.Kind {
+ case AuthNone:
+ case AuthToken:
+ if len(c.Auth.Fields) == 0 {
+ return fmt.Errorf("%s: a token connector needs at least one field", c.Id)
+ }
+ case AuthOauth2:
+ if c.Auth.AuthorizeUrl == "" || c.Auth.TokenUrl == "" {
+ return fmt.Errorf("%s: an oauth2 connector needs authorizeUrl and tokenUrl", c.Id)
+ }
+ default:
+ return fmt.Errorf("%s: unknown auth kind %q", c.Id, c.Auth.Kind)
+ }
+
+ for _, field := range c.Auth.Fields {
+ if strings.TrimSpace(field.Key) == "" {
+ return fmt.Errorf("%s: a field needs a key", c.Id)
+ }
+ }
+
+ switch c.Server.Transport {
+ case "stdio":
+ if c.Server.Command == "" {
+ return fmt.Errorf("%s: a stdio server needs a command", c.Id)
+ }
+ case "http":
+ if c.Server.Url == "" {
+ return fmt.Errorf("%s: an http server needs a url", c.Id)
+ }
+ default:
+ return fmt.Errorf("%s: unknown transport %q", c.Id, c.Server.Transport)
+ }
+
+ // Every placeholder a template uses has to be a field somebody fills in,
+ // or the entry written into an agent carries a literal "${...}".
+ declared := map[string]bool{}
+ for _, field := range c.Auth.Fields {
+ declared[field.Key] = true
+ }
+ for _, reference := range placeholders(c.Server) {
+ if !declared[reference] {
+ return fmt.Errorf("%s: server uses ${%s}, which no field supplies", c.Id, reference)
+ }
+ }
+ return nil
+}
+
+// List is the whole catalog, by id.
+func List() []Connector {
+ found := make([]Connector, len(catalog))
+ copy(found, catalog)
+ return found
+}
+
+// Get finds one connector by id.
+func Get(id string) (Connector, bool) {
+ for _, found := range catalog {
+ if found.Id == id {
+ return found, true
+ }
+ }
+ return Connector{}, false
+}
+
+// Categories is every section the catalog uses, in display order.
+func Categories() []string {
+ return []string{CategoryOffice, CategoryDocs, CategoryDev, CategoryMail, CategoryStorage, CategoryLife}
+}
diff --git a/connector/connector.go b/connector/connector.go
new file mode 100644
index 0000000..4212628
--- /dev/null
+++ b/connector/connector.go
@@ -0,0 +1,127 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package connector holds the catalog of applications an agent can be
+// connected to. A connector is data, not code: one JSON file naming the
+// service, how it is authenticated, and the MCP server that reaches it.
+package connector
+
+// How a connection is authenticated.
+const (
+ // AuthNone is a server that needs no credential at all.
+ AuthNone = "none"
+ // AuthToken is a credential the operator pastes in: an API key, a personal
+ // access token, a database URL.
+ AuthToken = "token"
+ // AuthOauth2 is the authorization code flow. The operator registers an
+ // application on the vendor's own platform and Gateway runs the flow with
+ // it, so no client secret of ours is shipped in the binary and the grant
+ // stays inside their own tenant.
+ AuthOauth2 = "oauth2"
+)
+
+// The catalog's own sections, which the page groups its cards by.
+const (
+ CategoryDev = "dev"
+ CategoryDocs = "docs"
+ CategoryOffice = "office"
+ CategoryMail = "mail"
+ CategoryStorage = "storage"
+ CategoryLife = "life"
+)
+
+var categories = map[string]bool{
+ CategoryDev: true,
+ CategoryDocs: true,
+ CategoryOffice: true,
+ CategoryMail: true,
+ CategoryStorage: true,
+ CategoryLife: true,
+}
+
+// Connector is one entry in the catalog.
+type Connector struct {
+ Id string `json:"id"`
+ DisplayName string `json:"displayName"`
+ Category string `json:"category"`
+ // Icon is a site the favicon is taken from, spelled like a provider's.
+ Icon string `json:"icon"`
+ Description string `json:"description"`
+ DocsUrl string `json:"docsUrl,omitempty"`
+ // Paid marks a service that bills for its own API, which the card says
+ // before anybody connects it rather than after.
+ Paid bool `json:"paid,omitempty"`
+ // Unverified marks an entry whose server has not been run against the live
+ // service by us. It is listed, and the card says so.
+ Unverified bool `json:"unverified,omitempty"`
+
+ Auth Auth `json:"auth"`
+ Server ServerSpec `json:"server"`
+}
+
+// Auth is how one connector proves who it is.
+type Auth struct {
+ Kind string `json:"kind"`
+ // Fields is what the operator fills in, for AuthToken and for the client
+ // application half of AuthOauth2.
+ Fields []Field `json:"fields,omitempty"`
+
+ // The rest is the OAuth flow, empty for every other kind.
+ AuthorizeUrl string `json:"authorizeUrl,omitempty"`
+ TokenUrl string `json:"tokenUrl,omitempty"`
+ Scopes []string `json:"scopes,omitempty"`
+ ExtraParams map[string]string `json:"extraParams,omitempty"`
+ // RegisterUrl is where the operator creates the application these
+ // credentials come from, linked from the connect dialog.
+ RegisterUrl string `json:"registerUrl,omitempty"`
+}
+
+// Field is one value the operator supplies.
+type Field struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ Placeholder string `json:"placeholder,omitempty"`
+ Help string `json:"help,omitempty"`
+ // Secret keeps the value out of every response once it is stored.
+ Secret bool `json:"secret,omitempty"`
+ Required bool `json:"required,omitempty"`
+}
+
+// ServerSpec is the MCP server that reaches the service, as a template. A
+// "${field}" in any value is replaced by the credential of that name.
+type ServerSpec struct {
+ // Name is what the entry is called in an agent's own configuration.
+ Name string `json:"name"`
+ Transport string `json:"transport"`
+ Command string `json:"command,omitempty"`
+ Args []string `json:"args,omitempty"`
+ Env map[string]string `json:"env,omitempty"`
+ Url string `json:"url,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+}
+
+// NeedsAuth reports whether connecting this one asks for anything.
+func (c Connector) NeedsAuth() bool { return c.Auth.Kind != AuthNone }
+
+// SecretKeys is every field of this connector whose value is never returned
+// once stored.
+func (c Connector) SecretKeys() []string {
+ keys := []string{}
+ for _, field := range c.Auth.Fields {
+ if field.Secret {
+ keys = append(keys, field.Key)
+ }
+ }
+ return keys
+}
diff --git a/connector/connectors/amap.json b/connector/connectors/amap.json
new file mode 100644
index 0000000..1d3b1d2
--- /dev/null
+++ b/connector/connectors/amap.json
@@ -0,0 +1,34 @@
+{
+ "id": "amap",
+ "displayName": "Amap",
+ "category": "life",
+ "icon": "amap.com",
+ "description": "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.",
+ "docsUrl": "https://lbs.amap.com/api/mcp-server/summary",
+ "unverified": true,
+ "auth": {
+ "kind": "token",
+ "fields": [
+ {
+ "key": "apiKey",
+ "label": "Web service API key",
+ "help": "From the Amap open platform. Pick the web service key type, not the one for apps.",
+ "secret": true,
+ "required": true
+ }
+ ],
+ "registerUrl": "https://console.amap.com/dev/key/app"
+ },
+ "server": {
+ "name": "amap",
+ "transport": "stdio",
+ "command": "npx",
+ "args": [
+ "-y",
+ "@amap/amap-maps-mcp-server"
+ ],
+ "env": {
+ "AMAP_MAPS_API_KEY": "${apiKey}"
+ }
+ }
+}
diff --git a/connector/connectors/chrome-devtools.json b/connector/connectors/chrome-devtools.json
new file mode 100644
index 0000000..9235136
--- /dev/null
+++ b/connector/connectors/chrome-devtools.json
@@ -0,0 +1,20 @@
+{
+ "id": "chrome-devtools",
+ "displayName": "Chrome DevTools",
+ "category": "dev",
+ "icon": "developer.chrome.com",
+ "description": "Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.",
+ "docsUrl": "https://github.com/ChromeDevTools/chrome-devtools-mcp",
+ "auth": {
+ "kind": "none"
+ },
+ "server": {
+ "name": "chrome-devtools",
+ "transport": "stdio",
+ "command": "npx",
+ "args": [
+ "-y",
+ "chrome-devtools-mcp@latest"
+ ]
+ }
+}
diff --git a/connector/connectors/context7.json b/connector/connectors/context7.json
new file mode 100644
index 0000000..7175cb6
--- /dev/null
+++ b/connector/connectors/context7.json
@@ -0,0 +1,32 @@
+{
+ "id": "context7",
+ "displayName": "Context7",
+ "category": "docs",
+ "icon": "context7.com",
+ "description": "Up-to-date documentation and code samples for third-party libraries, so the model stops writing API calls from memory.",
+ "docsUrl": "https://github.com/upstash/context7",
+ "auth": {
+ "kind": "token",
+ "fields": [
+ {
+ "key": "apiKey",
+ "label": "API key",
+ "placeholder": "ctx7sk-...",
+ "help": "From context7.com. It works without one, under a stricter rate limit.",
+ "secret": true
+ }
+ ]
+ },
+ "server": {
+ "name": "context7",
+ "transport": "stdio",
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/context7-mcp"
+ ],
+ "env": {
+ "CONTEXT7_API_KEY": "${apiKey}"
+ }
+ }
+}
diff --git a/connector/connectors/feishu.json b/connector/connectors/feishu.json
new file mode 100644
index 0000000..cd32b0d
--- /dev/null
+++ b/connector/connectors/feishu.json
@@ -0,0 +1,42 @@
+{
+ "id": "feishu",
+ "displayName": "Lark",
+ "category": "office",
+ "icon": "feishu.cn",
+ "description": "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app.",
+ "docsUrl": "https://github.com/larksuite/lark-openapi-mcp",
+ "unverified": true,
+ "auth": {
+ "kind": "token",
+ "fields": [
+ {
+ "key": "appId",
+ "label": "App ID",
+ "placeholder": "cli_...",
+ "help": "From a self-built app on the Lark open platform. Its permissions are what your own administrator approved.",
+ "required": true
+ },
+ {
+ "key": "appSecret",
+ "label": "App secret",
+ "secret": true,
+ "required": true
+ }
+ ],
+ "registerUrl": "https://open.feishu.cn/app"
+ },
+ "server": {
+ "name": "feishu",
+ "transport": "stdio",
+ "command": "npx",
+ "args": [
+ "-y",
+ "@larksuiteoapi/lark-mcp",
+ "mcp",
+ "-a",
+ "${appId}",
+ "-s",
+ "${appSecret}"
+ ]
+ }
+}
diff --git a/connector/connectors/filesystem.json b/connector/connectors/filesystem.json
new file mode 100644
index 0000000..279bd3d
--- /dev/null
+++ b/connector/connectors/filesystem.json
@@ -0,0 +1,30 @@
+{
+ "id": "filesystem",
+ "displayName": "Local files",
+ "category": "storage",
+ "icon": "modelcontextprotocol.io",
+ "description": "Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.",
+ "docsUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
+ "auth": {
+ "kind": "token",
+ "fields": [
+ {
+ "key": "root",
+ "label": "Directory to allow",
+ "placeholder": "D:/work/notes",
+ "help": "The agent can read and write this directory and everything under it.",
+ "required": true
+ }
+ ]
+ },
+ "server": {
+ "name": "filesystem",
+ "transport": "stdio",
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-filesystem",
+ "${root}"
+ ]
+ }
+}
diff --git a/connector/connectors/github.json b/connector/connectors/github.json
new file mode 100644
index 0000000..a4bb9a9
--- /dev/null
+++ b/connector/connectors/github.json
@@ -0,0 +1,30 @@
+{
+ "id": "github",
+ "displayName": "GitHub",
+ "category": "dev",
+ "icon": "github.com",
+ "description": "Read and write repositories, issues and pull requests, and search code and history, so an agent works inside your project's own context.",
+ "docsUrl": "https://github.com/github/github-mcp-server",
+ "auth": {
+ "kind": "token",
+ "fields": [
+ {
+ "key": "token",
+ "label": "Personal access token",
+ "placeholder": "ghp_... or github_pat_...",
+ "help": "Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.",
+ "secret": true,
+ "required": true
+ }
+ ],
+ "registerUrl": "https://github.com/settings/personal-access-tokens"
+ },
+ "server": {
+ "name": "github",
+ "transport": "http",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "headers": {
+ "Authorization": "Bearer ${token}"
+ }
+ }
+}
diff --git a/connector/connectors/notion.json b/connector/connectors/notion.json
new file mode 100644
index 0000000..116a0ec
--- /dev/null
+++ b/connector/connectors/notion.json
@@ -0,0 +1,34 @@
+{
+ "id": "notion",
+ "displayName": "Notion",
+ "category": "docs",
+ "icon": "notion.so",
+ "description": "Read and organise pages, databases and wikis: find a document, take stock of a project, and write the conclusion back.",
+ "docsUrl": "https://github.com/makenotion/notion-mcp-server",
+ "auth": {
+ "kind": "token",
+ "fields": [
+ {
+ "key": "token",
+ "label": "Integration token",
+ "placeholder": "ntn_...",
+ "help": "Create an internal integration in Notion, then share with it the pages it may read.",
+ "secret": true,
+ "required": true
+ }
+ ],
+ "registerUrl": "https://www.notion.so/my-integrations"
+ },
+ "server": {
+ "name": "notion",
+ "transport": "stdio",
+ "command": "npx",
+ "args": [
+ "-y",
+ "@notionhq/notion-mcp-server"
+ ],
+ "env": {
+ "NOTION_TOKEN": "${token}"
+ }
+ }
+}
diff --git a/connector/connectors/playwright.json b/connector/connectors/playwright.json
new file mode 100644
index 0000000..ee87911
--- /dev/null
+++ b/connector/connectors/playwright.json
@@ -0,0 +1,20 @@
+{
+ "id": "playwright",
+ "displayName": "Playwright",
+ "category": "dev",
+ "icon": "playwright.dev",
+ "description": "Drive a browser: open pages, click, fill forms, take screenshots, and read the console and network, for end-to-end tests and scraping.",
+ "docsUrl": "https://github.com/microsoft/playwright-mcp",
+ "auth": {
+ "kind": "none"
+ },
+ "server": {
+ "name": "playwright",
+ "transport": "stdio",
+ "command": "npx",
+ "args": [
+ "-y",
+ "@playwright/mcp@latest"
+ ]
+ }
+}
diff --git a/connector/render.go b/connector/render.go
new file mode 100644
index 0000000..d4e60b0
--- /dev/null
+++ b/connector/render.go
@@ -0,0 +1,133 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package connector
+
+import (
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// placeholder is "${field}" in any template value.
+var placeholder = regexp.MustCompile(`\$\{([A-Za-z0-9_.-]+)\}`)
+
+// placeholders is every field name a server template refers to, sorted so a
+// validation failure names them in a stable order.
+func placeholders(server ServerSpec) []string {
+ seen := map[string]bool{}
+ for _, value := range templateValues(server) {
+ for _, match := range placeholder.FindAllStringSubmatch(value, -1) {
+ seen[match[1]] = true
+ }
+ }
+
+ found := make([]string, 0, len(seen))
+ for name := range seen {
+ found = append(found, name)
+ }
+ sort.Strings(found)
+ return found
+}
+
+func templateValues(server ServerSpec) []string {
+ values := []string{server.Command, server.Url}
+ values = append(values, server.Args...)
+ for _, value := range server.Env {
+ values = append(values, value)
+ }
+ for _, value := range server.Headers {
+ values = append(values, value)
+ }
+ return values
+}
+
+// Rendered is one connector's server with its credentials filled in, in the
+// shape agentconfig writes into an agent's own configuration.
+type Rendered struct {
+ Name string
+ Transport string
+ Command string
+ Args []string
+ Env map[string]string
+ Url string
+ Headers map[string]string
+}
+
+// Render fills a connector's server template from credentials. A missing
+// required credential is an error rather than an empty substitution, so a
+// half-configured connection is never written into an agent.
+func (c Connector) Render(credentials map[string]string) (Rendered, error) {
+ for _, field := range c.Auth.Fields {
+ if field.Required && strings.TrimSpace(credentials[field.Key]) == "" {
+ return Rendered{}, fmt.Errorf("%s needs a value", field.Label)
+ }
+ }
+
+ fill := func(value string) (string, error) {
+ var missing string
+ filled := placeholder.ReplaceAllStringFunc(value, func(match string) string {
+ name := placeholder.FindStringSubmatch(match)[1]
+ found, ok := credentials[name]
+ if !ok || found == "" {
+ missing = name
+ return ""
+ }
+ return found
+ })
+ if missing != "" {
+ return "", fmt.Errorf("no value for ${%s}", missing)
+ }
+ return filled, nil
+ }
+
+ rendered := Rendered{Name: c.Server.Name, Transport: c.Server.Transport}
+ var err error
+ if rendered.Command, err = fill(c.Server.Command); err != nil {
+ return Rendered{}, err
+ }
+ if rendered.Url, err = fill(c.Server.Url); err != nil {
+ return Rendered{}, err
+ }
+ for _, arg := range c.Server.Args {
+ filled, err := fill(arg)
+ if err != nil {
+ return Rendered{}, err
+ }
+ rendered.Args = append(rendered.Args, filled)
+ }
+ if rendered.Env, err = fillMap(c.Server.Env, fill); err != nil {
+ return Rendered{}, err
+ }
+ if rendered.Headers, err = fillMap(c.Server.Headers, fill); err != nil {
+ return Rendered{}, err
+ }
+ return rendered, nil
+}
+
+func fillMap(values map[string]string, fill func(string) (string, error)) (map[string]string, error) {
+ if len(values) == 0 {
+ return nil, nil
+ }
+ filled := make(map[string]string, len(values))
+ for key, value := range values {
+ found, err := fill(value)
+ if err != nil {
+ return nil, err
+ }
+ filled[key] = found
+ }
+ return filled, nil
+}
diff --git a/controllers/connector.go b/controllers/connector.go
new file mode 100644
index 0000000..218638e
--- /dev/null
+++ b/controllers/connector.go
@@ -0,0 +1,205 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+ "encoding/json"
+ "sort"
+
+ "github.com/apache/casbin-gateway/agent"
+ "github.com/apache/casbin-gateway/agentconfig"
+ "github.com/apache/casbin-gateway/connector"
+ "github.com/apache/casbin-gateway/object"
+)
+
+// ConnectorEntry is one catalog card: the connector itself, plus whether this
+// machine has connected it and to which agents.
+type ConnectorEntry struct {
+ connector.Connector
+ Connected bool `json:"connected"`
+ Agents []string `json:"agents"`
+}
+
+// ConnectorTarget is one agent on this machine a connection can be installed
+// into: an installation Gateway found whose MCP configuration it knows how to
+// write.
+type ConnectorTarget struct {
+ AgentId string `json:"agentId"`
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+}
+
+// ConnectorCatalog is everything the connectors page draws itself from.
+type ConnectorCatalog struct {
+ Connectors []*ConnectorEntry `json:"connectors"`
+ Categories []string `json:"categories"`
+ Targets []*ConnectorTarget `json:"targets"`
+}
+
+// GetConnectors lists the whole catalog with each entry's connection state, the
+// sections to group it by, and the agents a connection can be installed into.
+func (c *ApiController) GetConnectors() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ connections, err := object.GetConnections(c.GetString("owner"))
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ state := map[string]*object.Connection{}
+ for _, connection := range connections {
+ state[connection.Name] = connection
+ }
+
+ entries := []*ConnectorEntry{}
+ for _, found := range connector.List() {
+ entry := &ConnectorEntry{Connector: found, Agents: []string{}}
+ if connection, ok := state[found.Id]; ok {
+ entry.Connected = true
+ entry.Agents = connection.Agents
+ }
+ entries = append(entries, entry)
+ }
+
+ targets, err := connectorTargets()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(&ConnectorCatalog{Connectors: entries, Categories: connector.Categories(), Targets: targets})
+}
+
+// connectorTargets is every installation found on this machine whose MCP
+// configuration Gateway knows how to write, one row per agent rather than per
+// installation: a connection is written into an agent's own configuration, and
+// two installations of one agent share it.
+func connectorTargets() ([]*ConnectorTarget, error) {
+ installations, err := agent.Scan(false)
+ if err != nil {
+ return nil, err
+ }
+
+ seen := map[string]bool{}
+ targets := []*ConnectorTarget{}
+ for _, installation := range installations {
+ if seen[installation.AgentId] || !agentconfig.SupportsMcp(installation.AgentId) {
+ continue
+ }
+ seen[installation.AgentId] = true
+ targets = append(targets, &ConnectorTarget{
+ AgentId: installation.AgentId,
+ Name: installation.Name,
+ Owner: installation.Owner,
+ })
+ }
+
+ sort.Slice(targets, func(i int, j int) bool { return targets[i].Name < targets[j].Name })
+ return targets, nil
+}
+
+// GetConnection returns one connection with its secrets masked, which is what
+// fills the edit dialog.
+func (c *ApiController) GetConnection() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ connection, err := object.GetConnection(c.GetString("owner"), c.GetString("name"))
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if connection == nil {
+ c.ResponseOk(nil)
+ return
+ }
+ c.ResponseOk(connection.Masked())
+}
+
+// Connect stores one connector's credentials and writes its MCP server into
+// the agents that were ticked. Sending it again is how a connection is edited:
+// the agents it names become the agents it is installed in.
+func (c *ApiController) Connect() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var form struct {
+ Owner string `json:"owner"`
+ Name string `json:"name"`
+ Credentials map[string]string `json:"credentials"`
+ Agents []string `json:"agents"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if _, ok := connector.Get(form.Name); !ok {
+ c.ResponseError("no connector named " + form.Name)
+ return
+ }
+
+ connection, err := object.GetConnection(form.Owner, form.Name)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if connection == nil {
+ connection = &object.Connection{Owner: form.Owner, Name: form.Name}
+ }
+ connection.Credentials = form.Credentials
+
+ // Storing before installing keeps a credential that was accepted from being
+ // lost because one agent's configuration could not be written.
+ if err := object.SaveConnection(connection); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ planned, err := object.InstallConnection(connection, form.Agents)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(planned)
+}
+
+// Disconnect removes the server from every agent that has it and forgets the
+// credentials, so revoking one connection is a single action rather than an
+// edit of every agent it reached.
+func (c *ApiController) Disconnect() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var form struct {
+ Owner string `json:"owner"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ planned, err := object.UninstallConnection(form.Owner, form.Name)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(planned)
+}
diff --git a/object/connection.go b/object/connection.go
new file mode 100644
index 0000000..c616097
--- /dev/null
+++ b/object/connection.go
@@ -0,0 +1,198 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+
+ "github.com/apache/casbin-gateway/connector"
+ "github.com/apache/casbin-gateway/util"
+ "github.com/xorm-io/core"
+)
+
+// Connection is one connector the operator has connected, and the credentials
+// it was connected with. Holding them here is what makes one connection a
+// single thing rather than a copy per agent: connecting writes it into every
+// agent that was picked, and disconnecting takes it out of all of them.
+//
+// The rendered credential still lands in each agent's own configuration file,
+// because that is where its MCP client reads a server from. Keeping the secret
+// out of those files needs Gateway to proxy the server itself, which it does
+// not do yet.
+type Connection struct {
+ Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
+ // Name is the connector's catalog id: one connection per connector.
+ Name string `xorm:"varchar(100) notnull pk" json:"name"`
+ CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
+ UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
+
+ // Credentials holds the fields the connector asked for. Secret values are
+ // ciphertext at rest when "apiKeyEncryptionKey" is set, like a provider's
+ // API key, and are masked on the way out.
+ Credentials map[string]string `xorm:"mediumtext json" json:"credentials"`
+ // Agents is every agent id this connection's MCP server was written into.
+ Agents []string `xorm:"mediumtext json" json:"agents"`
+}
+
+func (connection *Connection) GetId() string {
+ return fmt.Sprintf("%s/%s", connection.Owner, connection.Name)
+}
+
+// credentialAad binds one field's ciphertext to its own row and key, so a value
+// copied into another connection, or into another field of the same one, no
+// longer decrypts.
+func credentialAad(connection *Connection, key string) string {
+ return connection.GetId() + "/" + key
+}
+
+func encryptCredentials(connection *Connection) error {
+ found, ok := connector.Get(connection.Name)
+ if !ok {
+ return fmt.Errorf("no connector named %q", connection.Name)
+ }
+
+ for _, key := range found.SecretKeys() {
+ value, ok := connection.Credentials[key]
+ if !ok || value == "" {
+ continue
+ }
+ encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(), value, credentialAad(connection, key))
+ if err != nil {
+ return err
+ }
+ connection.Credentials[key] = encrypted
+ }
+ return nil
+}
+
+// decryptConnection leaves a value that will not decrypt as it is, matching how
+// a provider's key behaves when the encryption key changed: the row is still
+// listed, and using it is what reports the problem.
+func decryptConnection(connection *Connection) {
+ found, ok := connector.Get(connection.Name)
+ if !ok {
+ return
+ }
+
+ for _, key := range found.SecretKeys() {
+ value, ok := connection.Credentials[key]
+ if !ok || value == "" {
+ continue
+ }
+ decrypted, err := util.DecryptWithKey(apiKeyEncryptionSecret(), value, credentialAad(connection, key))
+ if err == nil {
+ connection.Credentials[key] = decrypted
+ }
+ }
+}
+
+// Masked is a copy safe to send to the browser: every secret field is replaced
+// by ApiKeyMask, so a stored credential never leaves this process.
+func (connection *Connection) Masked() *Connection {
+ found, ok := connector.Get(connection.Name)
+ if !ok {
+ return connection
+ }
+
+ masked := *connection
+ masked.Credentials = map[string]string{}
+ for key, value := range connection.Credentials {
+ masked.Credentials[key] = value
+ }
+ for _, key := range found.SecretKeys() {
+ if masked.Credentials[key] != "" {
+ masked.Credentials[key] = ApiKeyMask
+ }
+ }
+ return &masked
+}
+
+func GetConnections(owner string) ([]*Connection, error) {
+ connections := []*Connection{}
+ err := ormer.Engine.Where("owner = ?", owner).Find(&connections)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, connection := range connections {
+ decryptConnection(connection)
+ }
+ sort.Slice(connections, func(i int, j int) bool { return connections[i].Name < connections[j].Name })
+ return connections, nil
+}
+
+func GetConnection(owner string, name string) (*Connection, error) {
+ connection := Connection{Owner: owner, Name: name}
+ existed, err := ormer.Engine.Get(&connection)
+ if err != nil || !existed {
+ return nil, err
+ }
+
+ decryptConnection(&connection)
+ return &connection, nil
+}
+
+// SaveConnection writes one connection, creating it the first time. A secret
+// left at ApiKeyMask keeps whatever is already stored, so an edit that only
+// changes the agent list never has to resend the credential.
+func SaveConnection(connection *Connection) error {
+ found, ok := connector.Get(connection.Name)
+ if !ok {
+ return fmt.Errorf("no connector named %q", connection.Name)
+ }
+ if connection.Credentials == nil {
+ connection.Credentials = map[string]string{}
+ }
+
+ existing, err := GetConnection(connection.Owner, connection.Name)
+ if err != nil {
+ return err
+ }
+ for _, key := range found.SecretKeys() {
+ if connection.Credentials[key] != ApiKeyMask {
+ continue
+ }
+ if existing == nil {
+ return errors.New("a masked credential has nothing to keep")
+ }
+ connection.Credentials[key] = existing.Credentials[key]
+ }
+
+ // Rendering here is what stops a half-filled connection from being stored
+ // at all, rather than failing later when an agent tries to start it.
+ if _, err := found.Render(connection.Credentials); err != nil {
+ return err
+ }
+
+ connection.UpdatedTime = util.GetCurrentTime()
+ if err := encryptCredentials(connection); err != nil {
+ return err
+ }
+
+ if existing == nil {
+ connection.CreatedTime = util.GetCurrentTime()
+ _, err = ormer.Engine.Insert(connection)
+ return err
+ }
+ _, err = ormer.Engine.ID(core.PK{connection.Owner, connection.Name}).AllCols().Update(connection)
+ return err
+}
+
+func DeleteConnection(owner string, name string) error {
+ _, err := ormer.Engine.ID(core.PK{owner, name}).Delete(&Connection{})
+ return err
+}
diff --git a/object/connection_install.go b/object/connection_install.go
new file mode 100644
index 0000000..b8bf155
--- /dev/null
+++ b/object/connection_install.go
@@ -0,0 +1,135 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+ "fmt"
+ "sort"
+
+ "github.com/apache/casbin-gateway/agentconfig"
+ "github.com/apache/casbin-gateway/connector"
+)
+
+// InstallConnection writes one connection's MCP server into each agent in
+// agentIds and takes it out of every agent it was in before. One target's
+// failure is reported against that target and the rest still run, which is how
+// the rest of agentconfig behaves.
+func InstallConnection(connection *Connection, agentIds []string) ([]*agentconfig.PlanItem, error) {
+ found, ok := connector.Get(connection.Name)
+ if !ok {
+ return nil, fmt.Errorf("no connector named %q", connection.Name)
+ }
+ rendered, err := found.Render(connection.Credentials)
+ if err != nil {
+ return nil, err
+ }
+
+ planned := []*agentconfig.PlanItem{}
+ if len(agentIds) > 0 {
+ planned, err = agentconfig.AddMcp(agentconfig.McpRequest{
+ Owner: connection.Owner,
+ To: agentIds,
+ Name: rendered.Name,
+ Transport: rendered.Transport,
+ Command: rendered.Command,
+ Args: rendered.Args,
+ Env: rendered.Env,
+ Url: rendered.Url,
+ Headers: rendered.Headers,
+ Overwrite: true,
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ planned = append(planned, removeFrom(connection, rendered.Name, dropped(connection.Agents, agentIds))...)
+
+ connection.Agents = installed(planned, agentIds)
+ if err := SaveConnection(connection); err != nil {
+ return nil, err
+ }
+ return planned, nil
+}
+
+// UninstallConnection takes the server out of every agent that has it and
+// forgets the connection along with its credentials.
+func UninstallConnection(owner string, name string) ([]*agentconfig.PlanItem, error) {
+ connection, err := GetConnection(owner, name)
+ if err != nil || connection == nil {
+ return nil, err
+ }
+ found, ok := connector.Get(name)
+ if !ok {
+ return nil, fmt.Errorf("no connector named %q", name)
+ }
+
+ planned := removeFrom(connection, found.Server.Name, connection.Agents)
+ if err := DeleteConnection(owner, name); err != nil {
+ return nil, err
+ }
+ return planned, nil
+}
+
+// removeFrom deletes the server entry from each agent, reporting what happened
+// per agent. An agent that no longer has the entry is not an error: the
+// operator may have deleted it by hand, and the connection still has to let go.
+func removeFrom(connection *Connection, serverName string, agentIds []string) []*agentconfig.PlanItem {
+ planned := []*agentconfig.PlanItem{}
+ for _, agentId := range agentIds {
+ item := &agentconfig.PlanItem{AgentId: agentId, Name: serverName, Action: "remove"}
+ if err := agentconfig.Delete(agentId, connection.Owner, agentconfig.KindMcp, serverName); err != nil {
+ item.Action, item.Reason = "skip", err.Error()
+ }
+ planned = append(planned, item)
+ }
+ return planned
+}
+
+// dropped is every agent in before that is not in after.
+func dropped(before []string, after []string) []string {
+ keep := map[string]bool{}
+ for _, agentId := range after {
+ keep[agentId] = true
+ }
+
+ found := []string{}
+ for _, agentId := range before {
+ if !keep[agentId] {
+ found = append(found, agentId)
+ }
+ }
+ return found
+}
+
+// installed is the agents the write actually reached, so a connection never
+// claims an agent whose configuration could not be written.
+func installed(planned []*agentconfig.PlanItem, agentIds []string) []string {
+ failed := map[string]bool{}
+ for _, item := range planned {
+ if item.Action == "skip" {
+ failed[item.AgentId] = true
+ }
+ }
+
+ found := []string{}
+ for _, agentId := range agentIds {
+ if !failed[agentId] {
+ found = append(found, agentId)
+ }
+ }
+ sort.Strings(found)
+ return found
+}
diff --git a/object/ormer.go b/object/ormer.go
index a26aba8..4a83fe2 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -315,4 +315,12 @@ func (a *Ormer) createTable() {
if err != nil {
panic(err)
}
+
+ // The applications an agent has been connected to, and the credentials they
+ // were connected with, which stay here rather than in each agent's own
+ // configuration file.
+ err = a.Engine.Sync2(new(Connection))
+ if err != nil {
+ panic(err)
+ }
}
diff --git a/routers/router.go b/routers/router.go
index 2ccd97b..fddb7bb 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -108,6 +108,11 @@ func initAPI() {
beego.Router("/api/restore-agent-provider", &controllers.ApiController{}, "POST:RestoreAgentProvider")
beego.Router("/api/copy-agent-config", &controllers.ApiController{}, "POST:CopyAgentConfig")
+ beego.Router("/api/get-connectors", &controllers.ApiController{}, "GET:GetConnectors")
+ beego.Router("/api/get-connection", &controllers.ApiController{}, "GET:GetConnection")
+ beego.Router("/api/connect", &controllers.ApiController{}, "POST:Connect")
+ beego.Router("/api/disconnect", &controllers.ApiController{}, "POST:Disconnect")
+
// The desktop tray, which holds the local token instead of a session.
beego.Router("/api/get-tray-menu", &controllers.ApiController{}, "GET:GetTrayMenu")
beego.Router("/api/set-agent-provider", &controllers.ApiController{}, "POST:SetAgentProvider")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 6fac971..2fa4472 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -38,6 +38,7 @@ import AgentVersionsPage from "@/pages/AgentVersionsPage";
import AgentsPage from "@/pages/AgentsPage";
import AuthCallback from "@/pages/AuthCallback";
import AuthenticityPage from "@/pages/AuthenticityPage";
+import ConnectionsPage from "@/pages/ConnectionsPage";
import HomePage from "@/pages/HomePage";
import ImportLinkPage from "@/pages/ImportLinkPage";
import PermissionsPage from "@/pages/PermissionsPage";
@@ -309,6 +310,10 @@ export default function App() {
path="/agent-sessions/:sessionKey"
element={requireSignin(user => <AgentSessionPage account={user} />)}
/>
+ <Route
+ path="/connections"
+ element={requireSignin(user => <ConnectionsPage account={user} />)}
+ />
<Route path="/import" element={requireSignin(() => <ImportLinkPage />)} />
<Route path="/settings" element={requireSignin(user => <SettingPage account={user} />)} />
<Route
diff --git a/web/src/backend/ConnectorBackend.ts b/web/src/backend/ConnectorBackend.ts
new file mode 100644
index 0000000..164b45d
--- /dev/null
+++ b/web/src/backend/ConnectorBackend.ts
@@ -0,0 +1,42 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {query, request} from "@/backend/request";
+import type {AgentConfigPlanItem, ConnectRequest, ConnectorCatalog} from "@/types";
+
+/** The catalog, what is connected here, and the agents it can be written into. */
+export function getConnectors(owner: string) {
+ return request<ConnectorCatalog>(`/api/get-connectors${query({owner: owner})}`);
+}
+
+/** One connection with its secrets masked, which is what fills the dialog. */
+export function getConnection(owner: string, name: string) {
+ return request<{credentials: Record<string, string>; agents: string[]} | null>(
+ `/api/get-connection${query({owner: owner, name: name})}`,
+ );
+}
+
+/**
+ * Stores the credentials and writes the server into the agents named. Sending
+ * it again is how a connection is edited: the agents it names become the agents
+ * it is installed in, and the ones left out have it taken away.
+ */
+export function connect(body: ConnectRequest) {
+ return request<AgentConfigPlanItem[]>("/api/connect", "POST", body);
+}
+
+/** Removes the server from every agent and forgets the credentials. */
+export function disconnect(owner: string, name: string) {
+ return request<AgentConfigPlanItem[]>("/api/disconnect", "POST", {owner: owner, name: name});
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index e16092c..649ceba 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -801,6 +801,55 @@
"What this sends": "What this sends",
"Wrong": "Wrong"
},
+ "connector": {
+ "API key": "API key",
+ "All": "All",
+ "Amap": "Amap",
+ "App ID": "App ID",
+ "App secret": "App secret",
+ "Billed by vendor": "Billed by vendor",
+ "Connect": "Connect",
+ "Connected": "Connected",
+ "Connected in {count}": "In {count} agents",
+ "Connections": "Connections",
+ "Connections detail": "Connect the applications you use, so an agent can work across them. One connection is stored here and written into whichever agents you pick.",
+ "Create an internal integration in Notion, then share with it the pages it may read.": "Create an internal integration in Notion, then share with it the pages it may read.",
+ "Development": "Development",
+ "Directory to allow": "Directory to allow",
+ "Disconnect": "Disconnect",
+ "Disconnected": "Disconnected",
+ "Documents": "Documents",
+ "Drive a browser: open pages, click, fill forms, take screenshots, and read the console and network, for end-to-end tests and scraping.": "Drive a browser: open pages, click, fill forms, take screenshots, and read the console and network, for end-to-end tests and scraping.",
+ "From a self-built app on the Lark open platform. Its permissions are what your own administrator approved.": "From a self-built app on the Lark open platform. Its permissions are what your own administrator approved.",
+ "From context7.com. It works without one, under a stricter rate limit.": "From context7.com. It works without one, under a stricter rate limit.",
+ "From the Amap open platform. Pick the web service key type, not the one for apps.": "From the Amap open platform. Pick the web service key type, not the one for apps.",
+ "Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.": "Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.",
+ "Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.": "Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.",
+ "Install into": "Install into",
+ "Install into hint": "The agents this connection is written into. Unticking one takes the server out of it.",
+ "Integration token": "Integration token",
+ "Lark": "Lark",
+ "Life": "Life",
+ "Loading": "Loading...",
+ "Local files": "Local files",
+ "Mail": "Mail",
+ "Manage": "Manage",
+ "No agent found": "No agent on this machine takes MCP servers Gateway knows how to write.",
+ "Office": "Office",
+ "Personal access token": "Personal access token",
+ "Pick at least one agent": "Pick at least one agent.",
+ "Read and organise pages, databases and wikis: find a document, take stock of a project, and write the conclusion back.": "Read and organise pages, databases and wikis: find a document, take stock of a project, and write the conclusion back.",
+ "Read and write repositories, issues and pull requests, and search code and history, so an agent works inside your project's own context.": "Read and write repositories, issues and pull requests, and search code and history, so an agent works inside your project's own context.",
+ "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.": "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.",
+ "Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.": "Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.",
+ "Storage": "Storage",
+ "The agent can read and write this directory and everything under it.": "The agent can read and write this directory and everything under it.",
+ "Unverified": "Unverified",
+ "Up-to-date documentation and code samples for third-party libraries, so the model stops writing API calls from memory.": "Up-to-date documentation and code samples for third-party libraries, so the model stops writing API calls from memory.",
+ "Web service API key": "Web service API key",
+ "Where these come from": "Where these come from",
+ "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app.": "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app."
+ },
"general": {
"Action": "Action",
"Advanced": "Advanced",
@@ -840,6 +889,7 @@
"Latest version": "Latest version",
"Leave this page open, it reloads itself when the new version is up": "Leave this page open, it reloads itself when the new version is up",
"Light": "Light",
+ "Loading": "Loading...",
"Logged in successfully": "Logged in successfully",
"Login Error": "Login Error",
"Name": "Name",
@@ -851,6 +901,7 @@
"No data": "No data",
"OK": "OK",
"Owner": "Owner",
+ "Pages": "Pages",
"Palette": "Palette",
"Password": "Password",
"Path": "Path",
@@ -863,7 +914,6 @@
"Restarting into the new version": "Restarting into the new version",
"Retry": "Retry",
"Save": "Save",
- "Pages": "Pages",
"Search": "Search",
"Search pages, agents and providers": "Search pages, agents and providers",
"Select": "Select",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 1fe1b54..95a1537 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -873,6 +873,55 @@
"case.vendor-headers.method": "这一条只向厂商自己的域名发问,比对的也是该厂商文档里写明的响应头:能说OpenAI兼容的API,不等于自称是OpenAI。真货前面的转售商通常会透传一部分,而从没连过厂商的后端一个也拿不出来;但把响应头抹掉也可能只是干净,所以这一条单独不会判定为异常。响应头列表留空就用该厂商自己的,也可以在这里指定要检查哪些响应头。",
"case.vendor-headers.question": "厂商自己API的那些响应头回来了吗?"
},
+ "connector": {
+ "API key": "API Key",
+ "All": "全部",
+ "Amap": "高德地图",
+ "App ID": "App ID",
+ "App secret": "App Secret",
+ "Billed by vendor": "第三方收费",
+ "Connect": "连接",
+ "Connected": "已连接",
+ "Connected in {count}": "已装入{count}个Agent",
+ "Connections": "连接",
+ "Connections detail": "连接常用的应用,让Agent帮你跨工具完成任务。一次连接保存在这里,并写入你勾选的每个Agent。",
+ "Create an internal integration in Notion, then share with it the pages it may read.": "在Notion开发者后台建一个internal integration,再把允许它读取的页面共享给它。",
+ "Development": "开发工具",
+ "Directory to allow": "允许访问的目录",
+ "Disconnect": "断开连接",
+ "Disconnected": "已断开",
+ "Documents": "文档知识",
+ "Drive a browser: open pages, click, fill forms, take screenshots, and read the console and network, for end-to-end tests and scraping.": "让Agent操作浏览器:打开页面、点击、填表单、截图、读取控制台和网络请求,用于端到端测试和网页取数。",
+ "From a self-built app on the Lark open platform. Its permissions are what your own administrator approved.": "来自飞书开放平台上的企业自建应用,权限范围由你们的管理员审批。",
+ "From context7.com. It works without one, under a stricter rate limit.": "在context7.com注册后获取,留空也可使用,但速率限制更严。",
+ "From the Amap open platform. Pick the web service key type, not the one for apps.": "在高德开放平台申请,注意选择「Web服务」类型的Key,不是应用类型的。",
+ "Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.": "把一个指定目录交给Agent读写,范围仅限你填的那个路径,不影响机器上的其他文件。",
+ "Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.": "把Chrome开发者工具交给Agent:录制性能剖面、检查DOM、读取网络请求和控制台报错,用于排查真实页面的问题。",
+ "Install into": "装入哪些Agent",
+ "Install into hint": "这条连接会写入下面勾选的Agent,取消勾选会把对应的MCP服务从它的配置里删掉。",
+ "Integration token": "集成令牌",
+ "Lark": "飞书",
+ "Life": "生活服务",
+ "Loading": "加载中...",
+ "Local files": "本地文件",
+ "Mail": "邮件服务",
+ "Manage": "管理",
+ "No agent found": "这台机器上没有Gateway能写入MCP配置的Agent。",
+ "Office": "办公协同",
+ "Personal access token": "个人访问令牌",
+ "Pick at least one agent": "至少勾选一个Agent。",
+ "Read and organise pages, databases and wikis: find a document, take stock of a project, and write the conclusion back.": "读取和整理页面、数据库与知识库:查找文档、梳理项目状态,并把结论写回页面。",
+ "Read and write repositories, issues and pull requests, and search code and history, so an agent works inside your project's own context.": "读写仓库、issue和pull request,检索代码与提交历史,让Agent直接在你的项目上下文里工作。",
+ "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.": "搜索地点、规划驾车与步行路线、查询天气,以及把地址转成坐标。",
+ "Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.": "在Settings→Developer settings→Personal access tokens生成,只勾选你愿意让Agent拥有的权限。",
+ "Storage": "网盘存储",
+ "The agent can read and write this directory and everything under it.": "Agent只能读写这个目录及其子目录。",
+ "Unverified": "未验证",
+ "Up-to-date documentation and code samples for third-party libraries, so the model stops writing API calls from memory.": "为Agent提供第三方库的最新文档和代码示例,避免模型凭记忆写出过时的API调用。",
+ "Web service API key": "Web服务API Key",
+ "Where these come from": "去哪里获取这些凭据",
+ "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app.": "在Agent里直接操作飞书:消息与群组、云文档、多维表格、日历和通讯录,凭据来自你自己企业的自建应用。"
+ },
"general": {
"Action": "操作",
"Advanced": "高级",
@@ -912,6 +961,7 @@
"Latest version": "最新版本",
"Leave this page open, it reloads itself when the new version is up": "请保持本页面打开,新版本启动后会自动刷新",
"Light": "浅色",
+ "Loading": "加载中...",
"Logged in successfully": "登录成功",
"Login Error": "登录失败",
"Name": "名称",
@@ -923,6 +973,7 @@
"No data": "暂无数据",
"OK": "确定",
"Owner": "所有者",
+ "Pages": "页面",
"Palette": "配色",
"Password": "密码",
"Path": "路径",
@@ -935,7 +986,6 @@
"Restarting into the new version": "正在重启到新版本",
"Retry": "重试",
"Save": "保存",
- "Pages": "页面",
"Search": "搜索",
"Search pages, agents and providers": "搜索页面、Agent客户端和供应商",
"Select": "请选择",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index d2bab12..5fe35f2 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -16,6 +16,7 @@ import {
ArrowDownToLine,
Blocks,
Bot,
+ Cable,
ChartColumn,
CircleDollarSign,
FileSearch,
@@ -91,6 +92,9 @@ export const navGroups: NavGroup[] = [
{key: "/agent-configs?tab=prompt", label: "agentConfig:Prompts", path: "/agent-configs?tab=prompt"},
],
},
+ // Connections sit beside Skills, MCP & Prompts because a connection is an MCP
+ // server with its credentials kept here rather than in each agent's file.
+ {key: "/connections", label: "connector:Connections", icon: Cable, path: "/connections", adminOnly: true},
{
key: "/authenticity",
label: "audit:Authenticity",
diff --git a/web/src/pages/ConnectionsPage.tsx b/web/src/pages/ConnectionsPage.tsx
new file mode 100644
index 0000000..cbe57cf
--- /dev/null
+++ b/web/src/pages/ConnectionsPage.tsx
@@ -0,0 +1,344 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {ExternalLink, Plug} from "lucide-react";
+import i18next from "i18next";
+
+import * as Setting from "@/Setting";
+import * as ConnectorBackend from "@/backend/ConnectorBackend";
+import {AgentIcon} from "@/components/AgentIcon";
+import {Field, FormDialog} from "@/components/shared/form-dialog";
+import {UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent} from "@/components/ui/card";
+import {Input} from "@/components/ui/input";
+import {cn} from "@/lib/utils";
+import type {Account, ConnectorCatalog, ConnectorEntry, ConnectorTarget} from "@/types";
+
+const CREDENTIAL_MASK = "***";
+
+/** The catalog sections, keyed by the id the backend lists them under. */
+const categoryLabels: Record<string, string> = {
+ office: "connector:Office",
+ docs: "connector:Documents",
+ dev: "connector:Development",
+ mail: "connector:Mail",
+ storage: "connector:Storage",
+ life: "connector:Life",
+};
+
+function iconUrl(icon: string) {
+ return icon === "" ? "" : `https://www.google.com/s2/favicons?domain=${icon}&sz=64`;
+}
+
+/**
+ * Catalog entries are written in English and translated like every other
+ * string, keyed by what they say. An entry nobody has translated yet falls back
+ * to the catalog's own wording rather than rendering its key.
+ *
+ * A description is a sentence and may hold a ":", which i18next reads as the
+ * namespace separator wherever it sits in the key — so the namespace is passed
+ * as an option and that splitting is turned off for the lookup, or every
+ * sentence holding a colon silently falls back to English.
+ */
+function text(value: string | undefined) {
+ if (value === undefined || value === "") {
+ return "";
+ }
+ return i18next.t(value, {ns: "connector", nsSeparator: false, defaultValue: value});
+}
+
+/**
+ * The Connections page: every application an agent on this machine can be
+ * connected to. A connection is stored here once, credentials included, and
+ * written into whichever agents are ticked, which is what makes connecting an
+ * application one action rather than one per agent.
+ */
+export default function ConnectionsPage({account}: {account: Account}) {
+ const isAdmin = Setting.isAdminUser(account);
+ const [catalog, setCatalog] = React.useState<ConnectorCatalog | null>(null);
+ const [loading, setLoading] = React.useState(true);
+ const [category, setCategory] = React.useState("");
+ const [editing, setEditing] = React.useState<ConnectorEntry | null>(null);
+
+ const reload = React.useCallback(() => {
+ if (!isAdmin) {
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ ConnectorBackend.getConnectors(account.name)
+ .then(response => {
+ if (response.status === "ok") {
+ setCatalog(response.data);
+ } else {
+ Setting.showMessage("error", response.msg ?? "");
+ }
+ })
+ .catch(error => Setting.showMessage("error", error.message))
+ .finally(() => setLoading(false));
+ }, [account.name, isAdmin]);
+
+ React.useEffect(() => reload(), [reload]);
+
+ if (!isAdmin) {
+ return <UnauthorizedResult />;
+ }
+
+ const connectors = catalog?.connectors ?? [];
+ const shown = category === "" ? connectors : connectors.filter(entry => entry.category === category);
+ const categories = (catalog?.categories ?? []).filter(name => connectors.some(entry => entry.category === name));
+
+ return (
+ <PageContainer>
+ <PageHeader title={i18next.t("connector:Connections")} description={i18next.t("connector:Connections detail")} />
+
+ <div className="flex flex-wrap items-center gap-2">
+ <FilterChip active={category === ""} onClick={() => setCategory("")}>
+ {i18next.t("connector:All")}
+ <span className="text-muted-foreground ml-1.5 text-xs">{connectors.length}</span>
+ </FilterChip>
+ {categories.map(name => (
+ <FilterChip key={name} active={category === name} onClick={() => setCategory(name)}>
+ {i18next.t(categoryLabels[name] ?? name)}
+ </FilterChip>
+ ))}
+ </div>
+
+ {loading && connectors.length === 0 ? (
+ <p className="text-muted-foreground text-sm">{i18next.t("general:Loading")}</p>
+ ) : (
+ <div className="grid gap-4 lg:grid-cols-2">
+ {shown.map(entry => (
+ <ConnectorCard key={entry.id} entry={entry} onConnect={() => setEditing(entry)} />
+ ))}
+ </div>
+ )}
+
+ {editing ? (
+ <ConnectDialog
+ account={account}
+ entry={editing}
+ targets={catalog?.targets ?? []}
+ onClose={() => setEditing(null)}
+ onDone={() => {
+ setEditing(null);
+ reload();
+ }}
+ />
+ ) : null}
+ </PageContainer>
+ );
+}
+
+function FilterChip({active, onClick, children}: {active: boolean; onClick: () => void; children: React.ReactNode}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ className={cn(
+ "rounded-full px-3 py-1.5 text-sm transition-colors",
+ active ? "bg-foreground text-background font-medium" : "text-muted-foreground hover:bg-muted",
+ )}
+ >
+ {children}
+ </button>
+ );
+}
+
+function ConnectorCard({entry, onConnect}: {entry: ConnectorEntry; onConnect: () => void}) {
+ const image = iconUrl(entry.icon);
+ return (
+ <Card className="bg-muted/40 border-none shadow-none">
+ <CardContent className="flex items-start gap-4 p-5">
+ <div className="bg-background flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-xl">
+ {image === "" ? <Plug className="text-muted-foreground size-5" /> : <img src={image} alt="" className="size-6" />}
+ </div>
+
+ <div className="min-w-0 flex-1">
+ <div className="flex flex-wrap items-center gap-2">
+ <span className="font-semibold">{text(entry.displayName)}</span>
+ {entry.connected ? (
+ <Badge variant="secondary">
+ {entry.agents.length > 0
+ ? i18next.t("connector:Connected in {count}").replace("{count}", `${entry.agents.length}`)
+ : i18next.t("connector:Connected")}
+ </Badge>
+ ) : null}
+ {entry.paid ? <Badge variant="outline">{i18next.t("connector:Billed by vendor")}</Badge> : null}
+ {entry.unverified ? <Badge variant="outline">{i18next.t("connector:Unverified")}</Badge> : null}
+ </div>
+ <p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{text(entry.description)}</p>
+ </div>
+
+ <Button className="shrink-0 rounded-full" onClick={onConnect}>
+ {entry.connected ? i18next.t("connector:Manage") : i18next.t("connector:Connect")}
+ </Button>
+ </CardContent>
+ </Card>
+ );
+}
+
+/**
+ * The connect dialog: the credentials this connector asks for, and the agents
+ * to write it into. A stored secret comes back masked, and leaving the mask in
+ * place keeps whatever is already there.
+ */
+function ConnectDialog({
+ account,
+ entry,
+ targets,
+ onClose,
+ onDone,
+}: {
+ account: Account;
+ entry: ConnectorEntry;
+ targets: ConnectorTarget[];
+ onClose: () => void;
+ onDone: () => void;
+}) {
+ const fields = entry.auth.fields ?? [];
+ const [credentials, setCredentials] = React.useState<Record<string, string>>({});
+ const [agents, setAgents] = React.useState<string[]>(entry.agents);
+ const [submitting, setSubmitting] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!entry.connected) {
+ return;
+ }
+ ConnectorBackend.getConnection(account.name, entry.id).then(response => {
+ if (response.status === "ok" && response.data) {
+ setCredentials(response.data.credentials ?? {});
+ setAgents(response.data.agents ?? []);
+ }
+ });
+ }, [account.name, entry.connected, entry.id]);
+
+ const missing = fields.some(field => field.required && (credentials[field.key] ?? "").trim() === "");
+
+ const submit = () => {
+ setSubmitting(true);
+ ConnectorBackend.connect({owner: account.name, name: entry.id, credentials: credentials, agents: agents})
+ .then(response => {
+ if (response.status === "ok") {
+ Setting.showMessage("success", i18next.t("connector:Connected"));
+ onDone();
+ } else {
+ Setting.showMessage("error", response.msg ?? "");
+ }
+ })
+ .catch(error => Setting.showMessage("error", error.message))
+ .finally(() => setSubmitting(false));
+ };
+
+ const remove = () => {
+ setSubmitting(true);
+ ConnectorBackend.disconnect(account.name, entry.id)
+ .then(response => {
+ if (response.status === "ok") {
+ Setting.showMessage("success", i18next.t("connector:Disconnected"));
+ onDone();
+ } else {
+ Setting.showMessage("error", response.msg ?? "");
+ }
+ })
+ .catch(error => Setting.showMessage("error", error.message))
+ .finally(() => setSubmitting(false));
+ };
+
+ return (
+ <FormDialog
+ open={true}
+ onOpenChange={open => !open && onClose()}
+ title={text(entry.displayName)}
+ description={text(entry.description)}
+ onSubmit={submit}
+ submitting={submitting}
+ submitDisabled={missing || agents.length === 0}
+ submitText={i18next.t("connector:Connect")}
+ note={
+ entry.connected ? (
+ <Button variant="ghost" className="text-destructive" onClick={remove} disabled={submitting}>
+ {i18next.t("connector:Disconnect")}
+ </Button>
+ ) : null
+ }
+ >
+ {entry.auth.registerUrl ? (
+ <a
+ href={entry.auth.registerUrl}
+ target="_blank"
+ rel="noreferrer"
+ className="text-primary inline-flex items-center gap-1 text-sm hover:underline"
+ >
+ {i18next.t("connector:Where these come from")}
+ <ExternalLink className="size-3.5" />
+ </a>
+ ) : null}
+
+ {fields.map(field => (
+ <Field key={field.key} label={text(field.label)} required={field.required} hint={text(field.help)}>
+ <Input
+ type={field.secret ? "password" : "text"}
+ value={credentials[field.key] ?? ""}
+ placeholder={field.placeholder}
+ onFocus={event => {
+ // A masked secret stands for one already stored, not a value to
+ // edit, so touching the box clears it rather than letting the
+ // mask be sent back as the new credential.
+ if (event.target.value === CREDENTIAL_MASK) {
+ setCredentials({...credentials, [field.key]: ""});
+ }
+ }}
+ onChange={event => setCredentials({...credentials, [field.key]: event.target.value})}
+ />
+ </Field>
+ ))}
+
+ <Field
+ label={i18next.t("connector:Install into")}
+ hint={i18next.t("connector:Install into hint")}
+ error={agents.length === 0 ? i18next.t("connector:Pick at least one agent") : undefined}
+ >
+ <div className="flex flex-wrap gap-2">
+ {targets.map(target => {
+ const picked = agents.includes(target.agentId);
+ return (
+ <button
+ key={target.agentId}
+ type="button"
+ onClick={() =>
+ setAgents(picked ? agents.filter(id => id !== target.agentId) : [...agents, target.agentId])
+ }
+ className={cn(
+ "flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors",
+ picked ? "border-foreground bg-foreground text-background" : "hover:bg-muted",
+ )}
+ >
+ <AgentIcon agent={target.agentId} size={16} />
+ {target.name}
+ </button>
+ );
+ })}
+ {targets.length === 0 ? (
+ <p className="text-muted-foreground text-sm">{i18next.t("connector:No agent found")}</p>
+ ) : null}
+ </div>
+ </Field>
+ </FormDialog>
+ );
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index ee199c0..18c2d1d 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1485,3 +1485,74 @@ export interface CcSwitchResult {
prompts: CcSwitchOutcome[];
skills: CcSwitchOutcome[];
}
+
+export type ConnectorAuthKind = "none" | "token" | "oauth2";
+
+/** One value the operator supplies when connecting an application. */
+export interface ConnectorField {
+ key: string;
+ label: string;
+ placeholder?: string;
+ help?: string;
+ /** Never returned once stored; the form shows a mask instead. */
+ secret?: boolean;
+ required?: boolean;
+}
+
+export interface ConnectorAuth {
+ kind: ConnectorAuthKind;
+ fields?: ConnectorField[];
+ authorizeUrl?: string;
+ tokenUrl?: string;
+ scopes?: string[];
+ /** Where the operator creates the application these credentials come from. */
+ registerUrl?: string;
+}
+
+export interface ConnectorServerSpec {
+ name: string;
+ transport: string;
+ command?: string;
+ args?: string[];
+ env?: Record<string, string>;
+ url?: string;
+ headers?: Record<string, string>;
+}
+
+/** One card on the Connections page: the catalog entry and its state here. */
+export interface ConnectorEntry {
+ id: string;
+ displayName: string;
+ category: string;
+ icon: string;
+ description: string;
+ docsUrl?: string;
+ /** The service bills for its own API, said before anybody connects it. */
+ paid?: boolean;
+ /** Its server has not been run against the live service by us. */
+ unverified?: boolean;
+ auth: ConnectorAuth;
+ server: ConnectorServerSpec;
+ connected: boolean;
+ agents: string[];
+}
+
+/** An agent on this machine a connection can be installed into. */
+export interface ConnectorTarget {
+ agentId: string;
+ name: string;
+ owner: string;
+}
+
+export interface ConnectorCatalog {
+ connectors: ConnectorEntry[];
+ categories: string[];
+ targets: ConnectorTarget[];
+}
+
+export interface ConnectRequest {
+ owner: string;
+ name: string;
+ credentials: Record<string, string>;
+ agents: string[];
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org