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
The following commit(s) were added to refs/heads/master by this push:
new 0a28e2d feat(connections): test a connection and list the tools it
offers
0a28e2d is described below
commit 0a28e2d2f0f5eb1ae8f2ea64840947fb52851341
Author: Yang Luo <[email protected]>
AuthorDate: Sat Sep 5 18:33:19 2026 +0800
feat(connections): test a connection and list the tools it offers
---
connector/render.go | 29 +++++-
controllers/connector.go | 36 +++++++
mcpproxy/http.go | 6 ++
mcpproxy/probe.go | 201 ++++++++++++++++++++++++++++++++++++
mcpproxy/upstream.go | 60 ++++++++++-
object/connection.go | 11 ++
object/connection_install.go | 46 +++++++++
routers/router.go | 1 +
web/src/backend/ConnectorBackend.ts | 10 +-
web/src/locales/en/data.json | 11 +-
web/src/locales/zh/data.json | 9 +-
web/src/pages/ConnectionsPage.tsx | 63 ++++++++++-
web/src/types.ts | 17 +++
13 files changed, 489 insertions(+), 11 deletions(-)
diff --git a/connector/render.go b/connector/render.go
index 12ba8ce..a6a251d 100644
--- a/connector/render.go
+++ b/connector/render.go
@@ -81,14 +81,26 @@ func (c Connector) Render(credentials map[string]string)
(Rendered, error) {
return Rendered{}, fmt.Errorf("this connection has not been
authorized yet")
}
+ // A field the operator was not obliged to fill in renders as nothing
rather
+ // than as a failure: an optional API key left blank is a connector
running
+ // without one, which is what "optional" was supposed to mean.
+ optional := map[string]bool{}
+ for _, field := range c.Auth.Fields {
+ if !field.Required {
+ optional[field.Key] = true
+ }
+ }
+ for _, key := range ReservedKeys {
+ optional[key] = true
+ }
+
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 == "" {
+ found := credentials[name]
+ if found == "" && !optional[name] {
missing = name
- return ""
}
return found
})
@@ -122,17 +134,26 @@ func (c Connector) Render(credentials map[string]string)
(Rendered, error) {
return rendered, nil
}
+// fillMap renders an environment or header map, dropping any entry that came
+// out empty. An optional credential nobody supplied is better left unset than
+// passed as a blank value a server would try to authenticate with.
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))
+ filled := map[string]string{}
for key, value := range values {
found, err := fill(value)
if err != nil {
return nil, err
}
+ if strings.TrimSpace(found) == "" {
+ continue
+ }
filled[key] = found
}
+ if len(filled) == 0 {
+ return nil, nil
+ }
return filled, nil
}
diff --git a/controllers/connector.go b/controllers/connector.go
index 0e344d3..6205856 100644
--- a/controllers/connector.go
+++ b/controllers/connector.go
@@ -28,6 +28,7 @@ import (
"github.com/apache/casbin-gateway/agent"
"github.com/apache/casbin-gateway/agentconfig"
"github.com/apache/casbin-gateway/connector"
+ "github.com/apache/casbin-gateway/mcpproxy"
"github.com/apache/casbin-gateway/object"
)
@@ -40,6 +41,12 @@ type ConnectorEntry struct {
// Authorized reports that an oauth2 connector has a grant, which is
what
// the dialog needs to tell "fill in the application" from "go and
approve".
Authorized bool `json:"authorized"`
+ // What the last test found: the server's own name, the tools it
offers, and
+ // why the test failed when it did.
+ ServerName string `json:"serverName,omitempty"`
+ Tools []mcpproxy.ProbeTool `json:"tools,omitempty"`
+ ProbedTime string `json:"probedTime,omitempty"`
+ ProbeError string `json:"probeError,omitempty"`
}
// ConnectorTarget is one agent on this machine a connection can be installed
@@ -83,6 +90,10 @@ func (c *ApiController) GetConnectors() {
entry.Connected = true
entry.Agents = connection.Agents
entry.Authorized =
connection.Credentials[connector.KeyAccessToken] != ""
+ entry.ServerName = connection.ServerName
+ entry.Tools = connection.Tools
+ entry.ProbedTime = connection.ProbedTime
+ entry.ProbeError = connection.ProbeError
}
entries = append(entries, entry)
}
@@ -346,3 +357,28 @@ func (c *ApiController) GetConnectorRedirectUri() {
}
c.ResponseOk(redirect)
}
+
+// TestConnection starts this connection's server and reports what it offers.
It
+// is slow by nature - a server installed from npm is downloaded the first time
+// it runs - so the caller is expected to show that it is working.
+func (c *ApiController) TestConnection() {
+ 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
+ }
+
+ result, err := object.TestConnection(form.Owner, form.Name)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(result)
+}
diff --git a/mcpproxy/http.go b/mcpproxy/http.go
index eb0330c..fc99d8b 100644
--- a/mcpproxy/http.go
+++ b/mcpproxy/http.go
@@ -55,6 +55,12 @@ func (u *httpUpstream) Start(emit func([]byte) error) error {
func (u *httpUpstream) Close() error { return nil }
+// A remote server has no end of its own to notice: a request either answers or
+// fails, and there is nothing in between to wait on.
+func (u *httpUpstream) Ended() <-chan struct{} { return nil }
+
+func (u *httpUpstream) Diagnostics() string { return "" }
+
func (u *httpUpstream) Send(line []byte) error {
request, err := http.NewRequest(http.MethodPost, u.resolved.Url,
bytes.NewReader(line))
if err != nil {
diff --git a/mcpproxy/probe.go b/mcpproxy/probe.go
new file mode 100644
index 0000000..4603571
--- /dev/null
+++ b/mcpproxy/probe.go
@@ -0,0 +1,201 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package mcpproxy
+
+import (
+ "encoding/json"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/connector"
+)
+
+// ProbeTimeout is how long one probe waits. A server installed from npm is
+// downloaded the first time it runs, which is slow and is not a failure.
+const ProbeTimeout = 90 * time.Second
+
+// ProbeResult is what a connection's server said about itself.
+type ProbeResult struct {
+ ServerName string `json:"serverName"`
+ ServerVersion string `json:"serverVersion"`
+ Tools []ProbeTool `json:"tools"`
+}
+
+// ProbeTool is one tool the server offers. The description is kept because it
+// is what tells two similarly named tools apart when somebody is deciding
which
+// of them an agent may use.
+type ProbeTool struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+}
+
+// Probe starts one connection's server, asks what it is and what it offers,
and
+// stops it again. It is Gateway asking rather than an agent, so no permission
is
+// consulted and nothing is recorded against a session.
+func Probe(rendered connector.Rendered, timeout time.Duration) (*ProbeResult,
error) {
+ if timeout <= 0 {
+ timeout = ProbeTimeout
+ }
+
+ server, err := openUpstream(&resolvedServer{
+ Name: rendered.Name,
+ Transport: rendered.Transport,
+ Command: rendered.Command,
+ Args: rendered.Args,
+ Env: rendered.Env,
+ Url: rendered.Url,
+ Headers: rendered.Headers,
+ })
+ if err != nil {
+ return nil, err
+ }
+ defer server.Close()
+
+ replies := newReplyBox()
+ if err := server.Start(replies.accept); err != nil {
+ return nil, err
+ }
+
+ deadline := time.Now().Add(timeout)
+ initialize, err := ask(server, replies, 1, "initialize", map[string]any{
+ "protocolVersion": "2025-06-18",
+ "capabilities": map[string]any{},
+ "clientInfo": map[string]any{"name": "casbin-gateway",
"version": "1"},
+ }, time.Until(deadline))
+ if err != nil {
+ return nil, err
+ }
+
+ // A server is entitled to ignore everything until it has been told the
+ // handshake is done, so this notification is not optional.
+ if err := send(server, map[string]any{"jsonrpc": "2.0", "method":
"notifications/initialized"}); err != nil {
+ return nil, err
+ }
+
+ listed, err := ask(server, replies, 2, "tools/list", map[string]any{},
time.Until(deadline))
+ if err != nil {
+ return nil, err
+ }
+
+ result := &ProbeResult{Tools: []ProbeTool{}}
+ var info struct {
+ ServerInfo struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ } `json:"serverInfo"`
+ }
+ if json.Unmarshal(initialize, &info) == nil {
+ result.ServerName = info.ServerInfo.Name
+ result.ServerVersion = info.ServerInfo.Version
+ }
+
+ var tools struct {
+ Tools []ProbeTool `json:"tools"`
+ }
+ if err := json.Unmarshal(listed, &tools); err != nil {
+ return nil, fmt.Errorf("the server's tool list could not be
read: %w", err)
+ }
+ result.Tools = append(result.Tools, tools.Tools...)
+ return result, nil
+}
+
+// ask sends one request and waits for the reply carrying its id.
+func ask(server upstream, replies *replyBox, id int, method string, params
any, wait time.Duration) (json.RawMessage, error) {
+ waiting := replies.expect(id)
+ if err := send(server, map[string]any{
+ "jsonrpc": "2.0", "id": id, "method": method, "params": params,
+ }); err != nil {
+ return nil, err
+ }
+
+ if wait <= 0 {
+ return nil, fmt.Errorf("%s timed out", method)
+ }
+ select {
+ case reply := <-waiting:
+ if reply.Error != nil {
+ return nil, fmt.Errorf("%s failed: %s", method,
reply.Error.Message)
+ }
+ return reply.Result, nil
+ case <-server.Ended():
+ // The server is gone, so no reply is coming. What it printed
on the way
+ // out is the actual reason, and waiting out the timeout would
only hide
+ // it behind a slower, less useful message.
+ if said := server.Diagnostics(); said != "" {
+ return nil, fmt.Errorf("the server stopped without
answering %s: %s", method, said)
+ }
+ return nil, fmt.Errorf("the server stopped without answering
%s", method)
+ case <-time.After(wait):
+ return nil, fmt.Errorf("%s timed out after %s", method,
wait.Round(time.Second))
+ }
+}
+
+func send(server upstream, message map[string]any) error {
+ encoded, err := json.Marshal(message)
+ if err != nil {
+ return err
+ }
+ return server.Send(encoded)
+}
+
+type probeReply struct {
+ Result json.RawMessage
+ Error *struct {
+ Message string `json:"message"`
+ }
+}
+
+// replyBox matches the server's answers to the questions this probe asked. A
+// server may also send notifications and logs, which carry no id and are
+// dropped here.
+type replyBox struct {
+ mutex sync.Mutex
+ waiting map[int]chan probeReply
+}
+
+func newReplyBox() *replyBox {
+ return &replyBox{waiting: map[int]chan probeReply{}}
+}
+
+func (b *replyBox) expect(id int) chan probeReply {
+ channel := make(chan probeReply, 1)
+ b.mutex.Lock()
+ b.waiting[id] = channel
+ b.mutex.Unlock()
+ return channel
+}
+
+func (b *replyBox) accept(line []byte) error {
+ var message struct {
+ Id *int `json:"id"`
+ Result json.RawMessage `json:"result"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if json.Unmarshal(line, &message) != nil || message.Id == nil {
+ return nil
+ }
+
+ b.mutex.Lock()
+ channel, found := b.waiting[*message.Id]
+ delete(b.waiting, *message.Id)
+ b.mutex.Unlock()
+ if found {
+ channel <- probeReply{Result: message.Result, Error:
message.Error}
+ }
+ return nil
+}
diff --git a/mcpproxy/upstream.go b/mcpproxy/upstream.go
index 9eecb55..26a729f 100644
--- a/mcpproxy/upstream.go
+++ b/mcpproxy/upstream.go
@@ -18,8 +18,10 @@ import (
"bufio"
"bytes"
"fmt"
+ "io"
"os"
"os/exec"
+ "strings"
"sync"
)
@@ -33,6 +35,13 @@ type upstream interface {
Start(emit func([]byte) error) error
Send(line []byte) error
Close() error
+ // Ended is closed when the server has gone, so a caller waiting for a
reply
+ // can stop waiting instead of sitting out its whole timeout. A
transport
+ // that has nothing to end returns nil, which blocks forever, which is
right.
+ Ended() <-chan struct{}
+ // Diagnostics is whatever the server said on its way out, for an error
+ // message that names the cause rather than only the symptom.
+ Diagnostics() string
}
func openUpstream(resolved *resolvedServer) (upstream, error) {
@@ -57,14 +66,29 @@ type stdioUpstream struct {
Close() error
}
writing sync.Mutex
+ ended chan struct{}
+ stderr *tailWriter
+}
+
+func (u *stdioUpstream) Ended() <-chan struct{} { return u.ended }
+
+func (u *stdioUpstream) Diagnostics() string {
+ if u.stderr == nil {
+ return ""
+ }
+ return u.stderr.tail()
}
func (u *stdioUpstream) Start(emit func([]byte) error) error {
+ u.ended = make(chan struct{})
+ u.stderr = &tailWriter{}
+
command := exec.Command(u.resolved.Command, u.resolved.Args...)
command.Env = append(os.Environ(), u.resolved.envPairs()...)
// The child's own diagnostics belong on this process's stderr, where
the
- // agent shows them, rather than mixed into the JSON-RPC stream.
- command.Stderr = os.Stderr
+ // agent shows them, rather than mixed into the JSON-RPC stream. A copy
is
+ // kept so a failure can be reported with what the server actually said.
+ command.Stderr = io.MultiWriter(os.Stderr, u.stderr)
stdin, err := command.StdinPipe()
if err != nil {
@@ -80,6 +104,9 @@ func (u *stdioUpstream) Start(emit func([]byte) error) error
{
u.command, u.stdin = command, stdin
go func() {
+ // Closing this is what tells a caller the server has gone:
stdout ends
+ // when the process does, whether it exited or was killed.
+ defer close(u.ended)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), maxLineBytes)
for scanner.Scan() {
@@ -113,3 +140,32 @@ func (u *stdioUpstream) Close() error {
}
return u.command.Wait()
}
+
+// tailWriter keeps the last few lines written through it. A server that fails
+// usually says why on its way out, and that is the one thing worth quoting
back.
+type tailWriter struct {
+ mutex sync.Mutex
+ lines []string
+}
+
+const tailLines = 5
+
+func (w *tailWriter) Write(p []byte) (int, error) {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+ for _, line := range strings.Split(string(p), "\n") {
+ if line = strings.TrimSpace(line); line != "" {
+ w.lines = append(w.lines, line)
+ }
+ }
+ if len(w.lines) > tailLines {
+ w.lines = w.lines[len(w.lines)-tailLines:]
+ }
+ return len(p), nil
+}
+
+func (w *tailWriter) tail() string {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+ return strings.Join(w.lines, "; ")
+}
diff --git a/object/connection.go b/object/connection.go
index f657dde..408c156 100644
--- a/object/connection.go
+++ b/object/connection.go
@@ -20,6 +20,7 @@ import (
"sort"
"github.com/apache/casbin-gateway/connector"
+ "github.com/apache/casbin-gateway/mcpproxy"
"github.com/apache/casbin-gateway/util"
"github.com/xorm-io/core"
)
@@ -52,6 +53,16 @@ type Connection struct {
// written again rather than failing silently in each agent.
Endpoint string `xorm:"varchar(255)" json:"endpoint"`
Executable string `xorm:"varchar(500)" json:"executable"`
+
+ // What the server said about itself the last time it was tested. Tools
is
+ // what turns one switch for the whole connection into one per tool, so
a
+ // connection nobody has tested is governed more coarsely than one that
has.
+ ServerName string `xorm:"varchar(200)" json:"serverName"`
+ Tools []mcpproxy.ProbeTool `xorm:"mediumtext json" json:"tools"`
+ ProbedTime string `xorm:"varchar(100)" json:"probedTime"`
+ // ProbeError is why the last test failed, empty when it worked. It is
kept
+ // so the page can say what is wrong without testing again.
+ ProbeError string `xorm:"varchar(1000)" json:"probeError"`
}
func (connection *Connection) GetId() string {
diff --git a/object/connection_install.go b/object/connection_install.go
index d181366..a6a8f1a 100644
--- a/object/connection_install.go
+++ b/object/connection_install.go
@@ -22,6 +22,7 @@ import (
"github.com/apache/casbin-gateway/agentpatch"
"github.com/apache/casbin-gateway/connector"
"github.com/apache/casbin-gateway/mcpproxy"
+ "github.com/apache/casbin-gateway/util"
)
// InstallConnection writes one connection's MCP server into each agent in
@@ -260,3 +261,48 @@ func EnsureConnectionsCurrent() error {
}
return failed
}
+
+// TestConnection starts one connection's server, asks what it offers, and
+// remembers the answer. It is the only way to find out whether a credential
+// actually works before an agent tries to use it, and the tool list it brings
+// back is what the permission switches are built from.
+func TestConnection(owner string, name string) (*mcpproxy.ProbeResult, error) {
+ connection, err := GetConnection(owner, name)
+ if err != nil {
+ return nil, err
+ }
+ if connection == nil {
+ return nil, fmt.Errorf("%q is not connected", name)
+ }
+
+ // ResolveConnection renews a grant that is about to expire, so a test
says
+ // whether the connection works now rather than whether it worked once.
+ rendered, err := ResolveConnection(owner, name)
+ if err != nil {
+ return nil, recordProbe(connection, nil, err)
+ }
+
+ result, err := mcpproxy.Probe(*rendered, mcpproxy.ProbeTimeout)
+ if err != nil {
+ return nil, recordProbe(connection, nil, err)
+ }
+ return result, recordProbe(connection, result, nil)
+}
+
+// recordProbe stores what the last test found, failure included, and returns
+// the failure so the caller reports one error rather than two.
+func recordProbe(connection *Connection, result *mcpproxy.ProbeResult, failure
error) error {
+ connection.ProbedTime = util.GetCurrentTime()
+ if failure != nil {
+ connection.ProbeError = failure.Error()
+ } else {
+ connection.ProbeError = ""
+ connection.ServerName = result.ServerName
+ connection.Tools = result.Tools
+ }
+
+ if err := saveWithoutRendering(connection); err != nil && failure ==
nil {
+ return err
+ }
+ return failure
+}
diff --git a/routers/router.go b/routers/router.go
index 6503bc4..b696ac3 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -115,6 +115,7 @@ func initAPI() {
beego.Router("/api/resolve-connection", &controllers.ApiController{},
"POST:ResolveConnection")
beego.Router("/api/get-connector-redirect-uri",
&controllers.ApiController{}, "GET:GetConnectorRedirectUri")
beego.Router("/api/start-connector-auth", &controllers.ApiController{},
"POST:StartConnectorAuth")
+ beego.Router("/api/test-connection", &controllers.ApiController{},
"POST:TestConnection")
// Opened by the vendor's redirect rather than by the UI, so it is
reached
// without a session: what proves the caller is the unguessable state it
// carries, which is the whole point of that parameter.
diff --git a/web/src/backend/ConnectorBackend.ts
b/web/src/backend/ConnectorBackend.ts
index fbe1af0..9b3dcfe 100644
--- a/web/src/backend/ConnectorBackend.ts
+++ b/web/src/backend/ConnectorBackend.ts
@@ -13,7 +13,7 @@
// limitations under the License.
import {query, request} from "@/backend/request";
-import type {AgentConfigPlanItem, ConnectRequest, ConnectorCatalog} from
"@/types";
+import type {AgentConfigPlanItem, ConnectRequest, ConnectorCatalog,
ConnectorProbeResult} from "@/types";
/** The catalog, what is connected here, and the agents it can be written
into. */
export function getConnectors(owner: string) {
@@ -57,3 +57,11 @@ export function startConnectorAuth(owner: string, name:
string, credentials: Rec
credentials: credentials,
});
}
+
+/**
+ * Starts this connection's server and asks what it offers. Slow the first time
+ * a server is installed from npm, so callers should show that it is working.
+ */
+export function testConnection(owner: string, name: string) {
+ return request<ConnectorProbeResult>("/api/test-connection", "POST", {owner:
owner, name: name});
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index df3804f..bb533c6 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -817,7 +817,8 @@
"Callback URL hint": "Register this as the application's callback URL,
exactly as shown.",
"Connect": "Connect",
"Connected": "Connected",
- "Connected in {count}": "In {count} agents",
+ "Connected in_one": "In 1 agent",
+ "Connected in_other": "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.",
@@ -848,10 +849,18 @@
"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.",
+ "Run test": "Run test",
"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",
+ "Test": "Test",
+ "Test failed": "Test failed",
+ "Test hint": "Starts the server and asks what tools it offers. The first
run installs it, which is slow.",
+ "Test result": "{server} answered, offering {count} tools.",
+ "Testing": "Testing...",
"The agent can read and write this directory and everything under it.":
"The agent can read and write this directory and everything under it.",
+ "Tool count_one": "1 tool",
+ "Tool count_other": "{{count}} tools",
"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.",
"Waiting for approval": "Waiting for approval...",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 6180c66..5aa565c 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -889,7 +889,7 @@
"Callback URL hint": "把这个地址原样填进应用的回调地址,多一个字符都不行。",
"Connect": "连接",
"Connected": "已连接",
- "Connected in {count}": "已装入{count}个Agent",
+ "Connected in_other": "已装入{{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,再把允许它读取的页面共享给它。",
@@ -920,10 +920,17 @@
"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直接在你的项目上下文里工作。",
+ "Run test": "测试",
"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": "网盘存储",
+ "Test": "测试连接",
+ "Test failed": "测试失败",
+ "Test hint": "启动这个服务并问它提供哪些工具。首次运行要先安装,会比较慢。",
+ "Test result": "{server}已响应,提供{count}个工具。",
+ "Testing": "测试中...",
"The agent can read and write this directory and everything under it.":
"Agent只能读写这个目录及其子目录。",
+ "Tool count_other": "{{count}}个工具",
"Unverified": "未验证",
"Up-to-date documentation and code samples for third-party libraries, so
the model stops writing API calls from memory.":
"为Agent提供第三方库的最新文档和代码示例,避免模型凭记忆写出过时的API调用。",
"Waiting for approval": "等待授权完成...",
diff --git a/web/src/pages/ConnectionsPage.tsx
b/web/src/pages/ConnectionsPage.tsx
index cafe7d9..08b9a73 100644
--- a/web/src/pages/ConnectionsPage.tsx
+++ b/web/src/pages/ConnectionsPage.tsx
@@ -27,7 +27,7 @@ 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";
+import type {Account, ConnectorCatalog, ConnectorEntry, ConnectorTarget,
ConnectorTool} from "@/types";
const CREDENTIAL_MASK = "***";
@@ -175,10 +175,16 @@ function ConnectorCard({entry, onConnect}: {entry:
ConnectorEntry; onConnect: ()
{entry.connected ? (
<Badge variant="secondary">
{entry.agents.length > 0
- ? i18next.t("connector:Connected in
{count}").replace("{count}", `${entry.agents.length}`)
+ ? i18next.t("connector:Connected in", {count:
entry.agents.length})
: i18next.t("connector:Connected")}
</Badge>
) : null}
+ {entry.tools && entry.tools.length > 0 ? (
+ <Badge variant="outline">
+ {i18next.t("connector:Tool count", {count:
entry.tools.length})}
+ </Badge>
+ ) : null}
+ {entry.probeError ? <Badge
variant="destructive">{i18next.t("connector:Test failed")}</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>
@@ -291,6 +297,28 @@ function ConnectDialog({
const missing =
fields.some(field => field.required && (credentials[field.key] ??
"").trim() === "") || (isOauth && !authorized);
+ const [testing, setTesting] = React.useState(false);
+ const [probe, setProbe] = React.useState<{tools?: ConnectorTool[]; server?:
string; error?: string}>({
+ tools: entry.tools,
+ server: entry.serverName,
+ error: entry.probeError,
+ });
+
+ const test = () => {
+ setTesting(true);
+ setProbe({});
+ ConnectorBackend.testConnection(account.name, entry.id)
+ .then(response => {
+ if (response.status === "ok" && response.data) {
+ setProbe({tools: response.data.tools, server:
response.data.serverName});
+ } else {
+ setProbe({error: response.msg ?? ""});
+ }
+ })
+ .catch(error => setProbe({error: error.message}))
+ .finally(() => setTesting(false));
+ };
+
const submit = () => {
setSubmitting(true);
ConnectorBackend.connect({owner: account.name, name: entry.id,
credentials: credentials, agents: agents})
@@ -404,6 +432,37 @@ function ConnectDialog({
</Field>
) : null}
+ {entry.connected ? (
+ <Field label={i18next.t("connector:Test")}
hint={i18next.t("connector:Test hint")}>
+ <div className="flex items-start gap-3">
+ <Button type="button" variant="outline" onClick={test}
disabled={testing}>
+ {testing ? i18next.t("connector:Testing") :
i18next.t("connector:Run test")}
+ </Button>
+ <div className="min-w-0 flex-1 pt-1.5 text-sm">
+ {probe.error ? (
+ <span className="text-destructive
break-words">{probe.error}</span>
+ ) : probe.tools ? (
+ <span className="text-muted-foreground">
+ {i18next
+ .t("connector:Test result")
+ .replace("{server}", probe.server || entry.server.name)
+ .replace("{count}", `${probe.tools.length}`)}
+ </span>
+ ) : null}
+ </div>
+ </div>
+ {probe.tools && probe.tools.length > 0 ? (
+ <div className="flex flex-wrap gap-1.5 pt-1">
+ {probe.tools.map(tool => (
+ <code key={tool.name} className="bg-muted rounded px-1.5
py-0.5 text-xs" title={tool.description}>
+ {tool.name}
+ </code>
+ ))}
+ </div>
+ ) : null}
+ </Field>
+ ) : null}
+
<Field
label={i18next.t("connector:Install into")}
hint={i18next.t("connector:Install into hint")}
diff --git a/web/src/types.ts b/web/src/types.ts
index dc63a89..2c66c55 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1540,6 +1540,23 @@ export interface ConnectorEntry {
agents: string[];
/** An oauth2 connector that has a grant, rather than only a client app. */
authorized: boolean;
+ /** What the last test found. Absent until the connection has been tested. */
+ serverName?: string;
+ tools?: ConnectorTool[];
+ probedTime?: string;
+ probeError?: string;
+}
+
+/** One tool a connection's server offers, as its last test reported it. */
+export interface ConnectorTool {
+ name: string;
+ description?: string;
+}
+
+export interface ConnectorProbeResult {
+ serverName: string;
+ serverVersion: string;
+ tools: ConnectorTool[];
}
/** An agent on this machine a connection can be installed into. */
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]