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 e174087  feat(agent): add MCP servers to one or many agents from 
Gateway
e174087 is described below

commit e1740872c2bdfece80dbde52a64fb48610cb5f8c
Author: Yang Luo <[email protected]>
AuthorDate: Fri Aug 21 18:45:48 2026 +0800

    feat(agent): add MCP servers to one or many agents from Gateway
---
 README.md                                          |   2 +-
 README_zh.md                                       |   2 +-
 agentconfig/mcp_add.go                             | 161 +++++++++++
 controllers/agent_config.go                        |  21 ++
 routers/router.go                                  |   1 +
 web/src/backend/AgentConfigBackend.ts              |  19 ++
 web/src/components/agent-config/action-badge.tsx   |  31 ++
 web/src/components/agent-config/add-mcp-dialog.tsx | 312 +++++++++++++++++++++
 web/src/components/agent-config/copy-dialog.tsx    |  64 +----
 web/src/components/agent-config/target-picker.tsx  |  75 +++++
 web/src/locales/en/data.json                       |  17 ++
 web/src/locales/zh/data.json                       |  17 ++
 web/src/pages/AgentConfigsPage.tsx                 |  30 +-
 web/src/types.ts                                   |   3 +
 14 files changed, 696 insertions(+), 59 deletions(-)

diff --git a/README.md b/README.md
index 5af2da2..66310e3 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ That is the whole installation. Gateway keeps its data in a 
SQLite file inside i
 | Page | What you get | What it needs |
 | --- | --- | --- |
 | **Agents** | Every AI coding agent installed on this machine — Claude Code, 
Codex CLI, Cursor and more. Click **Patch** on one and its activity streams 
into the page live. | Nothing |
-| **Skills & MCP** | Every skill and MCP server of every agent in one table. 
Open one, delete it, or copy it into another agent. | Nothing |
+| **Skills & MCP** | Every skill and MCP server of every agent in one table. 
Add an MCP server to one agent or to several at once, open one, delete it, or 
copy it into another agent. | Nothing |
 | **Channels** | One endpoint in front of your model vendors. Gateway holds 
the API key, so the agents never have it. | A vendor API key |
 | **LLM Records** | Every request an agent relayed: the full system prompt, 
