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]
