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 <[email protected]>
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: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to