every message and tool call, the schema of every tool the model was offered, 
plus tokens and cost. | A channel, and `llmRecordMode` — see [Recording 
prompts](#recording-prompts) |
 | **Advanced → Sites** | The reverse-proxy WAF: per-site routing, rules, 
certificates and analytics. | Turning the proxy on — see [Turning the WAF proxy 
on](#turning-the-waf-proxy-on) |
diff --git a/README_zh.md b/README_zh.md
index 0020088..7417f28 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -64,7 +64,7 @@ irm 
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/insta
 | 页面 | 你能得到什么 | 需要什么 |
 | --- | --- | --- |
 | **Agents** | 本机安装的每一个 AI 编程 Agent —— Claude Code、Codex CLI、Cursor 等等。点其中一个的 
**Patch**,它的活动就会实时流进页面。 | 无 |
-| **Skills & MCP** | 所有 Agent 的所有技能和 MCP 服务器汇总在一张表里。可以打开、删除,或复制到另一个 Agent。 | 无 
|
+| **Skills & MCP** | 所有 Agent 的所有技能和 MCP 服务器汇总在一张表里。可以给一个或多个 Agent 添加 MCP 
服务器,也可以打开、删除,或复制到另一个 Agent。 | 无 |
 | **Channels** | 挡在模型厂商前面的统一入口。API Key 由 Gateway 持有,Agent 拿不到。 | 一个厂商的 API Key 
|
 | **LLM Records** | Agent 转发的每一次请求:完整的 system prompt、每一条消息和工具调用、模型可用的每个工具的 
schema,以及 token 数和费用。 | 一个 Channel,以及 `llmRecordMode` —— 见[记录提示词](#记录提示词) |
 | **Advanced → Sites** | 反向代理 WAF:按站点的路由、规则、证书和统计。 | 打开代理 —— 见[开启 WAF 
反向代理](#开启-waf-反向代理) |
diff --git a/agentconfig/mcp_add.go b/agentconfig/mcp_add.go
new file mode 100644
index 0000000..f965a9d
--- /dev/null
+++ b/agentconfig/mcp_add.go
@@ -0,0 +1,161 @@
+// 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 agentconfig
+
+import (
+       "errors"
+       "fmt"
+       "strings"
+)
+
+// How a new server is reached. Anything else an agent supports can still be
+// copied from another agent, which is what the migration path is for.
+const (
+       TransportStdio = "stdio"
+       TransportHttp  = "http"
+)
+
+// McpRequest is one new MCP server, described the way every host spells it, to
+// be written into each agent listed in To.
+type McpRequest struct {
+       Owner     string            `json:"owner"`
+       To        []string          `json:"to"`
+       Name      string            `json:"name"`
+       Transport string            `json:"transport"`
+       Command   string            `json:"command"`
+       Args      []string          `json:"args"`
+       Env       map[string]string `json:"env"`
+       Url       string            `json:"url"`
+       Headers   map[string]string `json:"headers"`
+       Overwrite bool              `json:"overwrite"`
+}
+
+// AddMcp writes one server into every target agent's own configuration file.
+// Like Copy, one target's failure is recorded against that target and the rest
+// still run, so a half-finished round is reported agent by agent.
+func AddMcp(request McpRequest) ([]*PlanItem, error) {
+       name := strings.TrimSpace(request.Name)
+       if name == ManagedEntryName {
+               return nil, errors.New("this name belongs to Gateway's agent 
monitoring")
+       }
+       entry, err := request.entry(name)
+       if err != nil {
+               return nil, err
+       }
+       if len(request.To) == 0 {
+               return nil, errors.New("no agent was picked to add this server 
to")
+       }
+
+       planned := []*PlanItem{}
+       for _, agentId := range request.To {
+               item := &PlanItem{AgentId: agentId, Name: name}
+               planned = append(planned, item)
+
+               existing, err := targetNames(agentId, request.Owner, KindMcp)
+               switch {
+               case err != nil:
+                       item.Action, item.Reason = ActionSkip, err.Error()
+                       continue
+               case !existing[name]:
+                       item.Action = ActionCreate
+               case request.Overwrite:
+                       item.Action = ActionOverwrite
+               default:
+                       item.Action, item.Reason = ActionSkip, "already exists"
+                       continue
+               }
+
+               path, err := writeMcp(request.Owner, agentId, name, entry)
+               if err != nil {
+                       item.Action, item.Reason = ActionFailed, err.Error()
+                       continue
+               }
+               item.Path = path
+       }
+       return planned, nil
+}
+
+// entry is the server as it goes into the file. A stdio server is left without
+// a type field, which every host reads as "spawn the command", and is also 
what
+// the TOML half of Codex expects.
+func (request McpRequest) entry(name string) (map[string]any, error) {
+       if err := checkName(name); err != nil {
+               return nil, err
+       }
+
+       entry := map[string]any{}
+       switch request.Transport {
+       case TransportHttp:
+               url := strings.TrimSpace(request.Url)
+               if url == "" {
+                       return nil, errors.New("an HTTP MCP server needs a URL")
+               }
+               entry["type"] = TransportHttp
+               entry["url"] = url
+               if headers := pairs(request.Headers); len(headers) > 0 {
+                       entry["headers"] = headers
+               }
+       case TransportStdio, "":
+               command := strings.TrimSpace(request.Command)
+               if command == "" {
+                       return nil, errors.New("a stdio MCP server needs a 
command to run")
+               }
+               entry["command"] = command
+               if args := list(request.Args); len(args) > 0 {
+                       entry["args"] = args
+               }
+               if env := pairs(request.Env); len(env) > 0 {
+                       entry["env"] = env
+               }
+       default:
+               return nil, fmt.Errorf("unknown transport: %q", 
request.Transport)
+       }
+       return entry, nil
+}
+
+func writeMcp(owner string, agentId string, name string, entry map[string]any) 
(string, error) {
+       found, home, err := resolve(agentId, owner, KindMcp)
+       if err != nil {
+               return "", err
+       }
+       if found.mcp.readOnly != "" {
+               return "", errors.New(found.mcp.readOnly)
+       }
+
+       file := found.mcp.path(home)
+       return file, found.mcp.store.write(file, name, entry)
+}
+
+// The two stores take the same shapes a parsed JSON file has, so the request's
+// own types are converted rather than passed through.
+func list(values []string) []any {
+       result := make([]any, 0, len(values))
+       for _, value := range values {
+               if value != "" {
+                       result = append(result, value)
+               }
+       }
+       return result
+}
+
+func pairs(values map[string]string) map[string]any {
+       result := make(map[string]any, len(values))
+       for key, value := range values {
+               if key = strings.TrimSpace(key); key != "" {
+                       result[key] = value
+               }
+       }
+       return result
+}
diff --git a/controllers/agent_config.go b/controllers/agent_config.go
index 2686c2f..58d1757 100644
--- a/controllers/agent_config.go
+++ b/controllers/agent_config.go
@@ -157,6 +157,27 @@ func (c *ApiController) DeleteAgentConfigItem() {
        c.ResponseOk(form.Name)
 }
 
+// AddAgentConfigMcp writes one new MCP server into the agents it names, so a
+// server can be set up from Gateway instead of by hand in each agent's file.
+func (c *ApiController) AddAgentConfigMcp() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var request agentconfig.McpRequest
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil 
{
+               c.ResponseError(err.Error())
+               return
+       }
+
+       result, err := agentconfig.AddMcp(request)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(result)
+}
+
 // PlanAgentConfigCopy reports what CopyAgentConfig would do, so the operator
 // sees which targets already have an item before anything is written.
 func (c *ApiController) PlanAgentConfigCopy() {
diff --git a/routers/router.go b/routers/router.go
index 31167da..843603a 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -73,6 +73,7 @@ func initAPI() {
        beego.Router("/api/get-agent-configs", &controllers.ApiController{}, 
"GET:GetAgentConfigs")
        beego.Router("/api/get-agent-config-item", 
&controllers.ApiController{}, "GET:GetAgentConfigItem")
        beego.Router("/api/delete-agent-config-item", 
&controllers.ApiController{}, "POST:DeleteAgentConfigItem")
+       beego.Router("/api/add-agent-config-mcp", &controllers.ApiController{}, 
"POST:AddAgentConfigMcp")
        beego.Router("/api/plan-agent-config-copy", 
&controllers.ApiController{}, "POST:PlanAgentConfigCopy")
        beego.Router("/api/plan-agent-provider", &controllers.ApiController{}, 
"POST:PlanAgentProvider")
        beego.Router("/api/apply-agent-provider", &controllers.ApiController{}, 
"POST:ApplyAgentProvider")
diff --git a/web/src/backend/AgentConfigBackend.ts 
b/web/src/backend/AgentConfigBackend.ts
index 770cbee..340ac75 100644
--- a/web/src/backend/AgentConfigBackend.ts
+++ b/web/src/backend/AgentConfigBackend.ts
@@ -18,6 +18,7 @@ import type {
   AgentConfigInventory,
   AgentConfigKind,
   AgentConfigPlanItem,
+  McpTransport,
 } from "@/types";
 
 export interface CopyRequest {
@@ -29,6 +30,24 @@ export interface CopyRequest {
   overwrite: boolean;
 }
 
+/** One new MCP server, for every agent in `to`. */
+export interface McpRequest {
+  owner: string;
+  to: string[];
+  name: string;
+  transport: McpTransport;
+  command?: string;
+  args?: string[];
+  env?: Record<string, string>;
+  url?: string;
+  headers?: Record<string, string>;
+  overwrite: boolean;
+}
+
+export function addAgentConfigMcp(body: McpRequest) {
+  return request<AgentConfigPlanItem[]>("/api/add-agent-config-mcp", "POST", 
body);
+}
+
 export function getAgentConfigs(forceRefresh = false) {
   return request<AgentConfigInventory[]>(`/api/get-agent-configs${forceRefresh 
? "?refresh=true" : ""}`);
 }
diff --git a/web/src/components/agent-config/action-badge.tsx 
b/web/src/components/agent-config/action-badge.tsx
new file mode 100644
index 0000000..8029a3a
--- /dev/null
+++ b/web/src/components/agent-config/action-badge.tsx
@@ -0,0 +1,31 @@
+// 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 i18next from "i18next";
+
+import {Badge, type BadgeVariant} from "@/components/ui/badge";
+import type {AgentConfigAction} from "@/types";
+
+const actionStyles: Record<AgentConfigAction, {variant: BadgeVariant; label: 
string}> = {
+  create: {variant: "success", label: "agentConfig:New"},
+  overwrite: {variant: "warning", label: "agentConfig:Replaced"},
+  skip: {variant: "muted", label: "agentConfig:Skipped"},
+  failed: {variant: "danger", label: "agentConfig:Failed"},
+};
+
+/** What a copy or an add did to one item at one agent. */
+export function ActionBadge({action}: {action: AgentConfigAction}) {
+  const style = actionStyles[action] ?? actionStyles.skip;
+  return <Badge variant={style.variant}>{i18next.t(style.label)}</Badge>;
+}
diff --git a/web/src/components/agent-config/add-mcp-dialog.tsx 
b/web/src/components/agent-config/add-mcp-dialog.tsx
new file mode 100644
index 0000000..692fefa
--- /dev/null
+++ b/web/src/components/agent-config/add-mcp-dialog.tsx
@@ -0,0 +1,312 @@
+// 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 i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import * as Setting from "@/Setting";
+import {ActionBadge} from "@/components/agent-config/action-badge";
+import {TargetPicker} from "@/components/agent-config/target-picker";
+import {Field} from "@/components/shared/form-dialog";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {MessageAlert} from "@/components/ui/alert";
+import {Button} from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {Input} from "@/components/ui/input";
+import {Switch} from "@/components/ui/switch";
+import {TagsInput} from "@/components/ui/tags-input";
+import {Textarea} from "@/components/ui/textarea";
+import {counted} from "@/lib/agent-configs";
+import type {AgentConfigInventory, AgentConfigPlanItem, McpTransport} from 
"@/types";
+
+/** "KEY=VALUE" per line, which is how both env and headers are written here. 
*/
+function parsePairs(text: string) {
+  const pairs: Record<string, string> = {};
+  text.split("\n").forEach(line => {
+    const separator = line.indexOf("=");
+    if (separator <= 0) {
+      return;
+    }
+    const key = line.slice(0, separator).trim();
+    if (key !== "") {
+      pairs[key] = line.slice(separator + 1).trim();
+    }
+  });
+  return pairs;
+}
+
+/**
+ * Setting up one MCP server from Gateway: it is written into every agent 
picked
+ * here, in that agent's own format and file, so the agents that should share a
+ * server are configured once instead of one file at a time.
+ */
+export function AddMcpDialog({
+  open,
+  onOpenChange,
+  inventories,
+  source,
+  onDone,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  inventories: AgentConfigInventory[];
+  source: AgentConfigInventory;
+  onDone: () => void;
+}) {
+  const [name, setName] = React.useState("");
+  const [transport, setTransport] = React.useState<McpTransport>("stdio");
+  const [command, setCommand] = React.useState("");
+  const [args, setArgs] = React.useState<string[]>([]);
+  const [env, setEnv] = React.useState("");
+  const [url, setUrl] = React.useState("");
+  const [headers, setHeaders] = React.useState("");
+  const [targets, setTargets] = React.useState<string[]>([]);
+  const [overwrite, setOverwrite] = React.useState(false);
+  const [result, setResult] = React.useState<AgentConfigPlanItem[] | 
null>(null);
+  const [busy, setBusy] = React.useState(false);
+  const [error, setError] = React.useState("");
+
+  // The server is written into one account's home directory, so the agents
+  // offered are the ones reading that same home - which is also what makes one
+  // owner enough for the whole request.
+  const candidates = inventories.filter(inventory => inventory.home === 
source.home);
+  const defaultTarget = source.mcpWritable ? source.agentId : "";
+
+  React.useEffect(() => {
+    if (open) {
+      setName("");
+      setTransport("stdio");
+      setCommand("");
+      setArgs([]);
+      setEnv("");
+      setUrl("");
+      setHeaders("");
+      setTargets(defaultTarget === "" ? [] : [defaultTarget]);
+      setOverwrite(false);
+      setResult(null);
+      setError("");
+    }
+  }, [open, defaultTarget]);
+
+  const toggleTarget = (agentId: string) => {
+    setTargets(previous =>
+      previous.includes(agentId) ? previous.filter(item => item !== agentId) : 
[...previous, agentId],
+    );
+  };
+
+  const ready =
+    name.trim() !== "" &&
+    targets.length > 0 &&
+    (transport === "http" ? url.trim() !== "" : command.trim() !== "");
+
+  const add = () => {
+    setBusy(true);
+    setError("");
+    AgentConfigBackend.addAgentConfigMcp({
+      owner: source.owner,
+      to: targets,
+      name: name.trim(),
+      transport: transport,
+      command: transport === "stdio" ? command.trim() : undefined,
+      args: transport === "stdio" ? args : undefined,
+      env: transport === "stdio" ? parsePairs(env) : undefined,
+      url: transport === "http" ? url.trim() : undefined,
+      headers: transport === "http" ? parsePairs(headers) : undefined,
+      overwrite: overwrite,
+    })
+      .then(res => {
+        if (res.status !== "ok") {
+          setError(res.msg || i18next.t("agentConfig:Failed to add this MCP 
server"));
+          return;
+        }
+
+        const written = res.data ?? [];
+        const done = written.filter(item => item.action === "create" || 
item.action === "overwrite").length;
+        const failed = written.filter(item => item.action === "failed").length;
+        setResult(written);
+        Setting.showMessage(
+          failed > 0 || done === 0 ? "error" : "success",
+          failed > 0
+            ? i18next
+              .t("agentConfig:Added to {done}, {failed} failed")
+              .replace("{done}", String(done))
+              .replace("{failed}", String(failed))
+            : counted(done, "agentConfig:Added to 1 agent", "agentConfig:Added 
to {done} agents", "{done}"),
+        );
+        onDone();
+      })
+      .catch(err => setError(err.message || String(err)))
+      .then(() => setBusy(false));
+  };
+
+  const nameOf = (agentId: string) =>
+    candidates.find(inventory => inventory.agentId === agentId)?.name ?? 
agentId;
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-h-[85vh] gap-4 sm:max-w-2xl">
+        <DialogHeader>
+          <DialogTitle>{i18next.t("agentConfig:Add MCP server")}</DialogTitle>
+          <DialogDescription>{i18next.t("agentConfig:Add MCP server 
hint")}</DialogDescription>
+        </DialogHeader>
+
+        <div className="flex flex-col gap-4 overflow-y-auto">
+          <Field label={i18next.t("general:Name")} htmlFor="mcp-name" required>
+            <Input
+              id="mcp-name"
+              value={name}
+              placeholder="context7"
+              disabled={result !== null}
+              onChange={event => setName(event.target.value)}
+            />
+          </Field>
+
+          <Field label={i18next.t("agentConfig:Transport")}>
+            <SimpleSelect
+              value={transport}
+              disabled={result !== null}
+              options={[
+                {label: i18next.t("agentConfig:Local command"), value: 
"stdio"},
+                {label: i18next.t("agentConfig:HTTP endpoint"), value: "http"},
+              ]}
+              onChange={value => setTransport(value as McpTransport)}
+            />
+          </Field>
+
+          {transport === "stdio" ? (
+            <>
+              <Field
+                label={i18next.t("agentConfig:Command")}
+                htmlFor="mcp-command"
+                hint={i18next.t("agentConfig:Command hint")}
+                required
+              >
+                <Input
+                  id="mcp-command"
+                  value={command}
+                  placeholder="npx"
+                  disabled={result !== null}
+                  onChange={event => setCommand(event.target.value)}
+                />
+              </Field>
+              <Field label={i18next.t("agentConfig:Arguments")}>
+                <TagsInput
+                  value={args}
+                  placeholder="-y, @upstash/context7-mcp"
+                  disabled={result !== null}
+                  onChange={setArgs}
+                />
+              </Field>
+              <Field
+                label={i18next.t("agentConfig:Environment")}
+                htmlFor="mcp-env"
+                hint={i18next.t("agentConfig:Pairs hint")}
+              >
+                <Textarea
+                  id="mcp-env"
+                  value={env}
+                  rows={2}
+                  placeholder="API_KEY=..."
+                  disabled={result !== null}
+                  onChange={event => setEnv(event.target.value)}
+                />
+              </Field>
+            </>
+          ) : (
+            <>
+              <Field label={i18next.t("agentConfig:URL")} htmlFor="mcp-url" 
required>
+                <Input
+                  id="mcp-url"
+                  value={url}
+                  placeholder="https://mcp.example.com/mcp";
+                  disabled={result !== null}
+                  onChange={event => setUrl(event.target.value)}
+                />
+              </Field>
+              <Field
+                label={i18next.t("agentConfig:Headers")}
+                htmlFor="mcp-headers"
+                hint={i18next.t("agentConfig:Pairs hint")}
+              >
+                <Textarea
+                  id="mcp-headers"
+                  value={headers}
+                  rows={2}
+                  placeholder="Authorization=Bearer ..."
+                  disabled={result !== null}
+                  onChange={event => setHeaders(event.target.value)}
+                />
+              </Field>
+            </>
+          )}
+
+          <Field label={i18next.t("agentConfig:Add to")} 
hint={i18next.t("agentConfig:Add to hint")}>
+            <TargetPicker
+              candidates={candidates}
+              kind="mcp"
+              selected={targets}
+              onToggle={toggleTarget}
+              disabled={result !== null}
+            />
+          </Field>
+
+          {result === null ? (
+            <label className="flex items-center gap-2 text-sm">
+              <Switch checked={overwrite} onCheckedChange={setOverwrite} />
+              <span>{i18next.t("agentConfig:Replace items that already 
exist")}</span>
+            </label>
+          ) : null}
+
+          {error ? <MessageAlert description={error} /> : null}
+
+          {result === null ? null : (
+            <ul className="divide-y rounded-md border">
+              {result.map(item => (
+                <li
+                  key={`${item.agentId}/${item.name}`}
+                  className="flex items-center justify-between gap-3 px-3 
py-1.5 text-sm"
+                >
+                  <span className="truncate">{nameOf(item.agentId)}</span>
+                  <span className="flex shrink-0 items-center gap-2">
+                    {item.reason ? <span className="text-muted-foreground 
text-xs">{item.reason}</span> : null}
+                    <ActionBadge action={item.action} />
+                  </span>
+                </li>
+              ))}
+            </ul>
+          )}
+        </div>
+
+        <DialogFooter>
+          <Button variant="outline" onClick={() => onOpenChange(false)}>
+            {result === null ? i18next.t("general:Cancel") : 
i18next.t("general:Close")}
+          </Button>
+          {result === null ? (
+            <Button onClick={add} disabled={busy || !ready} loading={busy}>
+              {i18next.t("agentConfig:Add MCP server")}
+            </Button>
+          ) : null}
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/web/src/components/agent-config/copy-dialog.tsx 
b/web/src/components/agent-config/copy-dialog.tsx
index 8b52db7..58594f8 100644
--- a/web/src/components/agent-config/copy-dialog.tsx
+++ b/web/src/components/agent-config/copy-dialog.tsx
@@ -18,10 +18,11 @@ import i18next from "i18next";
 
 import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
 import * as Setting from "@/Setting";
-import {AgentIcon} from "@/components/AgentIcon";
+import {ActionBadge} from "@/components/agent-config/action-badge";
+import {TargetPicker} from "@/components/agent-config/target-picker";
 import {Loading} from "@/components/shared/loading";
 import {MessageAlert} from "@/components/ui/alert";
-import {Badge, type BadgeVariant} from "@/components/ui/badge";
+import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {
   Dialog,
@@ -32,27 +33,8 @@ import {
   DialogTitle,
 } from "@/components/ui/dialog";
 import {Switch} from "@/components/ui/switch";
-import {SimpleTooltip} from "@/components/ui/tooltip";
-import {cn} from "@/lib/utils";
-import {blockedReason, counted, inventoryKey} from "@/lib/agent-configs";
-import type {
-  AgentConfigAction,
-  AgentConfigInventory,
-  AgentConfigKind,
-  AgentConfigPlanItem,
-} from "@/types";
-
-const actionStyles: Record<AgentConfigAction, {variant: BadgeVariant; label: 
string}> = {
-  create: {variant: "success", label: "agentConfig:New"},
-  overwrite: {variant: "warning", label: "agentConfig:Replaced"},
-  skip: {variant: "muted", label: "agentConfig:Skipped"},
-  failed: {variant: "danger", label: "agentConfig:Failed"},
-};
-
-function ActionBadge({action}: {action: AgentConfigAction}) {
-  const style = actionStyles[action] ?? actionStyles.skip;
-  return <Badge variant={style.variant}>{i18next.t(style.label)}</Badge>;
-}
+import {counted, inventoryKey} from "@/lib/agent-configs";
+import type {AgentConfigInventory, AgentConfigKind, AgentConfigPlanItem} from 
"@/types";
 
 /**
  * The migration step: pick the agents to copy into, see what would change at
@@ -212,35 +194,13 @@ export function CopyDialog({
             />
           ) : null}
 
-          <div className="flex flex-wrap gap-2">
-            {candidates.map(inventory => {
-              const blocked = blockedReason(inventory, kind);
-              const active = selected.includes(inventory.agentId);
-              const button = (
-                <button
-                  key={inventoryKey(inventory)}
-                  type="button"
-                  disabled={Boolean(blocked) || applied}
-                  onClick={() => toggleTarget(inventory.agentId)}
-                  className={cn(
-                    "flex items-center gap-2 rounded-md border px-3 py-2 
text-sm transition-colors",
-                    active ? "border-primary bg-primary/10" : 
"hover:bg-accent",
-                    (blocked || applied) && "cursor-not-allowed opacity-50",
-                  )}
-                >
-                  <AgentIcon agent={inventory.name} size={16} />
-                  <span>{inventory.name}</span>
-                </button>
-              );
-              return blocked ? (
-                <SimpleTooltip key={inventoryKey(inventory)} title={blocked}>
-                  <span className="inline-flex">{button}</span>
-                </SimpleTooltip>
-              ) : (
-                button
-              );
-            })}
-          </div>
+          <TargetPicker
+            candidates={candidates}
+            kind={kind}
+            selected={selected}
+            onToggle={toggleTarget}
+            disabled={applied}
+          />
 
           {!applied && candidates.length > 0 ? (
             <label className="flex items-center gap-2 text-sm">
diff --git a/web/src/components/agent-config/target-picker.tsx 
b/web/src/components/agent-config/target-picker.tsx
new file mode 100644
index 0000000..ecbaca5
--- /dev/null
+++ b/web/src/components/agent-config/target-picker.tsx
@@ -0,0 +1,75 @@
+// 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 i18next from "i18next";
+
+import {AgentIcon} from "@/components/AgentIcon";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {cn} from "@/lib/utils";
+import {blockedReason, inventoryKey} from "@/lib/agent-configs";
+import type {AgentConfigInventory, AgentConfigKind} from "@/types";
+
+/**
+ * The agents a write can land on. One that cannot take this kind of item is
+ * still listed, disabled and with the reason on it, because "Gateway will not
+ * write that file" is what the reader came to find out.
+ */
+export function TargetPicker({
+  candidates,
+  kind,
+  selected,
+  onToggle,
+  disabled = false,
+}: {
+  candidates: AgentConfigInventory[];
+  kind: AgentConfigKind;
+  selected: string[];
+  onToggle: (agentId: string) => void;
+  disabled?: boolean;
+}) {
+  return (
+    <div className="flex flex-wrap gap-2">
+      {candidates.map(inventory => {
+        const blocked = blockedReason(inventory, kind);
+        const active = selected.includes(inventory.agentId);
+        const button = (
+          <button
+            key={inventoryKey(inventory)}
+            type="button"
+            disabled={Boolean(blocked) || disabled}
+            onClick={() => onToggle(inventory.agentId)}
+            className={cn(
+              "flex items-center gap-2 rounded-md border px-3 py-2 text-sm 
transition-colors",
+              active ? "border-primary bg-primary/10" : "hover:bg-accent",
+              (blocked || disabled) && "cursor-not-allowed opacity-50",
+            )}
+          >
+            <AgentIcon agent={inventory.name} size={16} />
+            <span>{inventory.name}</span>
+            {inventory.installed ? null : (
+              <span className="text-muted-foreground 
text-xs">{i18next.t("agentConfig:Not installed")}</span>
+            )}
+          </button>
+        );
+        return blocked ? (
+          <SimpleTooltip key={inventoryKey(inventory)} title={blocked}>
+            <span className="inline-flex">{button}</span>
+          </SimpleTooltip>
+        ) : (
+          button
+        );
+      })}
+    </div>
+  );
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 0b9c05a..a39319d 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -129,7 +129,17 @@
   },
   "agentConfig": {
     "1 file": "1 file",
+    "Add MCP server": "Add MCP server",
+    "Add MCP server hint": "Gateway writes the server into each agent's own 
configuration file, in that agent's own format. Restart a session that is 
already running to pick it up.",
+    "Add to": "Add to",
+    "Add to hint": "Every agent picked here gets the same server.",
+    "Added to 1 agent": "Added to 1 agent",
+    "Added to {done} agents": "Added to {done} agents",
+    "Added to {done}, {failed} failed": "Added to {done} agents, {failed} 
failed",
+    "Arguments": "Arguments",
     "Bundled files": "Bundled files",
+    "Command": "Command",
+    "Command hint": "The executable the agent spawns, e.g. npx or uvx.",
     "Contents": "Contents",
     "Copied 1 item": "Copied 1 item",
     "Copied {done} items": "Copied {done} items",
@@ -148,7 +158,9 @@
     "Delete this skill?": "Delete this skill?",
     "Deleted": "Deleted",
     "Endpoint": "Endpoint",
+    "Environment": "Environment variables",
     "Failed": "Failed",
+    "Failed to add this MCP server": "Failed to add this MCP server",
     "Failed to copy": "Failed to copy",
     "Failed to delete": "Failed to delete",
     "Failed to plan this copy": "Failed to work out what this copy would do",
@@ -156,6 +168,8 @@
     "Failed to read this item": "Failed to read this item",
     "From": "From",
     "Gateway does not know where this agent keeps these": "Gateway does not 
know where this agent keeps these",
+    "HTTP endpoint": "HTTP endpoint",
+    "Local command": "Local command (stdio)",
     "MCP": "MCP",
     "MCP servers": "MCP servers",
     "Managed by Gateway": "Gateway",
@@ -169,6 +183,7 @@
     "Not installed": "config only",
     "Not installed detail": "Gateway found this configuration on disk but did 
not detect an installation of the agent.",
     "Page description": "The skills and MCP servers each agent on this machine 
keeps in its own configuration files. Compare them side by side, and copy them 
from one agent to another.",
+    "Pairs hint": "One KEY=VALUE per line.",
     "Pick the agents to copy into": "pick the agents to copy into",
     "Read from": "Read from",
     "Replace items that already exist": "Replace items that already exist",
@@ -180,6 +195,8 @@
     "Skills & MCP": "Skills & MCP",
     "Skipped": "Skipped",
     "This file is read-only for Gateway": "Gateway does not write this agent's 
configuration file",
+    "Transport": "Transport",
+    "URL": "URL",
     "View": "View",
     "{files} files": "{files} files"
   },
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 8e8faf2..161fd2a 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -129,7 +129,17 @@
   },
   "agentConfig": {
     "1 file": "1 个文件",
+    "Add MCP server": "添加 MCP 服务器",
+    "Add MCP server hint": "Gateway 会按每个 Agent 
自己的格式,把这个服务器写进它自己的配置文件。已经在运行的会话需要重启才会读到。",
+    "Add to": "添加到",
+    "Add to hint": "这里选中的每个 Agent 都会得到同一个服务器。",
+    "Added to 1 agent": "已添加到 1 个 Agent",
+    "Added to {done} agents": "已添加到 {done} 个 Agent",
+    "Added to {done}, {failed} failed": "已添加到 {done} 个 Agent,{failed} 个失败",
+    "Arguments": "参数",
     "Bundled files": "附带文件",
+    "Command": "命令",
+    "Command hint": "Agent 要启动的可执行文件,例如 npx 或 uvx。",
     "Contents": "内容",
     "Copied 1 item": "已复制 1 项",
     "Copied {done} items": "已复制 {done} 项",
@@ -148,7 +158,9 @@
     "Delete this skill?": "删除这个技能?",
     "Deleted": "已删除",
     "Endpoint": "端点",
+    "Environment": "环境变量",
     "Failed": "失败",
+    "Failed to add this MCP server": "添加 MCP 服务器失败",
     "Failed to copy": "复制失败",
     "Failed to delete": "删除失败",
     "Failed to plan this copy": "无法预览这次复制的结果",
@@ -156,6 +168,8 @@
     "Failed to read this item": "读取该项失败",
     "From": "从",
     "Gateway does not know where this agent keeps these": "Gateway 不知道该 Agent 
把这些配置放在哪里",
+    "HTTP endpoint": "HTTP 端点",
+    "Local command": "本地命令(stdio)",
     "MCP": "MCP",
     "MCP servers": "MCP 服务器",
     "Managed by Gateway": "Gateway",
@@ -169,6 +183,7 @@
     "Not installed": "仅配置",
     "Not installed detail": "Gateway 在磁盘上找到了这份配置,但没有检测到该 Agent 的安装。",
     "Page description": "本机每个 Agent 自己配置文件里的技能和 MCP 服务器。可以并排比较,并从一个 Agent 
复制到另一个。",
+    "Pairs hint": "每行一个 KEY=VALUE。",
     "Pick the agents to copy into": "选择要复制到的 Agent",
     "Read from": "读取自",
     "Replace items that already exist": "覆盖已存在的同名项",
@@ -180,6 +195,8 @@
     "Skills & MCP": "技能与 MCP",
     "Skipped": "跳过",
     "This file is read-only for Gateway": "Gateway 不会写入该 Agent 的配置文件",
+    "Transport": "连接方式",
+    "URL": "URL",
     "View": "查看",
     "{files} files": "{files} 个文件"
   },
diff --git a/web/src/pages/AgentConfigsPage.tsx 
b/web/src/pages/AgentConfigsPage.tsx
index 34e8f03..187f778 100644
--- a/web/src/pages/AgentConfigsPage.tsx
+++ b/web/src/pages/AgentConfigsPage.tsx
@@ -13,12 +13,13 @@
 // limitations under the License.
 
 import * as React from "react";
-import {Check, Eye, Minus, Package, RefreshCw, Send, Trash2} from 
"lucide-react";
+import {Check, Eye, Minus, Package, Plus, RefreshCw, Send, Trash2} from 
"lucide-react";
 import i18next from "i18next";
 
 import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
 import * as Setting from "@/Setting";
 import {AgentIcon} from "@/components/AgentIcon";
+import {AddMcpDialog} from "@/components/agent-config/add-mcp-dialog";
 import {CopyDialog} from "@/components/agent-config/copy-dialog";
 import {DetailDialog, type DetailTarget} from 
"@/components/agent-config/detail-dialog";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
@@ -102,6 +103,7 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
   const [selected, setSelected] = React.useState<string[]>([]);
   const [detail, setDetail] = React.useState<DetailTarget | null>(null);
   const [copyOpen, setCopyOpen] = React.useState(false);
+  const [addOpen, setAddOpen] = React.useState(false);
   const [deleting, setDeleting] = React.useState("");
 
   // The scan replaces every inventory, so the chosen source is re-resolved by
@@ -298,10 +300,18 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
         title={i18next.t("agentConfig:Skills & MCP")}
         description={i18next.t("agentConfig:Page description")}
         actions={
-          <Button variant="outline" onClick={() => refresh(true)} 
disabled={loading}>
-            <RefreshCw className={cn("size-4", loading && "animate-spin")} />
-            {i18next.t("general:Refresh")}
-          </Button>
+          <>
+            {kind === "mcp" && inventories.length > 0 ? (
+              <Button onClick={() => setAddOpen(true)}>
+                <Plus className="size-4" />
+                {i18next.t("agentConfig:Add MCP server")}
+              </Button>
+            ) : null}
+            <Button variant="outline" onClick={() => refresh(true)} 
disabled={loading}>
+              <RefreshCw className={cn("size-4", loading && "animate-spin")} />
+              {i18next.t("general:Refresh")}
+            </Button>
+          </>
         }
       />
 
@@ -393,6 +403,16 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
         </>
       ) : null}
 
+      {source ? (
+        <AddMcpDialog
+          open={addOpen}
+          onOpenChange={setAddOpen}
+          inventories={inventories}
+          source={source}
+          onDone={refresh}
+        />
+      ) : null}
+
       <DetailDialog target={detail} onOpenChange={open => !open && 
setDetail(null)} />
     </PageContainer>
   );
diff --git a/web/src/types.ts b/web/src/types.ts
index 73ed0a9..2996836 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -397,6 +397,9 @@ export interface AgentConfigDetail {
   files?: string[];
 }
 
+/** How an MCP server is reached: a spawned command, or an HTTP endpoint. */
+export type McpTransport = "stdio" | "http";
+
 export type AgentConfigAction = "create" | "overwrite" | "skip" | "failed";
 
 /** What a copy would do, or did, to one item at one target agent. */


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to