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 bde6658  feat(agent): manage each agent's skills and MCP servers
bde6658 is described below

commit bde6658fe4d59f2d5540cefb1c400bd42ce82495
Author: Yang Luo <[email protected]>
AuthorDate: Fri Aug 21 13:17:35 2026 +0800

    feat(agent): manage each agent's skills and MCP servers
---
 README.md                                         |  10 +
 agent/agent.go                                    |  11 +
 agentconfig/agentconfig.go                        | 387 +++++++++++++++++++++
 agentconfig/agentconfig_test.go                   | 313 +++++++++++++++++
 agentconfig/file.go                               |  89 +++++
 agentconfig/frontmatter.go                        | 121 +++++++
 agentconfig/layout.go                             | 192 +++++++++++
 agentconfig/mcp_item.go                           | 103 ++++++
 agentconfig/mcp_json.go                           | 175 ++++++++++
 agentconfig/mcp_toml.go                           | 291 ++++++++++++++++
 agentconfig/mcp_toml_test.go                      | 207 +++++++++++
 agentconfig/skill.go                              | 290 ++++++++++++++++
 {agentpatch => agenthome}/home.go                 |  17 +-
 agentpatch/claude_code.go                         |   3 +-
 agentpatch/claude_desktop.go                      |  16 +-
 agentpatch/openclaw.go                            |   3 +-
 agentpatch/state.go                               |   6 +-
 controllers/agent_config.go                       | 197 +++++++++++
 routers/router.go                                 |   5 +
 web/src/App.tsx                                   |   5 +
 web/src/backend/AgentConfigBackend.ts             |  58 ++++
 web/src/components/agent-config/copy-dialog.tsx   | 295 ++++++++++++++++
 web/src/components/agent-config/detail-dialog.tsx | 128 +++++++
 web/src/lib/agent-configs.ts                      | 127 +++++++
 web/src/locales/en/data.json                      |  61 +++-
 web/src/locales/zh/data.json                      |  61 +++-
 web/src/nav.ts                                    |   8 +
 web/src/pages/AgentConfigsPage.tsx                | 399 ++++++++++++++++++++++
 web/src/types.ts                                  |  60 ++++
 29 files changed, 3614 insertions(+), 24 deletions(-)

diff --git a/README.md b/README.md
index e3adca5..07c50c2 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,16 @@ Gateway also watches the AI coding agents installed on the 
machine it runs on: i
 
 Discovery reads that machine's user accounts, home directories and package 
install paths, so it only ever sees the host Gateway itself runs on. Inside a 
container — `docker compose up`, `docker run`, or Podman — Gateway scans the 
container's own filesystem, finds nothing, and the **Agents** page stays empty 
even though agents are installed on the host. Run Gateway from source or as a 
[single binary](#single-binary) on the host to use these features; every other 
part of Gateway, the WAF p [...]
 
+### Skills and MCP servers
+
+Every agent keeps its skills and its MCP servers in its own file, in its own 
format: `~/.claude/skills` and `~/.claude.json` for Claude Code, 
`~/.codex/skills` and the `[mcp_servers]` tables of `~/.codex/config.toml` for 
Codex, `~/.cursor/skills-cursor` and `~/.cursor/mcp.json` for Cursor, and so 
on. The **Skills & MCP** page reads all of them and puts them in one table, 
with a column per agent showing which of them already has each item.
+
+From there an item can be opened — the whole `SKILL.md`, or the server's entry 
with its credentials masked — deleted from the agent that holds it, or copied 
into the other agents on the same account. A copy shows what it would do at 
every target first, item by item, and only replaces something that is already 
there when told to.
+
+Gateway reads and writes these files in place. It stages a replacement and 
renames it over the original rather than truncating it, keeps every setting it 
does not own, and preserves the comments and formatting of `config.toml` by 
editing its text instead of re-encoding it. The MCP server Gateway registers 
for its own monitoring is listed like any other, but is never copied or deleted 
from here; turning monitoring off on the **Agents** page is what removes it.
+
+Configuration is found whether or not the agent itself was discovered, so an 
agent installed some way Gateway does not recognize still shows the skills it 
has on disk. Like agent monitoring, this only ever sees the host Gateway runs 
on.
+
 ### Quick start
 
 From nothing to a request flowing through the gateway, in four steps. No 
database server is needed: Gateway creates `./data/casbin-gateway.db` on first 
start.
diff --git a/agent/agent.go b/agent/agent.go
index 5bb83fb..bca4022 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -25,6 +25,17 @@ type Installation struct {
        Owner         string `json:"owner"`
 }
 
+// DisplayNameOf is the human-readable name of a known agent id, empty for an 
id
+// no fingerprint declares.
+func DisplayNameOf(id string) string {
+       for i := range fingerprints {
+               if fingerprints[i].ID == id {
+                       return fingerprints[i].DisplayName
+               }
+       }
+       return ""
+}
+
 // IsKnownAgentId reads the fingerprints rather than a host scan, so an agent
 // stays configurable while it is not installed.
 func IsKnownAgentId(id string) bool {
diff --git a/agentconfig/agentconfig.go b/agentconfig/agentconfig.go
new file mode 100644
index 0000000..74d05e7
--- /dev/null
+++ b/agentconfig/agentconfig.go
@@ -0,0 +1,387 @@
+// 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 reads and edits the skills and MCP servers that AI 
coding
+// agents keep in their own configuration files, so the agents installed on one
+// host can be compared and their configuration moved between them.
+//
+// Every agent stores the same two things in a different place and a different
+// format. layout.go holds that per-agent knowledge, and nothing else does.
+package agentconfig
+
+import (
+       "errors"
+       "fmt"
+       "sort"
+       "strings"
+)
+
+// The two kinds of configuration Gateway manages.
+const (
+       KindSkill = "skill"
+       KindMcp   = "mcp"
+)
+
+// ErrUnsupported reports a kind an agent has no known location for, which is
+// different from an agent that has one and it is empty.
+var ErrUnsupported = errors.New("Gateway does not know where this agent keeps 
that configuration")
+
+// Item is one skill or MCP server as it exists in an agent's own 
configuration.
+type Item struct {
+       AgentId string `json:"agentId"`
+       Owner   string `json:"owner"`
+       Kind    string `json:"kind"`
+       Name    string `json:"name"`
+
+       Description string `json:"description,omitempty"`
+
+       // Path is what an edit would change: the skill's own directory, or the
+       // config file the MCP server is one entry of.
+       Path string `json:"path"`
+
+       // Transport, Command and Url summarize an MCP server in a list.
+       Transport string `json:"transport,omitempty"`
+       Command   string `json:"command,omitempty"`
+       Url       string `json:"url,omitempty"`
+
+       // Files and Bytes summarize a skill in a list.
+       Files int   `json:"files,omitempty"`
+       Bytes int64 `json:"bytes,omitempty"`
+
+       // Managed marks an entry Gateway wrote itself, which is not the 
operator's
+       // to migrate and is removed by turning monitoring off instead.
+       Managed bool `json:"managed,omitempty"`
+}
+
+// Inventory is everything Gateway can read for one installation. A location it
+// cannot read leaves the corresponding list empty and adds to Errors, so one
+// unreadable file never hides the rest of the host.
+type Inventory struct {
+       AgentId string `json:"agentId"`
+       Owner   string `json:"owner"`
+       // Home is the account directory the locations below were resolved 
under.
+       // Two agents can be compared and copied between exactly when they 
share it.
+       Home string `json:"home,omitempty"`
+
+       SkillsDir string `json:"skillsDir,omitempty"`
+       McpFile   string `json:"mcpFile,omitempty"`
+
+       SkillsSupported bool `json:"skillsSupported"`
+       McpSupported    bool `json:"mcpSupported"`
+       // McpWritable is false for a config Gateway can read but must not 
rewrite,
+       // because writing it back would lose something it cannot represent.
+       McpWritable bool   `json:"mcpWritable"`
+       McpReadOnly string `json:"mcpReadOnly,omitempty"`
+
+       Skills     []*Item  `json:"skills"`
+       McpServers []*Item  `json:"mcpServers"`
+       Errors     []string `json:"errors,omitempty"`
+}
+
+// Detail is one item's full definition, for the viewer.
+type Detail struct {
+       Item *Item `json:"item"`
+       // Content is the SKILL.md of a skill, or the JSON of one MCP server 
entry.
+       Content string `json:"content"`
+       // Files lists the other files a skill directory carries.
+       Files []string `json:"files,omitempty"`
+}
+
+// source is one item of the migration source, read once per Copy rather than
+// once per target.
+type source struct {
+       item  *Item
+       entry map[string]any
+}
+
+// Read returns one installation's inventory. It never fails: an unreadable
+// location is reported in Errors, because a page listing every agent on the
+// host must stay useful when one of them has a broken config file.
+func Read(agentId string, owner string) *Inventory {
+       inventory := &Inventory{
+               AgentId:    agentId,
+               Owner:      owner,
+               Skills:     []*Item{},
+               McpServers: []*Item{},
+       }
+
+       found, ok := layoutOf(agentId)
+       if !ok {
+               return inventory
+       }
+       inventory.SkillsSupported = found.skills != nil
+       inventory.McpSupported = found.mcp != nil
+       if found.mcp != nil {
+               inventory.McpWritable = found.mcp.readOnly == ""
+               inventory.McpReadOnly = found.mcp.readOnly
+       }
+
+       home, err := homeOf(owner)
+       if err != nil {
+               inventory.Errors = append(inventory.Errors, err.Error())
+               return inventory
+       }
+       inventory.Home = home
+
+       if found.skills != nil {
+               inventory.SkillsDir = found.skills.dir(home)
+               skills, err := readSkills(agentId, owner, inventory.SkillsDir)
+               if err != nil {
+                       inventory.Errors = append(inventory.Errors, err.Error())
+               }
+               inventory.Skills = skills
+       }
+
+       if found.mcp != nil {
+               inventory.McpFile = found.mcp.path(home)
+               entries, err := found.mcp.store.read(inventory.McpFile)
+               if err != nil {
+                       inventory.Errors = append(inventory.Errors, err.Error())
+               }
+               inventory.McpServers = mcpItems(agentId, owner, 
inventory.McpFile, entries)
+       }
+       return inventory
+}
+
+// ReadDetail returns one item's full definition.
+func ReadDetail(agentId string, owner string, kind string, name string) 
(*Detail, error) {
+       found, home, err := resolve(agentId, owner, kind)
+       if err != nil {
+               return nil, err
+       }
+       if kind == KindSkill {
+               return skillDetail(agentId, owner, found.skills.dir(home), name)
+       }
+
+       file := found.mcp.path(home)
+       entries, err := found.mcp.store.read(file)
+       if err != nil {
+               return nil, err
+       }
+       entry, ok := entries[name]
+       if !ok {
+               return nil, fmt.Errorf("%s: no MCP server named %q in %s", 
agentId, name, file)
+       }
+       return mcpDetail(agentId, owner, file, name, entry)
+}
+
+// Delete removes one item from the agent's own configuration.
+func Delete(agentId string, owner string, kind string, name string) error {
+       found, home, err := resolve(agentId, owner, kind)
+       if err != nil {
+               return err
+       }
+       if kind == KindSkill {
+               return deleteSkill(found.skills.dir(home), name)
+       }
+       if found.mcp.readOnly != "" {
+               return fmt.Errorf("%s: %s", agentId, found.mcp.readOnly)
+       }
+       return found.mcp.store.remove(found.mcp.path(home), name)
+}
+
+// CopyRequest is one migration: some of a source agent's items, into one or
+// more other agents belonging to the same account.
+type CopyRequest struct {
+       Owner     string   `json:"owner"`
+       From      string   `json:"from"`
+       To        []string `json:"to"`
+       Kind      string   `json:"kind"`
+       Names     []string `json:"names"`
+       Overwrite bool     `json:"overwrite"`
+}
+
+// What one planned copy would do to the target.
+const (
+       ActionCreate    = "create"
+       ActionOverwrite = "overwrite"
+       ActionSkip      = "skip"
+       ActionFailed    = "failed"
+)
+
+// PlanItem is one item's fate at one target, decided before anything is 
written
+// and reported again afterwards with what actually happened.
+type PlanItem struct {
+       AgentId string `json:"agentId"`
+       Name    string `json:"name"`
+       Action  string `json:"action"`
+       Reason  string `json:"reason,omitempty"`
+       Path    string `json:"path,omitempty"`
+}
+
+// Plan reports what Copy would do, without touching anything.
+func Plan(request CopyRequest) ([]*PlanItem, error) {
+       sources, err := readSources(request)
+       if err != nil {
+               return nil, err
+       }
+       return plan(request, sources), nil
+}
+
+func plan(request CopyRequest, sources map[string]*source) []*PlanItem {
+       planned := []*PlanItem{}
+       for _, agentId := range request.To {
+               existing, err := targetNames(agentId, request.Owner, 
request.Kind)
+               for _, name := range request.Names {
+                       item := &PlanItem{AgentId: agentId, Name: name}
+                       switch {
+                       case err != nil:
+                               item.Action, item.Reason = ActionSkip, 
err.Error()
+                       case sources[name] == nil:
+                               item.Action, item.Reason = ActionSkip, "not 
found in the source agent"
+                       case sources[name].item.Managed:
+                               item.Action, item.Reason = ActionSkip, "written 
by Gateway, not migrated"
+                       case !existing[name]:
+                               item.Action = ActionCreate
+                       case request.Overwrite:
+                               item.Action = ActionOverwrite
+                       default:
+                               item.Action, item.Reason = ActionSkip, "already 
exists"
+                       }
+                       planned = append(planned, item)
+               }
+       }
+       return planned
+}
+
+// Copy writes the selected items into every target agent. One item's failure 
is
+// recorded against that item and the rest still run, so a partly finished
+// migration is reported item by item instead of hidden behind the first error.
+func Copy(request CopyRequest) ([]*PlanItem, error) {
+       sources, err := readSources(request)
+       if err != nil {
+               return nil, err
+       }
+
+       planned := plan(request, sources)
+       for _, item := range planned {
+               if item.Action != ActionCreate && item.Action != 
ActionOverwrite {
+                       continue
+               }
+               path, err := writeItem(request, sources[item.Name], 
item.AgentId)
+               if err != nil {
+                       item.Action, item.Reason = ActionFailed, err.Error()
+                       continue
+               }
+               item.Path = path
+       }
+       return planned, nil
+}
+
+// writeItem puts one already-read source item into one target agent.
+func writeItem(request CopyRequest, from *source, agentId string) (string, 
error) {
+       found, home, err := resolve(agentId, request.Owner, request.Kind)
+       if err != nil {
+               return "", err
+       }
+
+       if request.Kind == KindSkill {
+               return copySkill(from.item.Path, found.skills.dir(home), 
from.item.Name)
+       }
+       if found.mcp.readOnly != "" {
+               return "", errors.New(found.mcp.readOnly)
+       }
+       file := found.mcp.path(home)
+       return file, found.mcp.store.write(file, from.item.Name, from.entry)
+}
+
+// readSources validates the request and loads the source agent's items once.
+func readSources(request CopyRequest) (map[string]*source, error) {
+       switch {
+       case request.From == "":
+               return nil, errors.New("the source agent is empty")
+       case len(request.To) == 0:
+               return nil, errors.New("no target agent was selected")
+       case len(request.Names) == 0:
+               return nil, errors.New("nothing was selected to copy")
+       case request.Kind != KindSkill && request.Kind != KindMcp:
+               return nil, fmt.Errorf("unknown configuration kind: %s", 
request.Kind)
+       }
+       for _, agentId := range request.To {
+               if agentId == request.From {
+                       return nil, errors.New("the source agent cannot also be 
a target")
+               }
+       }
+
+       found, home, err := resolve(request.From, request.Owner, request.Kind)
+       if err != nil {
+               return nil, err
+       }
+
+       inventory := Read(request.From, request.Owner)
+       if len(inventory.Errors) > 0 {
+               return nil, errors.New(strings.Join(inventory.Errors, "; "))
+       }
+
+       entries := map[string]map[string]any{}
+       if request.Kind == KindMcp {
+               entries, err = found.mcp.store.read(found.mcp.path(home))
+               if err != nil {
+                       return nil, err
+               }
+       }
+
+       sources := map[string]*source{}
+       for _, item := range itemsOf(inventory, request.Kind) {
+               sources[item.Name] = &source{item: item, entry: 
entries[item.Name]}
+       }
+       return sources, nil
+}
+
+// targetNames is what the target already has, so planning can tell a new name
+// from one that would be replaced.
+func targetNames(agentId string, owner string, kind string) (map[string]bool, 
error) {
+       if _, _, err := resolve(agentId, owner, kind); err != nil {
+               return nil, err
+       }
+
+       inventory := Read(agentId, owner)
+       if len(inventory.Errors) > 0 {
+               return nil, errors.New(strings.Join(inventory.Errors, "; "))
+       }
+
+       names := map[string]bool{}
+       for _, item := range itemsOf(inventory, kind) {
+               names[item.Name] = true
+       }
+       return names, nil
+}
+
+func itemsOf(inventory *Inventory, kind string) []*Item {
+       if kind == KindSkill {
+               return inventory.Skills
+       }
+       return inventory.McpServers
+}
+
+// resolve is the check every read and write shares: the agent is known, it
+// keeps that kind of configuration somewhere, and the owner's home resolves.
+func resolve(agentId string, owner string, kind string) (layout, string, 
error) {
+       found, ok := layoutOf(agentId)
+       if !ok {
+               return layout{}, "", fmt.Errorf("unknown agent: %s", agentId)
+       }
+       if (kind == KindSkill && found.skills == nil) || (kind == KindMcp && 
found.mcp == nil) {
+               return layout{}, "", fmt.Errorf("%s: %w", agentId, 
ErrUnsupported)
+       }
+       home, err := homeOf(owner)
+       if err != nil {
+               return layout{}, "", err
+       }
+       return found, home, nil
+}
+
+func sortItems(items []*Item) {
+       sort.Slice(items, func(i, j int) bool { return items[i].Name < 
items[j].Name })
+}
diff --git a/agentconfig/agentconfig_test.go b/agentconfig/agentconfig_test.go
new file mode 100644
index 0000000..15d0d50
--- /dev/null
+++ b/agentconfig/agentconfig_test.go
@@ -0,0 +1,313 @@
+// 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 (
+       "os"
+       "path/filepath"
+       "strings"
+       "testing"
+)
+
+// fakeHome points the whole package at a temporary directory, so a test reads
+// and writes agent configuration without touching the account running it.
+func fakeHome(t *testing.T) string {
+       t.Helper()
+       home := t.TempDir()
+       t.Setenv("HOME", home)
+       t.Setenv("USERPROFILE", home)
+       return home
+}
+
+func writeFileAt(t *testing.T, path string, content string) {
+       t.Helper()
+       if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+               t.Fatal(err)
+       }
+       if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+               t.Fatal(err)
+       }
+}
+
+func writeSkill(t *testing.T, dir string, name string, description string) {
+       t.Helper()
+       writeFileAt(t, filepath.Join(dir, name, skillFile),
+               "---\nname: "+name+"\ndescription: "+description+"\n---\n# 
"+name+"\n")
+}
+
+func TestReadListsSkillsAndServers(t *testing.T) {
+       home := fakeHome(t)
+       writeSkill(t, filepath.Join(home, ".claude", "skills"), "pdf", "Work 
with PDFs")
+       writeFileAt(t, filepath.Join(home, ".claude", "skills", "pdf", 
"references", "spec.md"), "spec")
+       writeFileAt(t, filepath.Join(home, ".claude.json"),
+               
`{"projects":{"a":1},"mcpServers":{"files":{"command":"npx","args":["-y","server"]}}}`)
+
+       inventory := Read("claude-code", "")
+       if len(inventory.Errors) > 0 {
+               t.Fatalf("read reported errors: %v", inventory.Errors)
+       }
+       if len(inventory.Skills) != 1 || inventory.Skills[0].Name != "pdf" {
+               t.Fatalf("skills = %+v", inventory.Skills)
+       }
+       if inventory.Skills[0].Description != "Work with PDFs" || 
inventory.Skills[0].Files != 2 {
+               t.Fatalf("skill = %+v", inventory.Skills[0])
+       }
+       if len(inventory.McpServers) != 1 {
+               t.Fatalf("servers = %+v", inventory.McpServers)
+       }
+       server := inventory.McpServers[0]
+       if server.Name != "files" || server.Transport != "stdio" || 
server.Command != "npx -y server" {
+               t.Fatalf("server = %+v", server)
+       }
+}
+
+func TestReadSkipsDirectoriesWithoutAManifest(t *testing.T) {
+       home := fakeHome(t)
+       skills := filepath.Join(home, ".claude", "skills")
+       writeSkill(t, skills, "real", "kept")
+       writeFileAt(t, filepath.Join(skills, "notes", "README.md"), "not a 
skill")
+       writeFileAt(t, filepath.Join(skills, ".managed", skillFile), 
"---\nname: hidden\n---\n")
+
+       inventory := Read("claude-code", "")
+       if len(inventory.Skills) != 1 || inventory.Skills[0].Name != "real" {
+               t.Fatalf("skills = %+v", inventory.Skills)
+       }
+}
+
+func TestReadReportsAnUnparsableFileWithoutLosingSkills(t *testing.T) {
+       home := fakeHome(t)
+       writeSkill(t, filepath.Join(home, ".claude", "skills"), "pdf", "Work 
with PDFs")
+       writeFileAt(t, filepath.Join(home, ".claude.json"), "{ not json")
+
+       inventory := Read("claude-code", "")
+       if len(inventory.Errors) != 1 {
+               t.Fatalf("errors = %v", inventory.Errors)
+       }
+       if len(inventory.Skills) != 1 {
+               t.Fatalf("a broken MCP file hid the skills: %+v", 
inventory.Skills)
+       }
+}
+
+func TestOpenClawServersAreReadUnderEitherSpelling(t *testing.T) {
+       home := fakeHome(t)
+       writeFileAt(t, filepath.Join(home, ".openclaw", "openclaw.json"),
+               
`{"mcp":{"servers":{"a":{"command":"a"}}},"mcpServers":{"b":{"url":"https://b"}}}`)
+
+       inventory := Read("openclaw", "")
+       if len(inventory.McpServers) != 2 {
+               t.Fatalf("servers = %+v", inventory.McpServers)
+       }
+       if inventory.McpServers[1].Transport != "http" {
+               t.Fatalf("server = %+v", inventory.McpServers[1])
+       }
+}
+
+func TestDeleteRemovesOneServerAndKeepsTheRestOfTheFile(t *testing.T) {
+       home := fakeHome(t)
+       path := filepath.Join(home, ".cursor", "mcp.json")
+       writeFileAt(t, path, 
`{"other":{"keep":true},"mcpServers":{"a":{"command":"a"},"b":{"command":"b"}}}`)
+
+       if err := Delete("cursor", "", KindMcp, "a"); err != nil {
+               t.Fatal(err)
+       }
+
+       content := readBack(t, path)
+       if strings.Contains(content, `"a"`) || !strings.Contains(content, 
`"b"`) {
+               t.Fatalf("file after delete: %s", content)
+       }
+       if !strings.Contains(content, `"keep"`) {
+               t.Fatalf("delete dropped an unrelated key: %s", content)
+       }
+}
+
+func TestDeleteSkillRefusesADirectoryThatIsNotASkill(t *testing.T) {
+       home := fakeHome(t)
+       writeFileAt(t, filepath.Join(home, ".claude", "skills", "notes", 
"README.md"), "not a skill")
+
+       if err := Delete("claude-code", "", KindSkill, "notes"); err == nil {
+               t.Fatal("a directory without a manifest was deleted")
+       }
+       if _, err := os.Stat(filepath.Join(home, ".claude", "skills", 
"notes")); err != nil {
+               t.Fatal(err)
+       }
+}
+
+func TestDeleteRejectsATraversingName(t *testing.T) {
+       fakeHome(t)
+       for _, name := range []string{"..", "../escape", `..\escape`, "a/b", 
""} {
+               if err := Delete("claude-code", "", KindSkill, name); err == 
nil {
+                       t.Fatalf("name %q was accepted", name)
+               }
+       }
+}
+
+func TestCopySkillsBetweenAgents(t *testing.T) {
+       home := fakeHome(t)
+       source := filepath.Join(home, ".claude", "skills")
+       writeSkill(t, source, "pdf", "Work with PDFs")
+       writeFileAt(t, filepath.Join(source, "pdf", "references", "spec.md"), 
"spec")
+       writeSkill(t, source, "xlsx", "Work with spreadsheets")
+       writeSkill(t, filepath.Join(home, ".codex", "skills"), "pdf", "the 
older copy")
+
+       request := CopyRequest{
+               From:  "claude-code",
+               To:    []string{"codex-cli"},
+               Kind:  KindSkill,
+               Names: []string{"pdf", "xlsx"},
+       }
+
+       planned, err := Plan(request)
+       if err != nil {
+               t.Fatal(err)
+       }
+       if planned[0].Action != ActionSkip || planned[1].Action != ActionCreate 
{
+               t.Fatalf("plan = %+v %+v", planned[0], planned[1])
+       }
+
+       request.Overwrite = true
+       result, err := Copy(request)
+       if err != nil {
+               t.Fatal(err)
+       }
+       if result[0].Action != ActionOverwrite || result[1].Action != 
ActionCreate {
+               t.Fatalf("result = %+v %+v", result[0], result[1])
+       }
+
+       target := filepath.Join(home, ".codex", "skills")
+       if content := readBack(t, filepath.Join(target, "pdf", skillFile)); 
!strings.Contains(content, "Work with PDFs") {
+               t.Fatalf("the existing skill was not replaced: %s", content)
+       }
+       if content := readBack(t, filepath.Join(target, "pdf", "references", 
"spec.md")); content != "spec" {
+               t.Fatalf("reference file = %q", content)
+       }
+       if _, err := os.Stat(filepath.Join(target, "xlsx", skillFile)); err != 
nil {
+               t.Fatal(err)
+       }
+}
+
+func TestCopyLeavesTheSourceInPlace(t *testing.T) {
+       home := fakeHome(t)
+       writeSkill(t, filepath.Join(home, ".claude", "skills"), "pdf", "Work 
with PDFs")
+
+       _, err := Copy(CopyRequest{From: "claude-code", To: []string{"cursor"}, 
Kind: KindSkill, Names: []string{"pdf"}})
+       if err != nil {
+               t.Fatal(err)
+       }
+       if _, err := os.Stat(filepath.Join(home, ".claude", "skills", "pdf", 
skillFile)); err != nil {
+               t.Fatal(err)
+       }
+}
+
+func TestCopyReportsAFailureItemByItem(t *testing.T) {
+       home := fakeHome(t)
+       writeSkill(t, filepath.Join(home, ".claude", "skills"), "pdf", "Work 
with PDFs")
+
+       result, err := Copy(CopyRequest{
+               From:  "claude-code",
+               To:    []string{"claude-desktop", "cursor"},
+               Kind:  KindSkill,
+               Names: []string{"pdf"},
+       })
+       if err != nil {
+               t.Fatal(err)
+       }
+       if result[0].Action != ActionSkip || !strings.Contains(result[1].Path, 
"skills-cursor") {
+               t.Fatalf("result = %+v %+v", result[0], result[1])
+       }
+}
+
+func TestCopyRejectsTheSourceAsItsOwnTarget(t *testing.T) {
+       fakeHome(t)
+       _, err := Copy(CopyRequest{From: "cursor", To: []string{"cursor"}, 
Kind: KindSkill, Names: []string{"pdf"}})
+       if err == nil {
+               t.Fatal("an agent was allowed to copy onto itself")
+       }
+}
+
+func TestCopyServersIntoAJsonConfig(t *testing.T) {
+       home := fakeHome(t)
+       writeFileAt(t, filepath.Join(home, ".claude.json"),
+               
`{"mcpServers":{"files":{"command":"npx","args":["-y","server"],"env":{"API_KEY":"secret"}}}}`)
+       writeFileAt(t, filepath.Join(home, ".cursor", "mcp.json"), 
`{"mcpServers":{}}`)
+
+       result, err := Copy(CopyRequest{From: "claude-code", To: 
[]string{"cursor"}, Kind: KindMcp, Names: []string{"files"}})
+       if err != nil {
+               t.Fatal(err)
+       }
+       if result[0].Action != ActionCreate {
+               t.Fatalf("result = %+v", result[0])
+       }
+
+       content := readBack(t, filepath.Join(home, ".cursor", "mcp.json"))
+       if !strings.Contains(content, `"API_KEY": "secret"`) {
+               t.Fatalf("the copy lost the environment: %s", content)
+       }
+}
+
+func TestDetailMasksCredentialsButCopyDoesNot(t *testing.T) {
+       home := fakeHome(t)
+       writeFileAt(t, filepath.Join(home, ".cursor", "mcp.json"),
+               
`{"mcpServers":{"files":{"command":"npx","env":{"API_KEY":"secret"}}}}`)
+
+       detail, err := ReadDetail("cursor", "", KindMcp, "files")
+       if err != nil {
+               t.Fatal(err)
+       }
+       if strings.Contains(detail.Content, "secret") {
+               t.Fatalf("the detail view leaked a credential: %s", 
detail.Content)
+       }
+
+       if _, err := Copy(CopyRequest{From: "cursor", To: 
[]string{"claude-code"}, Kind: KindMcp, Names: []string{"files"}}); err != nil {
+               t.Fatal(err)
+       }
+       if content := readBack(t, filepath.Join(home, ".claude.json")); 
!strings.Contains(content, "secret") {
+               t.Fatalf("the copy wrote the masked value: %s", content)
+       }
+}
+
+func TestManagedEntriesAreNotMigrated(t *testing.T) {
+       home := fakeHome(t)
+       writeFileAt(t, filepath.Join(home, ".claude.json"),
+               `{"mcpServers":{"`+ManagedEntryName+`":{"command":"gateway"}}}`)
+
+       planned, err := Plan(CopyRequest{
+               From: "claude-code", To: []string{"cursor"}, Kind: KindMcp, 
Names: []string{ManagedEntryName},
+       })
+       if err != nil {
+               t.Fatal(err)
+       }
+       if planned[0].Action != ActionSkip {
+               t.Fatalf("plan = %+v", planned[0])
+       }
+}
+
+func TestUnsupportedKindIsAnError(t *testing.T) {
+       fakeHome(t)
+       if _, err := ReadDetail("windsurf", "", KindSkill, "pdf"); err == nil {
+               t.Fatal("an agent with no skills directory answered a skill 
lookup")
+       }
+       if inventory := Read("windsurf", ""); inventory.SkillsSupported {
+               t.Fatal("windsurf reported skill support")
+       }
+}
+
+func readBack(t *testing.T, path string) string {
+       t.Helper()
+       data, err := os.ReadFile(path)
+       if err != nil {
+               t.Fatal(err)
+       }
+       return string(data)
+}
diff --git a/agentconfig/file.go b/agentconfig/file.go
new file mode 100644
index 0000000..2b1d3a1
--- /dev/null
+++ b/agentconfig/file.go
@@ -0,0 +1,89 @@
+// 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 (
+       "fmt"
+       "os"
+       "path/filepath"
+)
+
+// defaultConfigMode is what a config file Gateway creates is born with. These
+// files carry API keys and tokens, so a new one is not world-readable.
+const defaultConfigMode = os.FileMode(0o600)
+
+// readFile returns empty content for a file that does not exist yet, which is
+// what an agent with no configuration of that kind looks like.
+func readFile(path string) ([]byte, os.FileMode, error) {
+       data, err := os.ReadFile(path)
+       if os.IsNotExist(err) {
+               return nil, defaultConfigMode, nil
+       }
+       if err != nil {
+               return nil, 0, err
+       }
+       info, err := os.Stat(path)
+       if err != nil {
+               return nil, 0, err
+       }
+       return data, info.Mode().Perm(), nil
+}
+
+// writeFile replaces path in one step. An agent may be reading its own config
+// while Gateway rewrites it, so the new content is staged in the same 
directory
+// and renamed over the old file rather than truncating it in place.
+func writeFile(path string, data []byte, mode os.FileMode) error {
+       dir := filepath.Dir(path)
+       if err := os.MkdirAll(dir, 0o755); err != nil {
+               return err
+       }
+
+       staged, err := os.CreateTemp(dir, "."+filepath.Base(path)+".gateway-*")
+       if err != nil {
+               return err
+       }
+       name := staged.Name()
+       defer os.Remove(name)
+
+       if _, err := staged.Write(data); err != nil {
+               staged.Close()
+               return err
+       }
+       if err := staged.Sync(); err != nil {
+               staged.Close()
+               return err
+       }
+       if err := staged.Close(); err != nil {
+               return err
+       }
+       if err := os.Chmod(name, mode); err != nil {
+               return err
+       }
+       return os.Rename(name, path)
+}
+
+// checkName rejects a name that would escape the directory it is looked up in.
+// Names come from an API request, and a skill is a directory that gets deleted
+// or copied whole.
+func checkName(name string) error {
+       if name == "" {
+               return fmt.Errorf("the name is empty")
+       }
+       if name != filepath.Base(name) || name == "." || name == ".." ||
+               filepath.IsAbs(name) || filepath.VolumeName(name) != "" {
+               return fmt.Errorf("invalid name: %q", name)
+       }
+       return nil
+}
diff --git a/agentconfig/frontmatter.go b/agentconfig/frontmatter.go
new file mode 100644
index 0000000..8b1d390
--- /dev/null
+++ b/agentconfig/frontmatter.go
@@ -0,0 +1,121 @@
+// 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 "strings"
+
+const maxDescriptionRunes = 400
+
+// parseFrontMatter reads the name and description out of a SKILL.md header.
+//
+// The header is YAML, but a skill only ever declares flat string fields in it,
+// and two of them are all a list needs. Reading those two directly keeps this 
a
+// leaf package and keeps a header Gateway does not fully understand from
+// costing the operator the whole listing.
+func parseFrontMatter(raw string) (string, string) {
+       lines, ok := frontMatterLines(raw)
+       if !ok {
+               return "", ""
+       }
+
+       name, description := "", ""
+       for index := 0; index < len(lines); index++ {
+               key, value := splitField(lines[index])
+               if key != "name" && key != "description" {
+                       continue
+               }
+               if folded := strings.TrimSpace(value); folded == ">" || folded 
== ">-" || folded == "|" || folded == "|-" {
+                       var consumed int
+                       value, consumed = readBlock(lines[index+1:], 
strings.HasPrefix(folded, "|"))
+                       index += consumed
+               }
+
+               if key == "name" {
+                       name = unquote(value)
+               } else {
+                       description = unquote(value)
+               }
+       }
+       return name, truncate(description)
+}
+
+// frontMatterLines returns the lines between the opening and closing --- of a
+// document that starts with one.
+func frontMatterLines(raw string) ([]string, bool) {
+       raw = strings.TrimLeft(raw, "\uFEFF \t\r\n")
+       if !strings.HasPrefix(raw, "---") {
+               return nil, false
+       }
+
+       lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
+       for index := 1; index < len(lines); index++ {
+               if strings.TrimRight(lines[index], " \t") == "---" {
+                       return lines[1:index], true
+               }
+       }
+       return nil, false
+}
+
+// splitField returns a top-level "key: value" pair. An indented line belongs 
to
+// the field above it and is not one itself.
+func splitField(line string) (string, string) {
+       if line == "" || line[0] == ' ' || line[0] == '\t' || 
strings.HasPrefix(strings.TrimSpace(line), "#") {
+               return "", ""
+       }
+       key, value, found := strings.Cut(line, ":")
+       if !found {
+               return "", ""
+       }
+       return strings.TrimSpace(key), strings.TrimSpace(value)
+}
+
+// readBlock collects the indented lines of a folded (>) or literal (|) scalar,
+// and reports how many lines it consumed.
+func readBlock(lines []string, literal bool) (string, int) {
+       collected := []string{}
+       consumed := 0
+       for _, line := range lines {
+               if strings.TrimSpace(line) != "" && !strings.HasPrefix(line, " 
") && !strings.HasPrefix(line, "\t") {
+                       break
+               }
+               consumed++
+               collected = append(collected, strings.TrimSpace(line))
+       }
+
+       separator := " "
+       if literal {
+               separator = "\n"
+       }
+       return strings.TrimSpace(strings.Join(collected, separator)), consumed
+}
+
+func unquote(value string) string {
+       value = strings.TrimSpace(value)
+       if len(value) < 2 {
+               return value
+       }
+       if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' 
&& value[len(value)-1] == '\'') {
+               return strings.TrimSpace(value[1 : len(value)-1])
+       }
+       return value
+}
+
+func truncate(value string) string {
+       runes := []rune(value)
+       if len(runes) <= maxDescriptionRunes {
+               return value
+       }
+       return string(runes[:maxDescriptionRunes]) + "..."
+}
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
new file mode 100644
index 0000000..3ae1091
--- /dev/null
+++ b/agentconfig/layout.go
@@ -0,0 +1,192 @@
+// 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 (
+       "os"
+       "path/filepath"
+       "runtime"
+       "sort"
+
+       "github.com/apache/casbin-gateway/agenthome"
+)
+
+// ManagedEntryName is the MCP server Gateway registers for its own monitoring.
+// It is listed like any other so an operator can see it, but it is never
+// migrated or deleted from here: turning monitoring off is what removes it.
+const ManagedEntryName = "casbin-gateway-agent-monitor"
+
+// layout is where one agent keeps its skills and MCP servers. A nil half means
+// the agent has no such location that Gateway knows about, which the UI shows
+// as unsupported rather than as empty.
+type layout struct {
+       skills *skillLayout
+       mcp    *mcpLayout
+}
+
+// skillLayout is a directory of skill folders, each with a SKILL.md. Every
+// agent Gateway supports uses that same layout, which is what makes copying a
+// skill between two of them a plain directory copy.
+type skillLayout struct {
+       segments []string
+}
+
+func (l *skillLayout) dir(home string) string {
+       return filepath.Join(append([]string{home}, l.segments...)...)
+}
+
+// mcpLayout is one config file holding named MCP server entries.
+type mcpLayout struct {
+       file  func(home string) string
+       store mcpStore
+       // readOnly explains, in the operator's words, why Gateway will not 
write
+       // this file. Empty for the files it may write.
+       readOnly string
+}
+
+func (l *mcpLayout) path(home string) string {
+       return l.file(home)
+}
+
+// mcpStore is the file format half: JSON objects for most agents, TOML tables
+// for Codex. Entries are keyed by server name and hold that server's own
+// fields, in whatever spelling the file used.
+type mcpStore interface {
+       read(file string) (map[string]map[string]any, error)
+       write(file string, name string, entry map[string]any) error
+       remove(file string, name string) error
+}
+
+// layouts is the whole of Gateway's per-agent knowledge. The Codex CLI, the
+// Codex VS Code extension and the ChatGPT desktop app share one CODEX_HOME, 
and
+// Cursor and its CLI share one ~/.cursor, so those ids share a layout.
+var layouts = map[string]layout{
+       "claude-code": {
+               skills: &skillLayout{segments: []string{".claude", "skills"}},
+               mcp:    &mcpLayout{file: under(".claude.json"), store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
+       },
+       "claude-desktop": {
+               mcp: &mcpLayout{file: claudeDesktopConfig, store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
+       },
+       "codex":        codexLayout,
+       "codex-cli":    codexLayout,
+       "codex-vscode": codexLayout,
+       "codex_vscode": codexLayout,
+       "cursor":       cursorLayout,
+       "cursor-agent": cursorLayout,
+       "windsurf": {
+               mcp: &mcpLayout{
+                       file:  under(".codeium", "windsurf", "mcp_config.json"),
+                       store: &jsonStore{paths: [][]string{{"mcpServers"}}},
+               },
+       },
+       "openclaw": {
+               skills: &skillLayout{segments: []string{".openclaw", "skills"}},
+               mcp: &mcpLayout{
+                       file: under(".openclaw", "openclaw.json"),
+                       // OpenClaw has spelled this three ways across 
versions, and reads
+                       // all three; the first is where a new entry goes.
+                       store: &jsonStore{paths: [][]string{{"mcp", "servers"}, 
{"mcp", "mcpServers"}, {"mcpServers"}}},
+               },
+       },
+}
+
+var codexLayout = layout{
+       skills: &skillLayout{segments: []string{".codex", "skills"}},
+       mcp:    &mcpLayout{file: under(".codex", "config.toml"), store: 
&tomlStore{table: "mcp_servers"}},
+}
+
+var cursorLayout = layout{
+       skills: &skillLayout{segments: []string{".cursor", "skills-cursor"}},
+       mcp:    &mcpLayout{file: under(".cursor", "mcp.json"), store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
+}
+
+func layoutOf(agentId string) (layout, bool) {
+       found, ok := layouts[agentId]
+       return found, ok
+}
+
+// Supports reports whether Gateway knows where agentId keeps either kind of
+// configuration, so a caller can skip an installation before resolving a home.
+func Supports(agentId string) bool {
+       _, ok := layouts[agentId]
+       return ok
+}
+
+// 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.
+func McpConfigPath(agentId string, home string) (string, bool) {
+       found, ok := layouts[agentId]
+       if !ok || found.mcp == nil {
+               return "", false
+       }
+       return found.mcp.path(home), true
+}
+
+func under(segments ...string) func(string) string {
+       return func(home string) string {
+               return filepath.Join(append([]string{home}, segments...)...)
+       }
+}
+
+func claudeDesktopConfig(home string) string {
+       switch runtime.GOOS {
+       case "windows":
+               return filepath.Join(home, "AppData", "Roaming", "Claude", 
"claude_desktop_config.json")
+       case "darwin":
+               return filepath.Join(home, "Library", "Application Support", 
"Claude", "claude_desktop_config.json")
+       default:
+               return filepath.Join(home, ".config", "Claude", 
"claude_desktop_config.json")
+       }
+}
+
+func homeOf(owner string) (string, error) {
+       return agenthome.Resolve(owner)
+}
+
+// KnownAgents lists the agent ids Gateway has a layout for, in a stable order.
+func KnownAgents() []string {
+       ids := make([]string, 0, len(layouts))
+       for id := range layouts {
+               ids = append(ids, id)
+       }
+       sort.Strings(ids)
+       return ids
+}
+
+// Configured reports whether agentId has either of its locations on disk for
+// owner. An agent can be set up on an account without Gateway recognizing an
+// installation of it, and those skills are just as real as any other.
+func Configured(agentId string, owner string) bool {
+       found, ok := layouts[agentId]
+       if !ok {
+               return false
+       }
+       home, err := homeOf(owner)
+       if err != nil {
+               return false
+       }
+
+       if found.skills != nil && exists(found.skills.dir(home)) {
+               return true
+       }
+       return found.mcp != nil && exists(found.mcp.path(home))
+}
+
+func exists(path string) bool {
+       _, err := os.Stat(path)
+       return err == nil
+}
diff --git a/agentconfig/mcp_item.go b/agentconfig/mcp_item.go
new file mode 100644
index 0000000..99e4a51
--- /dev/null
+++ b/agentconfig/mcp_item.go
@@ -0,0 +1,103 @@
+// 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 (
+       "encoding/json"
+       "strings"
+
+       "github.com/apache/casbin-gateway/auditutil"
+)
+
+// mcpItems summarizes one file's entries for a list. The fields below are the
+// ones every MCP host spells the same way; anything else an agent keeps in an
+// entry is preserved on copy and shown in the detail view.
+func mcpItems(agentId string, owner string, file string, entries 
map[string]map[string]any) []*Item {
+       items := make([]*Item, 0, len(entries))
+       for name, entry := range entries {
+               item := &Item{
+                       AgentId:   agentId,
+                       Owner:     owner,
+                       Kind:      KindMcp,
+                       Name:      name,
+                       Path:      file,
+                       Command:   commandOf(entry),
+                       Url:       stringField(entry, "url", "serverUrl", 
"endpoint"),
+                       Managed:   name == ManagedEntryName,
+                       Transport: transportOf(entry),
+               }
+               items = append(items, item)
+       }
+       sortItems(items)
+       return items
+}
+
+// mcpDetail shows one entry as it is written in the file, with credentials
+// masked. Copying reads the file again, so what is migrated is the real entry
+// rather than this redacted view of it.
+func mcpDetail(agentId string, owner string, file string, name string, entry 
map[string]any) (*Detail, error) {
+       redacted := auditutil.SanitizeValue("", entry)
+       content, err := json.MarshalIndent(redacted, "", "  ")
+       if err != nil {
+               return nil, err
+       }
+
+       items := mcpItems(agentId, owner, file, map[string]map[string]any{name: 
entry})
+       return &Detail{Item: items[0], Content: string(content)}, nil
+}
+
+// transportOf reports how the agent reaches the server. An entry that names 
its
+// own type is believed; otherwise a command means the server is spawned 
locally
+// and a URL means it is reached over HTTP.
+func transportOf(entry map[string]any) string {
+       if declared := strings.ToLower(stringField(entry, "type", 
"transport")); declared != "" {
+               return declared
+       }
+       if commandOf(entry) != "" {
+               return "stdio"
+       }
+       if stringField(entry, "url", "serverUrl", "endpoint") != "" {
+               return "http"
+       }
+       return ""
+}
+
+// commandOf renders the spawn command the way a shell would show it, so a list
+// row says which binary runs rather than only that the server is local.
+func commandOf(entry map[string]any) string {
+       command := stringField(entry, "command")
+       if command == "" {
+               return ""
+       }
+
+       arguments, _ := entry["args"].([]any)
+       parts := make([]string, 0, len(arguments)+1)
+       parts = append(parts, command)
+       for _, argument := range arguments {
+               if text, ok := argument.(string); ok {
+                       parts = append(parts, text)
+               }
+       }
+       return strings.Join(parts, " ")
+}
+
+func stringField(entry map[string]any, keys ...string) string {
+       for _, key := range keys {
+               if text, ok := entry[key].(string); ok && text != "" {
+                       return text
+               }
+       }
+       return ""
+}
diff --git a/agentconfig/mcp_json.go b/agentconfig/mcp_json.go
new file mode 100644
index 0000000..e001845
--- /dev/null
+++ b/agentconfig/mcp_json.go
@@ -0,0 +1,175 @@
+// 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 (
+       "encoding/json"
+       "fmt"
+       "os"
+       "strings"
+)
+
+// jsonStore holds MCP servers in a JSON object somewhere in a JSON config 
file.
+// paths are the places that object has been spelled; all of them are read, and
+// the first is where a new entry goes when the file has none of them yet.
+type jsonStore struct {
+       paths [][]string
+}
+
+func (store *jsonStore) read(file string) (map[string]map[string]any, error) {
+       config, _, _, err := store.load(file)
+       if err != nil {
+               return nil, err
+       }
+
+       entries := map[string]map[string]any{}
+       for _, path := range store.paths {
+               servers, ok := objectAt(config, path)
+               if !ok {
+                       continue
+               }
+               for name, value := range servers {
+                       entry, ok := value.(map[string]any)
+                       if !ok || entries[name] != nil {
+                               continue
+                       }
+                       entries[name] = entry
+               }
+       }
+       return entries, nil
+}
+
+func (store *jsonStore) write(file string, name string, entry map[string]any) 
error {
+       if err := checkName(name); err != nil {
+               return err
+       }
+       if entry == nil {
+               return fmt.Errorf("the definition of MCP server %q is empty", 
name)
+       }
+
+       config, mode, _, err := store.load(file)
+       if err != nil {
+               return err
+       }
+
+       servers, err := ensureObject(config, store.writePath(config))
+       if err != nil {
+               return err
+       }
+       servers[name] = entry
+       return store.save(file, config, mode)
+}
+
+func (store *jsonStore) remove(file string, name string) error {
+       config, mode, exists, err := store.load(file)
+       if err != nil {
+               return err
+       }
+       if !exists {
+               return fmt.Errorf("%s does not exist", file)
+       }
+
+       removed := false
+       for _, path := range store.paths {
+               servers, ok := objectAt(config, path)
+               if !ok {
+                       continue
+               }
+               if _, found := servers[name]; found {
+                       delete(servers, name)
+                       removed = true
+               }
+       }
+       if !removed {
+               return fmt.Errorf("no MCP server named %q in %s", name, file)
+       }
+       return store.save(file, config, mode)
+}
+
+// writePath is the path a new entry goes to: the one the file already uses, so
+// an edit does not add a second spelling the agent may not merge.
+func (store *jsonStore) writePath(config map[string]any) []string {
+       for _, path := range store.paths {
+               if _, ok := objectAt(config, path); ok {
+                       return path
+               }
+       }
+       return store.paths[0]
+}
+
+func (store *jsonStore) load(file string) (map[string]any, os.FileMode, bool, 
error) {
+       data, mode, err := readFile(file)
+       if err != nil {
+               return nil, 0, false, err
+       }
+       if len(strings.TrimSpace(string(data))) == 0 {
+               return map[string]any{}, mode, len(data) > 0, nil
+       }
+
+       config := map[string]any{}
+       if err := json.Unmarshal(data, &config); err != nil {
+               return nil, 0, false, fmt.Errorf("parse %s: %w", file, err)
+       }
+       return config, mode, true, nil
+}
+
+func (store *jsonStore) save(file string, config map[string]any, mode 
os.FileMode) error {
+       data, err := json.MarshalIndent(config, "", "  ")
+       if err != nil {
+               return err
+       }
+       return writeFile(file, append(data, '\n'), mode)
+}
+
+func objectAt(config map[string]any, path []string) (map[string]any, bool) {
+       current := config
+       for index, key := range path {
+               value, ok := current[key]
+               if !ok {
+                       return nil, false
+               }
+               object, ok := value.(map[string]any)
+               if !ok {
+                       return nil, false
+               }
+               if index == len(path)-1 {
+                       return object, true
+               }
+               current = object
+       }
+       return nil, false
+}
+
+// ensureObject walks path, creating the objects that are missing. A key 
already
+// holding something that is not an object is an error rather than a value to
+// overwrite: that is somebody else's setting.
+func ensureObject(config map[string]any, path []string) (map[string]any, 
error) {
+       current := config
+       for _, key := range path {
+               value, ok := current[key]
+               if !ok {
+                       created := map[string]any{}
+                       current[key] = created
+                       current = created
+                       continue
+               }
+               object, ok := value.(map[string]any)
+               if !ok {
+                       return nil, fmt.Errorf("%q is not a JSON object", 
strings.Join(path, "."))
+               }
+               current = object
+       }
+       return current, nil
+}
diff --git a/agentconfig/mcp_toml.go b/agentconfig/mcp_toml.go
new file mode 100644
index 0000000..c68c746
--- /dev/null
+++ b/agentconfig/mcp_toml.go
@@ -0,0 +1,291 @@
+// 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 (
+       "fmt"
+       "sort"
+       "strconv"
+       "strings"
+
+       "github.com/BurntSushi/toml"
+)
+
+// tomlStore holds MCP servers as sub-tables of one TOML table, which is how
+// Codex keeps them in config.toml.
+//
+// Reading decodes the file, but writing edits its text: config.toml is a
+// hand-written file with comments and formatting in it, and re-encoding a
+// decoded document would hand it back stripped of both.
+type tomlStore struct {
+       table string
+}
+
+func (store *tomlStore) read(file string) (map[string]map[string]any, error) {
+       data, _, err := readFile(file)
+       if err != nil {
+               return nil, err
+       }
+
+       document := map[string]any{}
+       if len(strings.TrimSpace(string(data))) > 0 {
+               if err := toml.Unmarshal(data, &document); err != nil {
+                       return nil, fmt.Errorf("parse %s: %w", file, err)
+               }
+       }
+
+       entries := map[string]map[string]any{}
+       servers, ok := document[store.table].(map[string]any)
+       if !ok {
+               return entries, nil
+       }
+       for name, value := range servers {
+               if entry, ok := value.(map[string]any); ok {
+                       entries[name] = entry
+               }
+       }
+       return entries, nil
+}
+
+func (store *tomlStore) write(file string, name string, entry map[string]any) 
error {
+       if err := checkName(name); err != nil {
+               return err
+       }
+       if entry == nil {
+               return fmt.Errorf("the definition of MCP server %q is empty", 
name)
+       }
+
+       block, err := store.render(name, entry)
+       if err != nil {
+               return err
+       }
+
+       data, mode, err := readFile(file)
+       if err != nil {
+               return err
+       }
+       text, _ := store.cut(string(data), name)
+
+       text = strings.TrimRight(text, "\n")
+       if text != "" {
+               text += "\n\n"
+       }
+       return writeFile(file, []byte(text+block), mode)
+}
+
+func (store *tomlStore) remove(file string, name string) error {
+       data, mode, err := readFile(file)
+       if err != nil {
+               return err
+       }
+
+       text, found := store.cut(string(data), name)
+       if !found {
+               return fmt.Errorf("no MCP server named %q in %s", name, file)
+       }
+       return writeFile(file, []byte(text), mode)
+}
+
+// cut removes one server's table and its sub-tables from the document text,
+// leaving every other line, comment and blank line exactly as it was.
+func (store *tomlStore) cut(text string, name string) (string, bool) {
+       lines := strings.Split(text, "\n")
+       kept := make([]string, 0, len(lines))
+       dropping := false
+       found := false
+
+       for _, line := range lines {
+               header, isHeader := tableHeader(line)
+               if isHeader {
+                       dropping = store.owns(header, name)
+                       found = found || dropping
+               }
+               if !dropping {
+                       kept = append(kept, line)
+               }
+       }
+
+       result := strings.Join(kept, "\n")
+       if found {
+               result = strings.TrimRight(result, "\n") + "\n"
+       }
+       return result, found
+}
+
+// owns reports whether a table header belongs to one server: 
[mcp_servers.name]
+// itself, or any sub-table of it such as [mcp_servers.name.env].
+func (store *tomlStore) owns(header []string, name string) bool {
+       return len(header) >= 2 && header[0] == store.table && header[1] == name
+}
+
+// render writes one server as TOML text. Scalars come first and sub-tables
+// after, because everything following a sub-table header belongs to it.
+func (store *tomlStore) render(name string, entry map[string]any) (string, 
error) {
+       scalars := &strings.Builder{}
+       tables := &strings.Builder{}
+
+       fmt.Fprintf(scalars, "[%s.%s]\n", tomlKey(store.table), tomlKey(name))
+       for _, key := range sortedKeys(entry) {
+               value := entry[key]
+               if nested, ok := value.(map[string]any); ok {
+                       fmt.Fprintf(tables, "\n[%s.%s.%s]\n", 
tomlKey(store.table), tomlKey(name), tomlKey(key))
+                       for _, nestedKey := range sortedKeys(nested) {
+                               text, err := tomlValue(nested[nestedKey])
+                               if err != nil {
+                                       return "", fmt.Errorf("%s.%s: %w", key, 
nestedKey, err)
+                               }
+                               fmt.Fprintf(tables, "%s = %s\n", 
tomlKey(nestedKey), text)
+                       }
+                       continue
+               }
+
+               text, err := tomlValue(value)
+               if err != nil {
+                       return "", fmt.Errorf("%s: %w", key, err)
+               }
+               fmt.Fprintf(scalars, "%s = %s\n", tomlKey(key), text)
+       }
+       return scalars.String() + tables.String(), nil
+}
+
+// tableHeader parses a [a.b.c] line into its dotted parts. Anything else, an
+// array-of-tables header included, is not a header this package edits.
+func tableHeader(line string) ([]string, bool) {
+       trimmed := strings.TrimSpace(line)
+       if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "[[") 
{
+               return nil, false
+       }
+       end := strings.LastIndex(trimmed, "]")
+       if end <= 0 {
+               return nil, false
+       }
+       // A comment may follow the header, but nothing else may.
+       if rest := strings.TrimSpace(trimmed[end+1:]); rest != "" && 
!strings.HasPrefix(rest, "#") {
+               return nil, false
+       }
+       return splitTomlKey(trimmed[1:end]), true
+}
+
+// splitTomlKey splits a dotted key, honouring quoted parts and unquoting them,
+// so ["my-server"] and [my-server] name the same table.
+func splitTomlKey(key string) []string {
+       parts := []string{}
+       current := &strings.Builder{}
+       quote := rune(0)
+
+       for _, char := range key {
+               switch {
+               case quote != 0:
+                       if char == quote {
+                               quote = 0
+                               continue
+                       }
+                       current.WriteRune(char)
+               case char == '"' || char == '\'':
+                       quote = char
+               case char == '.':
+                       parts = append(parts, 
strings.TrimSpace(current.String()))
+                       current.Reset()
+               default:
+                       current.WriteRune(char)
+               }
+       }
+       return append(parts, strings.TrimSpace(current.String()))
+}
+
+// tomlKey quotes a key that is not a bare TOML key.
+func tomlKey(key string) string {
+       for _, char := range key {
+               bare := char == '-' || char == '_' ||
+                       (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 
'Z') || (char >= '0' && char <= '9')
+               if !bare {
+                       return quoteToml(key)
+               }
+       }
+       if key == "" {
+               return `""`
+       }
+       return key
+}
+
+func tomlValue(value any) (string, error) {
+       switch typed := value.(type) {
+       case string:
+               return quoteToml(typed), nil
+       case bool:
+               return strconv.FormatBool(typed), nil
+       case int:
+               return strconv.Itoa(typed), nil
+       case int64:
+               return strconv.FormatInt(typed, 10), nil
+       case float64:
+               // JSON has no integers, so a whole number arriving from a JSON 
config
+               // has to go back out as one: Codex rejects 120.0 for a timeout.
+               if typed == float64(int64(typed)) {
+                       return strconv.FormatInt(int64(typed), 10), nil
+               }
+               return strconv.FormatFloat(typed, 'f', -1, 64), nil
+       case []any:
+               items := make([]string, 0, len(typed))
+               for _, item := range typed {
+                       text, err := tomlValue(item)
+                       if err != nil {
+                               return "", err
+                       }
+                       items = append(items, text)
+               }
+               return "[" + strings.Join(items, ", ") + "]", nil
+       case nil:
+               return "", fmt.Errorf("a null value has no TOML equivalent")
+       default:
+               return "", fmt.Errorf("a %T value has no TOML equivalent", 
value)
+       }
+}
+
+func quoteToml(value string) string {
+       escaped := &strings.Builder{}
+       escaped.WriteByte('"')
+       for _, char := range value {
+               switch char {
+               case '"':
+                       escaped.WriteString(`\"`)
+               case '\\':
+                       escaped.WriteString(`\\`)
+               case '\n':
+                       escaped.WriteString(`\n`)
+               case '\r':
+                       escaped.WriteString(`\r`)
+               case '\t':
+                       escaped.WriteString(`\t`)
+               default:
+                       if char < 0x20 {
+                               fmt.Fprintf(escaped, `\u%04X`, char)
+                               continue
+                       }
+                       escaped.WriteRune(char)
+               }
+       }
+       escaped.WriteByte('"')
+       return escaped.String()
+}
+
+func sortedKeys(entry map[string]any) []string {
+       keys := make([]string, 0, len(entry))
+       for key := range entry {
+               keys = append(keys, key)
+       }
+       sort.Strings(keys)
+       return keys
+}
diff --git a/agentconfig/mcp_toml_test.go b/agentconfig/mcp_toml_test.go
new file mode 100644
index 0000000..d71c21b
--- /dev/null
+++ b/agentconfig/mcp_toml_test.go
@@ -0,0 +1,207 @@
+// 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 (
+       "os"
+       "path/filepath"
+       "strings"
+       "testing"
+)
+
+const codexConfig = `# the operator's own notes
+model = "gpt-5"
+
+[features]
+js_repl = false
+
+[mcp_servers.node_repl]
+command = 'C:\bin\node_repl.exe'
+args = []
+startup_timeout_sec = 120
+
+[mcp_servers.node_repl.env]
+NODE_REPL_NODE_PATH = 'C:\bin\node.exe'
+
+[desktop]
+followUpQueueMode = "queue"
+`
+
+func codexFile(t *testing.T, content string) (*tomlStore, string) {
+       t.Helper()
+       path := filepath.Join(t.TempDir(), "config.toml")
+       if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+               t.Fatal(err)
+       }
+       return &tomlStore{table: "mcp_servers"}, path
+}
+
+func TestTomlReadsServersAndTheirEnvironment(t *testing.T) {
+       store, path := codexFile(t, codexConfig)
+
+       entries, err := store.read(path)
+       if err != nil {
+               t.Fatal(err)
+       }
+       entry, ok := entries["node_repl"]
+       if !ok {
+               t.Fatalf("entries = %v", entries)
+       }
+       if entry["command"] != `C:\bin\node_repl.exe` {
+               t.Fatalf("command = %v", entry["command"])
+       }
+       environment, ok := entry["env"].(map[string]any)
+       if !ok || environment["NODE_REPL_NODE_PATH"] != `C:\bin\node.exe` {
+               t.Fatalf("env = %v", entry["env"])
+       }
+}
+
+func TestTomlRemoveKeepsCommentsAndOtherTables(t *testing.T) {
+       store, path := codexFile(t, codexConfig)
+
+       if err := store.remove(path, "node_repl"); err != nil {
+               t.Fatal(err)
+       }
+
+       content := readBack(t, path)
+       if strings.Contains(content, "mcp_servers") {
+               t.Fatalf("the table survived: %s", content)
+       }
+       for _, kept := range []string{"# the operator's own notes", `model = 
"gpt-5"`, "[features]", "[desktop]"} {
+               if !strings.Contains(content, kept) {
+                       t.Fatalf("remove dropped %q: %s", kept, content)
+               }
+       }
+}
+
+func TestTomlRemoveReportsAMissingServer(t *testing.T) {
+       store, path := codexFile(t, codexConfig)
+
+       if err := store.remove(path, "absent"); err == nil {
+               t.Fatal("removing a server that is not there succeeded")
+       }
+       if readBack(t, path) != codexConfig {
+               t.Fatal("a failed remove rewrote the file")
+       }
+}
+
+func TestTomlWriteReplacesOneServerInPlace(t *testing.T) {
+       store, path := codexFile(t, codexConfig)
+
+       entry := map[string]any{
+               "command":             "npx",
+               "args":                []any{"-y", 
"@modelcontextprotocol/server-files"},
+               "startup_timeout_sec": float64(30),
+               "env":                 map[string]any{"API_KEY": "secret"},
+       }
+       if err := store.write(path, "node_repl", entry); err != nil {
+               t.Fatal(err)
+       }
+
+       content := readBack(t, path)
+       if strings.Contains(content, "node_repl.exe") {
+               t.Fatalf("the old definition survived: %s", content)
+       }
+       if !strings.Contains(content, "startup_timeout_sec = 30") {
+               t.Fatalf("a whole number was written as a float: %s", content)
+       }
+       if !strings.Contains(content, "[desktop]") {
+               t.Fatalf("write dropped a later table: %s", content)
+       }
+
+       entries, err := store.read(path)
+       if err != nil {
+               t.Fatal(err)
+       }
+       written := entries["node_repl"]
+       if written["command"] != "npx" {
+               t.Fatalf("command = %v", written["command"])
+       }
+       arguments, _ := written["args"].([]any)
+       if len(arguments) != 2 || arguments[0] != "-y" {
+               t.Fatalf("args = %v", written["args"])
+       }
+       environment, _ := written["env"].(map[string]any)
+       if environment["API_KEY"] != "secret" {
+               t.Fatalf("env = %v", written["env"])
+       }
+}
+
+func TestTomlWriteCreatesAMissingFile(t *testing.T) {
+       store := &tomlStore{table: "mcp_servers"}
+       path := filepath.Join(t.TempDir(), "nested", "config.toml")
+
+       if err := store.write(path, "files", map[string]any{"command": "npx"}); 
err != nil {
+               t.Fatal(err)
+       }
+       entries, err := store.read(path)
+       if err != nil {
+               t.Fatal(err)
+       }
+       if entries["files"]["command"] != "npx" {
+               t.Fatalf("entries = %v", entries)
+       }
+}
+
+func TestTomlQuotesANameThatIsNotABareKey(t *testing.T) {
+       store, path := codexFile(t, codexConfig)
+
+       if err := store.write(path, "my server", map[string]any{"command": 
"npx"}); err != nil {
+               t.Fatal(err)
+       }
+       if !strings.Contains(readBack(t, path), `[mcp_servers."my server"]`) {
+               t.Fatalf("content = %s", readBack(t, path))
+       }
+
+       if err := store.remove(path, "my server"); err != nil {
+               t.Fatal(err)
+       }
+       if strings.Contains(readBack(t, path), "my server") {
+               t.Fatalf("a quoted name was not matched on remove: %s", 
readBack(t, path))
+       }
+}
+
+func TestTomlRejectsAValueItCannotWrite(t *testing.T) {
+       store, path := codexFile(t, codexConfig)
+
+       err := store.write(path, "files", map[string]any{"command": "npx", 
"deep": map[string]any{
+               "nested": map[string]any{"too": "far"},
+       }})
+       if err == nil {
+               t.Fatal("a value with no TOML equivalent was accepted")
+       }
+       if readBack(t, path) != codexConfig {
+               t.Fatal("a rejected write still changed the file")
+       }
+}
+
+func TestFrontMatterReadsFoldedAndQuotedFields(t *testing.T) {
+       name, description := parseFrontMatter("---\nname: babysit\ndescription: 
>-\n  Keep a PR merge-ready by\n  fixing CI in a loop.\n---\n# body\n")
+       if name != "babysit" {
+               t.Fatalf("name = %q", name)
+       }
+       if description != "Keep a PR merge-ready by fixing CI in a loop." {
+               t.Fatalf("description = %q", description)
+       }
+
+       _, quoted := parseFrontMatter("---\nname: a\ndescription: 'Work with 
PDFs'\n---\n")
+       if quoted != "Work with PDFs" {
+               t.Fatalf("description = %q", quoted)
+       }
+
+       if _, none := parseFrontMatter("# no front matter\ndescription: not a 
field\n"); none != "" {
+               t.Fatalf("description = %q", none)
+       }
+}
diff --git a/agentconfig/skill.go b/agentconfig/skill.go
new file mode 100644
index 0000000..cb7d228
--- /dev/null
+++ b/agentconfig/skill.go
@@ -0,0 +1,290 @@
+// 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 (
+       "fmt"
+       "io"
+       "io/fs"
+       "os"
+       "path/filepath"
+       "sort"
+       "strings"
+)
+
+const (
+       // skillFile is the manifest that makes a directory a skill rather than
+       // whatever else the operator keeps next to their skills.
+       skillFile = "SKILL.md"
+       // maxSkillFileBytes bounds what the detail view loads: a skill is 
meant to
+       // be read by a model, but nothing stops one from carrying a large file.
+       maxSkillFileBytes = 256 * 1024
+       // maxSkillFiles bounds the file list shown beside a skill's manifest.
+       maxSkillFiles = 200
+)
+
+// readSkills lists the skill folders in dir. A directory that does not exist 
is
+// an agent with no skills yet, not an error.
+func readSkills(agentId string, owner string, dir string) ([]*Item, error) {
+       entries, err := os.ReadDir(dir)
+       if os.IsNotExist(err) {
+               return []*Item{}, nil
+       }
+       if err != nil {
+               return []*Item{}, err
+       }
+
+       items := []*Item{}
+       for _, entry := range entries {
+               // A dot directory is the agent's own bookkeeping, such as the 
manifest
+               // Cursor writes beside the skills it manages.
+               if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
+                       continue
+               }
+               path := filepath.Join(dir, entry.Name())
+               manifest, ok := manifestPath(path)
+               if !ok {
+                       continue
+               }
+
+               item := &Item{
+                       AgentId: agentId,
+                       Owner:   owner,
+                       Kind:    KindSkill,
+                       Name:    entry.Name(),
+                       Path:    path,
+               }
+               if raw, err := os.ReadFile(manifest); err == nil {
+                       _, item.Description = parseFrontMatter(string(raw))
+               }
+               item.Files, item.Bytes = measure(path)
+               items = append(items, item)
+       }
+       sortItems(items)
+       return items, nil
+}
+
+// skillDetail loads one skill's manifest and the names of the files shipped
+// with it.
+func skillDetail(agentId string, owner string, dir string, name string) 
(*Detail, error) {
+       if err := checkName(name); err != nil {
+               return nil, err
+       }
+
+       path := filepath.Join(dir, name)
+       manifest, ok := manifestPath(path)
+       if !ok {
+               return nil, fmt.Errorf("no %s in %s", skillFile, path)
+       }
+
+       raw, err := os.ReadFile(manifest)
+       if err != nil {
+               return nil, err
+       }
+       content := string(raw)
+       if len(raw) > maxSkillFileBytes {
+               content = string(raw[:maxSkillFileBytes]) + "\n\n... truncated 
by Gateway ..."
+       }
+
+       _, description := parseFrontMatter(content)
+       files, bytes := measure(path)
+       item := &Item{
+               AgentId:     agentId,
+               Owner:       owner,
+               Kind:        KindSkill,
+               Name:        name,
+               Description: description,
+               Path:        path,
+               Files:       files,
+               Bytes:       bytes,
+       }
+       return &Detail{Item: item, Content: content, Files: listFiles(path)}, 
nil
+}
+
+// deleteSkill removes one skill folder. It refuses a directory without a
+// manifest, so a mistyped name cannot delete something that is not a skill.
+func deleteSkill(dir string, name string) error {
+       if err := checkName(name); err != nil {
+               return err
+       }
+
+       path := filepath.Join(dir, name)
+       if _, ok := manifestPath(path); !ok {
+               return fmt.Errorf("%s is not a skill folder", path)
+       }
+       return os.RemoveAll(path)
+}
+
+// copySkill copies one skill folder into another agent's skills directory,
+// replacing what is there. The two agents Gateway supports keep skills in the
+// same format, so the copy is the folder itself and nothing is converted.
+func copySkill(from string, dir string, name string) (string, error) {
+       if err := checkName(name); err != nil {
+               return "", err
+       }
+       if _, ok := manifestPath(from); !ok {
+               return "", fmt.Errorf("%s is not a skill folder", from)
+       }
+
+       to := filepath.Join(dir, name)
+       same, err := samePath(from, to)
+       if err != nil {
+               return "", err
+       }
+       if same {
+               return "", fmt.Errorf("%s is already the source of this copy", 
to)
+       }
+
+       staged := to + ".gateway-copy"
+       if err := os.RemoveAll(staged); err != nil {
+               return "", err
+       }
+       if err := copyTree(from, staged); err != nil {
+               os.RemoveAll(staged)
+               return "", err
+       }
+       if err := os.RemoveAll(to); err != nil {
+               os.RemoveAll(staged)
+               return "", err
+       }
+       if err := os.Rename(staged, to); err != nil {
+               os.RemoveAll(staged)
+               return "", err
+       }
+       return to, nil
+}
+
+// manifestPath finds the skill manifest inside a folder. The name is matched
+// case-insensitively because the agents that write it do not agree on the case
+// on the file systems that care.
+func manifestPath(dir string) (string, bool) {
+       path := filepath.Join(dir, skillFile)
+       if _, err := os.Stat(path); err == nil {
+               return path, true
+       }
+
+       entries, err := os.ReadDir(dir)
+       if err != nil {
+               return "", false
+       }
+       for _, entry := range entries {
+               if !entry.IsDir() && strings.EqualFold(entry.Name(), skillFile) 
{
+                       return filepath.Join(dir, entry.Name()), true
+               }
+       }
+       return "", false
+}
+
+func measure(dir string) (int, int64) {
+       files := 0
+       var bytes int64
+       filepath.WalkDir(dir, func(_ string, entry fs.DirEntry, err error) 
error {
+               if err != nil || entry.IsDir() {
+                       return nil
+               }
+               files++
+               if info, err := entry.Info(); err == nil {
+                       bytes += info.Size()
+               }
+               return nil
+       })
+       return files, bytes
+}
+
+// listFiles names what a skill ships besides its manifest, in slash form so 
the
+// list reads the same on every platform.
+func listFiles(dir string) []string {
+       files := []string{}
+       filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) 
error {
+               if err != nil || entry.IsDir() || len(files) >= maxSkillFiles {
+                       return nil
+               }
+               relative, err := filepath.Rel(dir, path)
+               if err != nil || strings.EqualFold(relative, skillFile) {
+                       return nil
+               }
+               files = append(files, filepath.ToSlash(relative))
+               return nil
+       })
+       sort.Strings(files)
+       return files
+}
+
+func copyTree(from string, to string) error {
+       return filepath.WalkDir(from, func(path string, entry fs.DirEntry, err 
error) error {
+               if err != nil {
+                       return err
+               }
+               relative, err := filepath.Rel(from, path)
+               if err != nil {
+                       return err
+               }
+               target := filepath.Join(to, relative)
+
+               if entry.IsDir() {
+                       return os.MkdirAll(target, 0o755)
+               }
+               // A symbolic link in a skill folder would point at the source 
agent's
+               // directory, so it is left behind rather than copied as a 
broken link.
+               if !entry.Type().IsRegular() {
+                       return nil
+               }
+               return copyOneFile(path, target, entry)
+       })
+}
+
+func copyOneFile(path string, target string, entry fs.DirEntry) error {
+       info, err := entry.Info()
+       if err != nil {
+               return err
+       }
+
+       source, err := os.Open(path)
+       if err != nil {
+               return err
+       }
+       defer source.Close()
+
+       if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+               return err
+       }
+       destination, err := os.OpenFile(target, 
os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
+       if err != nil {
+               return err
+       }
+       if _, err := io.Copy(destination, source); err != nil {
+               destination.Close()
+               return err
+       }
+       return destination.Close()
+}
+
+// samePath compares two paths by what they resolve to, so a copy cannot be
+// asked to overwrite its own source through a link or a differently spelled
+// path to the same directory.
+func samePath(left string, right string) (bool, error) {
+       leftInfo, err := os.Stat(left)
+       if err != nil {
+               return false, err
+       }
+       rightInfo, err := os.Stat(right)
+       if os.IsNotExist(err) {
+               return false, nil
+       }
+       if err != nil {
+               return false, err
+       }
+       return os.SameFile(leftInfo, rightInfo), nil
+}
diff --git a/agentpatch/home.go b/agenthome/home.go
similarity index 81%
rename from agentpatch/home.go
rename to agenthome/home.go
index 24659ca..d2323c3 100644
--- a/agentpatch/home.go
+++ b/agenthome/home.go
@@ -12,7 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package agentpatch
+// Package agenthome resolves the home directory of an agent installation's
+// owner. Both the monitoring patches and the skill/MCP configuration reader
+// write into that home, and neither should own the account lookup.
+package agenthome
 
 import (
        "fmt"
@@ -22,12 +25,12 @@ import (
        "strings"
 )
 
-// homeOf resolves the home directory of the installation owner. Patching 
writes
-// into that home, so guessing here would silently modify the wrong account's
-// configuration: an unresolvable owner is an error rather than a fallback to
-// whichever account happens to run Gateway.
-func homeOf(target Target) (string, error) {
-       owner := strings.TrimSpace(target.Owner)
+// Resolve returns the home directory of owner. Callers write into that home, 
so
+// guessing here would silently modify the wrong account's configuration: an
+// unresolvable owner is an error rather than a fallback to whichever account
+// happens to run Gateway.
+func Resolve(owner string) (string, error) {
+       owner = strings.TrimSpace(owner)
        if owner == "" {
                home, err := os.UserHomeDir()
                if err != nil {
diff --git a/agentpatch/claude_code.go b/agentpatch/claude_code.go
index 297c9b8..9e20585 100644
--- a/agentpatch/claude_code.go
+++ b/agentpatch/claude_code.go
@@ -19,6 +19,7 @@ import (
        "os"
        "path/filepath"
 
+       "github.com/apache/casbin-gateway/agenthome"
        "github.com/apache/casbin-gateway/agenthook"
 )
 
@@ -120,7 +121,7 @@ func (claudeCodePatcher) Status(target Target) (Status, 
error) {
 }
 
 func claudeCodeConfigPath(target Target) (string, error) {
-       home, err := homeOf(target)
+       home, err := agenthome.Resolve(target.Owner)
        if err != nil {
                return "", err
        }
diff --git a/agentpatch/claude_desktop.go b/agentpatch/claude_desktop.go
index 40f396d..c758900 100644
--- a/agentpatch/claude_desktop.go
+++ b/agentpatch/claude_desktop.go
@@ -19,10 +19,11 @@ import (
        "errors"
        "fmt"
        "os"
-       "path/filepath"
        "runtime"
        "strings"
 
+       "github.com/apache/casbin-gateway/agentconfig"
+       "github.com/apache/casbin-gateway/agenthome"
        "github.com/apache/casbin-gateway/agentmonitor"
        "github.com/apache/casbin-gateway/mcpserver"
 )
@@ -160,18 +161,15 @@ func (p claudeDesktopPatcher) PatchNotice(patched bool) 
(string, string) {
 }
 
 func (p claudeDesktopPatcher) configPath(target Target) (string, error) {
-       home, err := homeOf(target)
+       home, err := agenthome.Resolve(target.Owner)
        if err != nil {
                return "", err
        }
-       switch runtime.GOOS {
-       case "windows":
-               return filepath.Join(home, "AppData", "Roaming", "Claude", 
"claude_desktop_config.json"), nil
-       case "darwin":
-               return filepath.Join(home, "Library", "Application Support", 
"Claude", "claude_desktop_config.json"), nil
-       default:
-               return filepath.Join(home, ".config", "Claude", 
"claude_desktop_config.json"), nil
+       path, ok := agentconfig.McpConfigPath(p.AgentId(), home)
+       if !ok {
+               return "", errors.New("Gateway does not know where Claude 
Desktop keeps its MCP servers")
        }
+       return path, nil
 }
 
 func (p claudeDesktopPatcher) serverEntry(target Target) (map[string]any, 
error) {
diff --git a/agentpatch/openclaw.go b/agentpatch/openclaw.go
index 33ce6ce..85fa2bd 100644
--- a/agentpatch/openclaw.go
+++ b/agentpatch/openclaw.go
@@ -22,6 +22,7 @@ import (
        "path/filepath"
        "strings"
 
+       "github.com/apache/casbin-gateway/agenthome"
        "github.com/apache/casbin-gateway/agentmonitor"
 )
 
@@ -118,7 +119,7 @@ type openclawLayout struct {
 }
 
 func (p openclawPatcher) layoutOf(target Target) (openclawLayout, error) {
-       home, err := homeOf(target)
+       home, err := agenthome.Resolve(target.Owner)
        if err != nil {
                return openclawLayout{}, err
        }
diff --git a/agentpatch/state.go b/agentpatch/state.go
index 1ffabab..6b38e19 100644
--- a/agentpatch/state.go
+++ b/agentpatch/state.go
@@ -26,12 +26,14 @@ import (
        "sync"
        "time"
 
+       "github.com/apache/casbin-gateway/agentconfig"
        "github.com/apache/casbin-gateway/conf"
 )
 
 // gatewayEntryName is the identifier Gateway writes into every agent
-// configuration it touches, so operators can find and remove it by hand.
-const gatewayEntryName = "casbin-gateway-agent-monitor"
+// configuration it touches, so operators can find and remove it by hand. The
+// skill and MCP listings recognize it by the same name.
+const gatewayEntryName = agentconfig.ManagedEntryName
 
 type changeKind string
 
diff --git a/controllers/agent_config.go b/controllers/agent_config.go
new file mode 100644
index 0000000..58041c3
--- /dev/null
+++ b/controllers/agent_config.go
@@ -0,0 +1,197 @@
+// 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"
+       "slices"
+       "sort"
+
+       "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/agentconfig"
+)
+
+// agentConfigView is one installation's configuration, named the way the agent
+// list names it.
+type agentConfigView struct {
+       *agentconfig.Inventory
+
+       Name string `json:"name"`
+       Path string `json:"path,omitempty"`
+       // Installed is false for configuration found without a matching 
installation.
+       Installed bool `json:"installed"`
+       // SharedWith names the other agents reading the same files. Cursor and 
its
+       // CLI share one ~/.cursor, and the Codex front ends one CODEX_HOME, so 
those
+       // appear once with the others listed here rather than as separate 
copies of
+       // the same configuration.
+       SharedWith []string `json:"sharedWith,omitempty"`
+}
+
+// GetAgentConfigs lists the skills and MCP servers of every agent on this host
+// whose configuration layout Gateway knows.
+func (c *ApiController) GetAgentConfigs() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       installations, err := agent.Scan(c.GetString("refresh") == "true")
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       collected := &agentConfigs{seen: map[string]*agentConfigView{}}
+       for _, installation := range installations {
+               collected.add(installation.AgentId, installation.Owner, 
installation.Name, installation.Path, true)
+       }
+
+       // An agent can be configured on the account Gateway runs as without its
+       // installation being recognized — installed through a package manager
+       // Gateway does not scan, or already uninstalled. Its skills are on disk
+       // either way, so the page would be wrong to leave them out.
+       for _, agentId := range agentconfig.KnownAgents() {
+               name := agent.DisplayNameOf(agentId)
+               if name == "" || !agentconfig.Configured(agentId, "") {
+                       continue
+               }
+               collected.add(agentId, "", name, "", false)
+       }
+
+       sort.Slice(collected.views, func(i, j int) bool { return 
collected.views[i].Name < collected.views[j].Name })
+       c.ResponseOk(collected.views)
+}
+
+// agentConfigs collects one view per configuration location. Several agent ids
+// can read the same files, and the page lists what is on disk rather than who
+// reads it, so the second id to arrive is recorded as a reader of the first.
+type agentConfigs struct {
+       views []*agentConfigView
+       seen  map[string]*agentConfigView
+}
+
+func (collected *agentConfigs) add(agentId string, owner string, name string, 
path string, installed bool) {
+       if !agentconfig.Supports(agentId) {
+               return
+       }
+
+       inventory := agentconfig.Read(agentId, owner)
+       key := inventory.SkillsDir + "\x00" + inventory.McpFile
+       if first, ok := collected.seen[key]; ok {
+               if first.AgentId != agentId && 
!slices.Contains(first.SharedWith, name) {
+                       first.SharedWith = append(first.SharedWith, name)
+               }
+               return
+       }
+
+       view := &agentConfigView{
+               Inventory: inventory,
+               Name:      name,
+               Path:      path,
+               Installed: installed,
+       }
+       collected.seen[key] = view
+       collected.views = append(collected.views, view)
+}
+
+// GetAgentConfigItem returns one skill's manifest or one MCP server's entry.
+func (c *ApiController) GetAgentConfigItem() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       detail, err := agentconfig.ReadDetail(
+               c.GetString("agentId"), c.GetString("owner"), 
c.GetString("kind"), c.GetString("name"))
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(detail)
+}
+
+// DeleteAgentConfigItem removes one skill or MCP server from the agent's own
+// configuration.
+func (c *ApiController) DeleteAgentConfigItem() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               AgentId string `json:"agentId"`
+               Owner   string `json:"owner"`
+               Kind    string `json:"kind"`
+               Name    string `json:"name"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if form.Name == agentconfig.ManagedEntryName {
+               c.ResponseError("this entry belongs to Gateway's agent 
monitoring; turn monitoring off to remove it")
+               return
+       }
+
+       if err := agentconfig.Delete(form.AgentId, form.Owner, form.Kind, 
form.Name); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(form.Name)
+}
+
+// PlanAgentConfigCopy reports what CopyAgentConfig would do, so the operator
+// sees which targets already have an item before anything is written.
+func (c *ApiController) PlanAgentConfigCopy() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       request, ok := c.readCopyRequest()
+       if !ok {
+               return
+       }
+       planned, err := agentconfig.Plan(request)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(planned)
+}
+
+// CopyAgentConfig migrates the selected skills or MCP servers into the target
+// agents' own configuration files.
+func (c *ApiController) CopyAgentConfig() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       request, ok := c.readCopyRequest()
+       if !ok {
+               return
+       }
+       result, err := agentconfig.Copy(request)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(result)
+}
+
+func (c *ApiController) readCopyRequest() (agentconfig.CopyRequest, bool) {
+       var request agentconfig.CopyRequest
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil 
{
+               c.ResponseError(err.Error())
+               return request, false
+       }
+       return request, true
+}
diff --git a/routers/router.go b/routers/router.go
index 589968c..4932a4e 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -70,6 +70,11 @@ func initAPI() {
        beego.Router("/api/get-agent-records", &controllers.ApiController{}, 
"GET:GetAgentRecords")
        beego.Router("/api/get-agent-sessions", &controllers.ApiController{}, 
"GET:GetAgentSessions")
        beego.Router("/api/add-agent-record", &controllers.ApiController{}, 
"POST:AddAgentRecord")
+       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/plan-agent-config-copy", 
&controllers.ApiController{}, "POST:PlanAgentConfigCopy")
+       beego.Router("/api/copy-agent-config", &controllers.ApiController{}, 
"POST:CopyAgentConfig")
 
        beego.Router("/api/get-records", &controllers.ApiController{}, 
"GET:GetRecords")
        beego.Router("/api/get-record", &controllers.ApiController{}, 
"GET:GetRecord")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 8ecade2..94268b5 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -27,6 +27,7 @@ import {Loading} from "@/components/shared/loading";
 import {TooltipProvider} from "@/components/ui/tooltip";
 import {cn} from "@/lib/utils";
 import AccountPage from "@/pages/AccountPage";
+import AgentConfigsPage from "@/pages/AgentConfigsPage";
 import AgentDashboardPage from "@/pages/AgentDashboardPage";
 import AgentDetailPage from "@/pages/AgentDetailPage";
 import AgentRecordsPage from "@/pages/AgentRecordsPage";
@@ -255,6 +256,10 @@ export default function App() {
                   path="/agents/:agentId"
                   element={requireSignin(user => <AgentDetailPage 
account={user} />)}
                 />
+                <Route
+                  path="/agent-configs"
+                  element={requireSignin(user => <AgentConfigsPage 
account={user} />)}
+                />
                 <Route
                   path="/agent-records"
                   element={requireSignin(user => <AgentRecordsPage 
account={user} />)}
diff --git a/web/src/backend/AgentConfigBackend.ts 
b/web/src/backend/AgentConfigBackend.ts
new file mode 100644
index 0000000..770cbee
--- /dev/null
+++ b/web/src/backend/AgentConfigBackend.ts
@@ -0,0 +1,58 @@
+// 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 {
+  AgentConfigDetail,
+  AgentConfigInventory,
+  AgentConfigKind,
+  AgentConfigPlanItem,
+} from "@/types";
+
+export interface CopyRequest {
+  owner: string;
+  from: string;
+  to: string[];
+  kind: AgentConfigKind;
+  names: string[];
+  overwrite: boolean;
+}
+
+export function getAgentConfigs(forceRefresh = false) {
+  return request<AgentConfigInventory[]>(`/api/get-agent-configs${forceRefresh 
? "?refresh=true" : ""}`);
+}
+
+export function getAgentConfigItem(agentId: string, owner: string, kind: 
AgentConfigKind, name: string) {
+  return request<AgentConfigDetail>(
+    `/api/get-agent-config-item${query({agentId: agentId, owner: owner, kind: 
kind, name: name})}`,
+  );
+}
+
+export function deleteAgentConfigItem(agentId: string, owner: string, kind: 
AgentConfigKind, name: string) {
+  return request("/api/delete-agent-config-item", "POST", {
+    agentId: agentId,
+    owner: owner,
+    kind: kind,
+    name: name,
+  });
+}
+
+/** What a copy would do, asked for before anything is written. */
+export function planAgentConfigCopy(body: CopyRequest) {
+  return request<AgentConfigPlanItem[]>("/api/plan-agent-config-copy", "POST", 
body);
+}
+
+export function copyAgentConfig(body: CopyRequest) {
+  return request<AgentConfigPlanItem[]>("/api/copy-agent-config", "POST", 
body);
+}
diff --git a/web/src/components/agent-config/copy-dialog.tsx 
b/web/src/components/agent-config/copy-dialog.tsx
new file mode 100644
index 0000000..8b52db7
--- /dev/null
+++ b/web/src/components/agent-config/copy-dialog.tsx
@@ -0,0 +1,295 @@
+// 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 {ArrowRight} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {Loading} from "@/components/shared/loading";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge, type BadgeVariant} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  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>;
+}
+
+/**
+ * The migration step: pick the agents to copy into, see what would change at
+ * each of them, then apply. The preview is what makes an overwrite a decision
+ * rather than a surprise, so it is loaded before the button does anything.
+ */
+export function CopyDialog({
+  open,
+  onOpenChange,
+  kind,
+  source,
+  inventories,
+  names,
+  onDone,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  kind: AgentConfigKind;
+  source: AgentConfigInventory;
+  inventories: AgentConfigInventory[];
+  names: string[];
+  onDone: () => void;
+}) {
+  const [selected, setSelected] = React.useState<string[]>([]);
+  const [overwrite, setOverwrite] = React.useState(false);
+  const [plan, setPlan] = React.useState<AgentConfigPlanItem[] | null>(null);
+  const [planning, setPlanning] = React.useState(false);
+  const [applied, setApplied] = React.useState(false);
+  const [busy, setBusy] = React.useState(false);
+  const [error, setError] = React.useState("");
+
+  // Copying moves files inside one account's home directory, so an agent
+  // configured under another home is not a target this page can offer.
+  const candidates = inventories.filter(
+    inventory => inventory.home === source.home && inventory.agentId !== 
source.agentId,
+  );
+
+  React.useEffect(() => {
+    if (open) {
+      setSelected([]);
+      setOverwrite(false);
+      setPlan(null);
+      setApplied(false);
+      setError("");
+    }
+  }, [open, kind, source.agentId]);
+
+  React.useEffect(() => {
+    if (!open || applied || selected.length === 0) {
+      setPlan(null);
+      return;
+    }
+
+    let current = true;
+    setPlanning(true);
+    setError("");
+    AgentConfigBackend.planAgentConfigCopy({
+      owner: source.owner,
+      from: source.agentId,
+      to: selected,
+      kind: kind,
+      names: names,
+      overwrite: overwrite,
+    })
+      .then(res => {
+        if (!current) {
+          return;
+        }
+        if (res.status === "ok") {
+          setPlan(res.data ?? []);
+        } else {
+          setError(res.msg || i18next.t("agentConfig:Failed to plan this 
copy"));
+        }
+      })
+      .catch(err => current && setError(err.message || String(err)))
+      .then(() => current && setPlanning(false));
+
+    return () => {
+      current = false;
+    };
+  }, [open, applied, selected, overwrite, kind, names, source.owner, 
source.agentId]);
+
+  const toggleTarget = (agentId: string) => {
+    setSelected(previous =>
+      previous.includes(agentId) ? previous.filter(item => item !== agentId) : 
[...previous, agentId],
+    );
+  };
+
+  const changes = (plan ?? []).filter(item => item.action === "create" || 
item.action === "overwrite");
+
+  const apply = () => {
+    setBusy(true);
+    setError("");
+    AgentConfigBackend.copyAgentConfig({
+      owner: source.owner,
+      from: source.agentId,
+      to: selected,
+      kind: kind,
+      names: names,
+      overwrite: overwrite,
+    })
+      .then(res => {
+        if (res.status !== "ok") {
+          setError(res.msg || i18next.t("agentConfig:Failed to copy"));
+          return;
+        }
+
+        const result = res.data ?? [];
+        const written = result.filter(item => item.action === "create" || 
item.action === "overwrite").length;
+        const failed = result.filter(item => item.action === "failed").length;
+        setPlan(result);
+        setApplied(true);
+        Setting.showMessage(
+          failed > 0 ? "error" : "success",
+          failed > 0
+            ? i18next
+              .t("agentConfig:Copied {done}, {failed} failed")
+              .replace("{done}", String(written))
+              .replace("{failed}", String(failed))
+            : counted(written, "agentConfig:Copied 1 item", 
"agentConfig:Copied {done} items", "{done}"),
+        );
+        onDone();
+      })
+      .catch(err => setError(err.message || String(err)))
+      .then(() => setBusy(false));
+  };
+
+  const byTarget = candidates
+    .filter(inventory => selected.includes(inventory.agentId))
+    .map(inventory => ({
+      inventory: inventory,
+      items: (plan ?? []).filter(item => item.agentId === inventory.agentId),
+    }));
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-h-[85vh] gap-4 sm:max-w-2xl">
+        <DialogHeader>
+          <DialogTitle>
+            {kind === "skill"
+              ? counted(names.length, "agentConfig:Copy 1 skill", 
"agentConfig:Copy {count} skills")
+              : counted(names.length, "agentConfig:Copy 1 MCP server", 
"agentConfig:Copy {count} MCP servers")}
+          </DialogTitle>
+          <DialogDescription className="flex flex-wrap items-center gap-1.5">
+            {i18next.t("agentConfig:From")}
+            <Badge variant="muted">{source.name}</Badge>
+            <ArrowRight className="size-3.5" />
+            {i18next.t("agentConfig:Pick the agents to copy into")}
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="flex flex-col gap-4 overflow-y-auto">
+          {candidates.length === 0 ? (
+            <MessageAlert
+              variant="info"
+              description={i18next.t("agentConfig:No other agent on this 
machine can receive these")}
+            />
+          ) : 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>
+
+          {!applied && candidates.length > 0 ? (
+            <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}
+          {planning ? <Loading /> : null}
+
+          {byTarget.map(({inventory, items}) => (
+            <div key={inventoryKey(inventory)} className="rounded-md border">
+              <div className="bg-muted/50 flex items-center justify-between 
gap-2 border-b px-3 py-2 text-sm font-medium">
+                <span>{inventory.name}</span>
+                <span className="text-muted-foreground text-xs font-normal">
+                  {items.filter(item => item.action !== "skip" && item.action 
!== "failed").length}
+                  {" / "}
+                  {items.length}
+                </span>
+              </div>
+              <ul className="divide-y">
+                {items.map(item => (
+                  <li key={item.name} className="flex items-center 
justify-between gap-3 px-3 py-1.5 text-sm">
+                    <span className="truncate font-mono 
text-xs">{item.name}</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>
+          ))}
+        </div>
+
+        <DialogFooter>
+          <Button variant="outline" onClick={() => onOpenChange(false)}>
+            {applied ? i18next.t("general:Close") : 
i18next.t("general:Cancel")}
+          </Button>
+          {applied ? null : (
+            <Button onClick={apply} disabled={busy || planning || 
changes.length === 0}>
+              {counted(changes.length, "agentConfig:Copy 1 item", 
"agentConfig:Copy {count} items")}
+            </Button>
+          )}
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/web/src/components/agent-config/detail-dialog.tsx 
b/web/src/components/agent-config/detail-dialog.tsx
new file mode 100644
index 0000000..1ca8423
--- /dev/null
+++ b/web/src/components/agent-config/detail-dialog.tsx
@@ -0,0 +1,128 @@
+// 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 {Loading} from "@/components/shared/loading";
+import {CodeText} from "@/components/shared/misc";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {endpointOf} from "@/lib/agent-configs";
+import type {AgentConfigDetail, AgentConfigItem} from "@/types";
+
+/** The item whose definition is on screen, and the agent it was read from. */
+export interface DetailTarget {
+  item: AgentConfigItem;
+  agentName: string;
+}
+
+/**
+ * Shows one skill's SKILL.md or one MCP server's entry, exactly as the agent
+ * has it on disk. Credentials are masked by the server before they get here.
+ */
+export function DetailDialog({
+  target,
+  onOpenChange,
+}: {
+  target: DetailTarget | null;
+  onOpenChange: (open: boolean) => void;
+}) {
+  const [detail, setDetail] = React.useState<AgentConfigDetail | null>(null);
+  const [error, setError] = React.useState("");
+  const [loading, setLoading] = React.useState(false);
+
+  const item = target?.item;
+
+  React.useEffect(() => {
+    if (!item) {
+      return;
+    }
+
+    let current = true;
+    setDetail(null);
+    setError("");
+    setLoading(true);
+    AgentConfigBackend.getAgentConfigItem(item.agentId, item.owner, item.kind, 
item.name)
+      .then(res => {
+        if (!current) {
+          return;
+        }
+        if (res.status === "ok") {
+          setDetail(res.data ?? null);
+        } else {
+          setError(res.msg || i18next.t("agentConfig:Failed to read this 
item"));
+        }
+      })
+      .catch(err => current && setError(err.message || String(err)))
+      .then(() => current && setLoading(false));
+
+    return () => {
+      current = false;
+    };
+  }, [item]);
+
+  return (
+    <Dialog open={Boolean(target)} onOpenChange={onOpenChange}>
+      <DialogContent className="max-h-[85vh] gap-3 sm:max-w-3xl">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            <span className="truncate">{item?.name}</span>
+            <Badge variant="muted">{target?.agentName}</Badge>
+          </DialogTitle>
+          <DialogDescription>
+            {item?.description || detail?.item.description || (item ? 
endpointOf(item) : "")}
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="flex flex-col gap-3 overflow-y-auto">
+          <div className="flex flex-wrap items-center gap-2 text-xs">
+            <span 
className="text-muted-foreground">{i18next.t("general:Path")}</span>
+            <CodeText copyable>{item?.path}</CodeText>
+          </div>
+
+          {detail?.files?.length ? (
+            <div className="flex flex-wrap items-center gap-1.5 text-xs">
+              <span 
className="text-muted-foreground">{i18next.t("agentConfig:Bundled 
files")}</span>
+              {detail.files.map(file => (
+                <Badge key={file} variant="outline" className="font-mono 
font-normal">
+                  {file}
+                </Badge>
+              ))}
+            </div>
+          ) : null}
+
+          {error ? <MessageAlert description={error} /> : null}
+          {loading ? <Loading /> : null}
+          {detail ? (
+            <pre className="bg-muted max-h-[50vh] overflow-auto rounded-md p-3 
text-xs whitespace-pre-wrap">
+              {detail.content}
+            </pre>
+          ) : null}
+          {detail && item?.kind === "mcp" ? (
+            <p className="text-muted-foreground 
text-xs">{i18next.t("agentConfig:Credentials are masked here")}</p>
+          ) : null}
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/web/src/lib/agent-configs.ts b/web/src/lib/agent-configs.ts
new file mode 100644
index 0000000..2ae59b9
--- /dev/null
+++ b/web/src/lib/agent-configs.ts
@@ -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.
+
+import * as React from "react";
+import i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import type {AgentConfigInventory, AgentConfigItem, AgentConfigKind} from 
"@/types";
+
+/** Skills and MCP servers belong to an account, so an id alone is not unique. 
*/
+export function inventoryKey(inventory: Pick<AgentConfigInventory, "agentId" | 
"owner">) {
+  return `${inventory.owner}:${inventory.agentId}`;
+}
+
+export function itemsOf(inventory: AgentConfigInventory, kind: 
AgentConfigKind) {
+  return kind === "skill" ? inventory.skills : inventory.mcpServers;
+}
+
+export function supports(inventory: AgentConfigInventory, kind: 
AgentConfigKind) {
+  return kind === "skill" ? inventory.skillsSupported : inventory.mcpSupported;
+}
+
+/** Where the items of one kind live, shown under the agent it belongs to. */
+export function locationOf(inventory: AgentConfigInventory, kind: 
AgentConfigKind) {
+  return (kind === "skill" ? inventory.skillsDir : inventory.mcpFile) ?? "";
+}
+
+/**
+ * Why an agent cannot be a copy target, in the reader's words. Empty when it
+ * can be one.
+ */
+export function blockedReason(inventory: AgentConfigInventory, kind: 
AgentConfigKind) {
+  if (!supports(inventory, kind)) {
+    return i18next.t("agentConfig:Gateway does not know where this agent keeps 
these");
+  }
+  if (kind === "mcp" && !inventory.mcpWritable) {
+    return inventory.mcpReadOnly || i18next.t("agentConfig:This file is 
read-only for Gateway");
+  }
+  return "";
+}
+
+/** Which agents already have an item of each name, keyed by name. */
+export function presenceOf(inventories: AgentConfigInventory[], kind: 
AgentConfigKind) {
+  const presence = new Map<string, Set<string>>();
+  inventories.forEach(inventory => {
+    itemsOf(inventory, kind).forEach(item => {
+      const holders = presence.get(item.name) ?? new Set<string>();
+      holders.add(inventoryKey(inventory));
+      presence.set(item.name, holders);
+    });
+  });
+  return presence;
+}
+
+/**
+ * Picks the singular or the plural wording. English needs a different noun for
+ * one item; the other locales map both keys to the same string.
+ */
+export function counted(count: number, oneKey: string, manyKey: string, token 
= "{count}") {
+  return count === 1 ? i18next.t(oneKey) : i18next.t(manyKey).replace(token, 
String(count));
+}
+
+export function formatBytes(bytes: number | undefined) {
+  if (!bytes) {
+    return "";
+  }
+  const units = ["B", "KB", "MB", "GB"];
+  let value = bytes;
+  let unit = 0;
+  while (value >= 1024 && unit < units.length - 1) {
+    value /= 1024;
+    unit += 1;
+  }
+  return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} 
${units[unit]}`;
+}
+
+/** The one-line summary of an MCP server: what it runs, or what it connects 
to. */
+export function endpointOf(item: AgentConfigItem) {
+  return item.command || item.url || "";
+}
+
+/**
+ * useAgentConfigs owns the host scan behind the Skills & MCP page. Deleting 
and
+ * copying both change files this page has already read, so every one of them
+ * ends by calling refresh().
+ */
+export function useAgentConfigs() {
+  const [inventories, setInventories] = 
React.useState<AgentConfigInventory[]>([]);
+  const [loading, setLoading] = React.useState(false);
+  const [error, setError] = React.useState("");
+  const [scanned, setScanned] = React.useState(false);
+
+  const refresh = React.useCallback((forceRefresh = false) => {
+    setLoading(true);
+    setError("");
+    AgentConfigBackend.getAgentConfigs(forceRefresh)
+      .then(res => {
+        if (res.status === "ok") {
+          setInventories(res.data ?? []);
+        } else {
+          setError(res.msg || i18next.t("agentConfig:Failed to read agent 
configuration"));
+        }
+      })
+      .catch(err => setError(err.message || String(err)))
+      .then(() => {
+        setLoading(false);
+        setScanned(true);
+      });
+  }, []);
+
+  React.useEffect(() => {
+    refresh();
+  }, [refresh]);
+
+  return {inventories, loading, error, scanned, refresh};
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 2346871..076651b 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -92,6 +92,62 @@
     "View all": "View all",
     "{count} sessions": "{count} sessions"
   },
+  "agentConfig": {
+    "1 file": "1 file",
+    "Bundled files": "Bundled files",
+    "Contents": "Contents",
+    "Copied 1 item": "Copied 1 item",
+    "Copied {done} items": "Copied {done} items",
+    "Copied {done}, {failed} failed": "Copied {done} items, {failed} failed",
+    "Copy 1 MCP server": "Copy 1 MCP server",
+    "Copy 1 item": "Copy 1 item",
+    "Copy 1 skill": "Copy 1 skill",
+    "Copy to other agents": "Copy to other agents",
+    "Copy {count} MCP servers": "Copy {count} MCP servers",
+    "Copy {count} items": "Copy {count} items",
+    "Copy {count} skills": "Copy {count} skills",
+    "Copy {count} to other agents": "Copy {count} to other agents",
+    "Credentials are masked here": "Credentials are masked in this view. A 
copy carries the real values.",
+    "Delete description": "This removes it from the agent's own configuration, 
here:",
+    "Delete this MCP server?": "Delete this MCP server?",
+    "Delete this skill?": "Delete this skill?",
+    "Deleted": "Deleted",
+    "Endpoint": "Endpoint",
+    "Failed": "Failed",
+    "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",
+    "Failed to read agent configuration": "Failed to read the agents' 
configuration",
+    "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",
+    "MCP": "MCP",
+    "MCP servers": "MCP servers",
+    "Managed by Gateway": "Gateway",
+    "Managed by Gateway detail": "Registered by Gateway's agent monitoring. 
Turn monitoring off on the Agents page to remove it.",
+    "New": "New",
+    "No MCP servers yet": "This agent has no MCP servers yet",
+    "No agents found": "No agents found on this machine",
+    "No agents found detail": "Gateway reads the configuration of the agents 
installed where it runs. Inside a container it sees the container's own 
filesystem, not the host's.",
+    "No other agent on this machine can receive these": "No other agent on 
this machine can receive these.",
+    "No skills yet": "This agent has no skills yet",
+    "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.",
+    "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",
+    "Replaced": "Replaced",
+    "Search by name": "Search by name",
+    "Select all": "Select all",
+    "Shared with": "The same files are read by {agents}.",
+    "Skills": "Skills",
+    "Skills & MCP": "Skills & MCP",
+    "Skipped": "Skipped",
+    "This file is read-only for Gateway": "Gateway does not write this agent's 
configuration file",
+    "View": "View",
+    "{files} files": "{files} files"
+  },
   "cert": {
     "Access key": "Access key",
     "Access secret": "Access secret",
@@ -158,6 +214,7 @@
     "Certs": "Certs",
     "Client IP": "Client IP",
     "Client ip": "Client ip",
+    "Close": "Close",
     "Copied": "Copied",
     "Copied to clipboard": "Copied to clipboard",
     "Copy": "Copy",
@@ -184,8 +241,8 @@
     "Home": "Home",
     "Host": "Host",
     "ID": "ID",
-    "Input IP Addresses": "Input IP Addresses",
     "IP Address": "IP Address",
+    "Input IP Addresses": "Input IP Addresses",
     "Light": "Light",
     "Logged in successfully": "Logged in successfully",
     "Login Error": "Login Error",
@@ -193,8 +250,8 @@
     "Name": "Name",
     "Name cannot be empty": "Name cannot be empty",
     "New Record": "New Record",
-    "No data": "No data",
     "No Upgrade": "No Upgrade",
+    "No data": "No data",
     "Nodes": "Nodes",
     "OK": "OK",
     "Other": "Other",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 57ea6a1..1a8d651 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -92,6 +92,62 @@
     "View all": "查看全部",
     "{count} sessions": "{count} 个会话"
   },
+  "agentConfig": {
+    "1 file": "1 个文件",
+    "Bundled files": "附带文件",
+    "Contents": "内容",
+    "Copied 1 item": "已复制 1 项",
+    "Copied {done} items": "已复制 {done} 项",
+    "Copied {done}, {failed} failed": "已复制 {done} 项,{failed} 项失败",
+    "Copy 1 MCP server": "复制 1 个 MCP 服务器",
+    "Copy 1 item": "复制 1 项",
+    "Copy 1 skill": "复制 1 个技能",
+    "Copy to other agents": "复制到其他 Agent",
+    "Copy {count} MCP servers": "复制 {count} 个 MCP 服务器",
+    "Copy {count} items": "复制 {count} 项",
+    "Copy {count} skills": "复制 {count} 个技能",
+    "Copy {count} to other agents": "把 {count} 项复制到其他 Agent",
+    "Credentials are masked here": "此视图中的凭据已被遮蔽。复制时携带的是真实值。",
+    "Delete description": "这会把它从该 Agent 自己的配置中删除,位置:",
+    "Delete this MCP server?": "删除这个 MCP 服务器?",
+    "Delete this skill?": "删除这个技能?",
+    "Deleted": "已删除",
+    "Endpoint": "端点",
+    "Failed": "失败",
+    "Failed to copy": "复制失败",
+    "Failed to delete": "删除失败",
+    "Failed to plan this copy": "无法预览这次复制的结果",
+    "Failed to read agent configuration": "读取 Agent 配置失败",
+    "Failed to read this item": "读取该项失败",
+    "From": "从",
+    "Gateway does not know where this agent keeps these": "Gateway 不知道该 Agent 
把这些配置放在哪里",
+    "MCP": "MCP",
+    "MCP servers": "MCP 服务器",
+    "Managed by Gateway": "Gateway",
+    "Managed by Gateway detail": "由 Gateway 的 Agent 监控注册。要移除它,请在 Agents 
页面关闭监控。",
+    "New": "新增",
+    "No MCP servers yet": "该 Agent 还没有 MCP 服务器",
+    "No agents found": "本机没有找到 Agent",
+    "No agents found detail": "Gateway 只读取它自己所在机器上安装的 Agent 
的配置。在容器里运行时,它看到的是容器自己的文件系统,而不是宿主机的。",
+    "No other agent on this machine can receive these": "本机没有其他 Agent 
可以接收这些内容。",
+    "No skills yet": "该 Agent 还没有技能",
+    "Not installed": "仅配置",
+    "Not installed detail": "Gateway 在磁盘上找到了这份配置,但没有检测到该 Agent 的安装。",
+    "Page description": "本机每个 Agent 自己配置文件里的技能和 MCP 服务器。可以并排比较,并从一个 Agent 
复制到另一个。",
+    "Pick the agents to copy into": "选择要复制到的 Agent",
+    "Read from": "读取自",
+    "Replace items that already exist": "覆盖已存在的同名项",
+    "Replaced": "覆盖",
+    "Search by name": "按名称搜索",
+    "Select all": "全选",
+    "Shared with": "{agents} 读取的是同一批文件。",
+    "Skills": "技能",
+    "Skills & MCP": "技能与 MCP",
+    "Skipped": "跳过",
+    "This file is read-only for Gateway": "Gateway 不会写入该 Agent 的配置文件",
+    "View": "查看",
+    "{files} files": "{files} 个文件"
+  },
   "cert": {
     "Access key": "访问密钥",
     "Access secret": "访问密钥 Secret",
@@ -158,6 +214,7 @@
     "Certs": "证书",
     "Client IP": "客户端 IP",
     "Client ip": "客户端IP",
+    "Close": "关闭",
     "Copied": "已复制",
     "Copied to clipboard": "已复制到剪贴板",
     "Copy": "复制",
@@ -184,8 +241,8 @@
     "Home": "首页",
     "Host": "主机",
     "ID": "ID",
-    "Input IP Addresses": "请输入 IP 地址",
     "IP Address": "IP地址",
+    "Input IP Addresses": "请输入 IP 地址",
     "Light": "浅色",
     "Logged in successfully": "登录成功",
     "Login Error": "登录失败",
@@ -193,8 +250,8 @@
     "Name": "名称",
     "Name cannot be empty": "名称不能为空",
     "New Record": "新建记录",
-    "No data": "暂无数据",
     "No Upgrade": "不升级",
+    "No data": "暂无数据",
     "Nodes": "节点",
     "OK": "确定",
     "Other": "其他",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 77c23e5..cf7e07a 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import {
+  Blocks,
   Bot,
   ChartColumn,
   FileSearch,
@@ -59,6 +60,13 @@ export interface NavGroup {
 export const navGroups: NavGroup[] = [
   {key: "/", label: "general:Dashboard", icon: LayoutDashboard, path: "/"},
   {key: "/agents", label: "agent:Agents", icon: Bot, path: "/agents", 
adminOnly: true},
+  {
+    key: "/agent-configs",
+    label: "agentConfig:Skills & MCP",
+    icon: Blocks,
+    path: "/agent-configs",
+    adminOnly: true,
+  },
   {
     key: "/agent-sessions",
     label: "agent:Agent Sessions",
diff --git a/web/src/pages/AgentConfigsPage.tsx 
b/web/src/pages/AgentConfigsPage.tsx
new file mode 100644
index 0000000..9b656f1
--- /dev/null
+++ b/web/src/pages/AgentConfigsPage.tsx
@@ -0,0 +1,399 @@
+// 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 {Check, Eye, Minus, Package, 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 {CopyDialog} from "@/components/agent-config/copy-dialog";
+import {DetailDialog, type DetailTarget} from 
"@/components/agent-config/detail-dialog";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {EmptyState} from "@/components/shared/empty-state";
+import {CodeText, UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {cn} from "@/lib/utils";
+import {
+  blockedReason,
+  counted,
+  endpointOf,
+  formatBytes,
+  inventoryKey,
+  itemsOf,
+  locationOf,
+  presenceOf,
+  supports,
+  useAgentConfigs,
+} from "@/lib/agent-configs";
+import type {Account, AgentConfigInventory, AgentConfigItem, AgentConfigKind} 
from "@/types";
+
+/** The agent picker doubles as the overview: every row carries its own 
counts. */
+function SourcePicker({
+  inventories,
+  selectedKey,
+  onSelect,
+}: {
+  inventories: AgentConfigInventory[];
+  selectedKey: string;
+  onSelect: (inventory: AgentConfigInventory) => void;
+}) {
+  return (
+    <div className="flex flex-wrap gap-2">
+      {inventories.map(inventory => {
+        const active = inventoryKey(inventory) === selectedKey;
+        return (
+          <button
+            key={inventoryKey(inventory)}
+            type="button"
+            onClick={() => onSelect(inventory)}
+            className={cn(
+              "flex items-center gap-2.5 rounded-md border px-3 py-2 text-left 
transition-colors",
+              active ? "border-primary bg-primary/10" : "hover:bg-accent",
+            )}
+          >
+            <AgentIcon agent={inventory.name} size={20} fallback={<Package 
className="size-5" />} />
+            <span className="flex flex-col">
+              <span className="flex items-center gap-1.5 text-sm leading-tight 
font-medium">
+                {inventory.name}
+                {inventory.installed ? null : (
+                  <SimpleTooltip title={i18next.t("agentConfig:Not installed 
detail")}>
+                    <Badge variant="muted">{i18next.t("agentConfig:Not 
installed")}</Badge>
+                  </SimpleTooltip>
+                )}
+              </span>
+              <span className="text-muted-foreground text-xs leading-tight">
+                {i18next.t("agentConfig:Skills")} {inventory.skills.length}
+                {" · "}
+                {i18next.t("agentConfig:MCP")} {inventory.mcpServers.length}
+              </span>
+            </span>
+          </button>
+        );
+      })}
+    </div>
+  );
+}
+
+export default function AgentConfigsPage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const {inventories, loading, error, scanned, refresh} = useAgentConfigs();
+
+  const [kind, setKind] = React.useState<AgentConfigKind>("skill");
+  const [sourceKey, setSourceKey] = React.useState("");
+  const [selected, setSelected] = React.useState<string[]>([]);
+  const [detail, setDetail] = React.useState<DetailTarget | null>(null);
+  const [copyOpen, setCopyOpen] = React.useState(false);
+  const [deleting, setDeleting] = React.useState("");
+
+  // The scan replaces every inventory, so the chosen source is re-resolved by
+  // key rather than held as an object that would go stale on every refresh.
+  const source = inventories.find(inventory => inventoryKey(inventory) === 
sourceKey) ?? inventories[0];
+
+  // The first agent with something in it is a better landing page than the
+  // first one alphabetically, but it is chosen once and then committed: left
+  // derived, a copy that fills an empty agent would move the page off the
+  // source the reader is working from.
+  React.useEffect(() => {
+    if (sourceKey === "" && inventories.length > 0) {
+      const landing = inventories.find(inventory => itemsOf(inventory, 
kind).length > 0) ?? inventories[0];
+      setSourceKey(inventoryKey(landing));
+    }
+  }, [inventories, kind, sourceKey]);
+
+  React.useEffect(() => {
+    setSelected([]);
+  }, [sourceKey, kind]);
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  const peers = source ? inventories.filter(inventory => 
inventoryKey(inventory) !== inventoryKey(source)) : [];
+  const items = source ? itemsOf(source, kind) : [];
+  const presence = presenceOf(inventories, kind);
+  const selectable = items.filter(item => !item.managed);
+  const allSelected = selectable.length > 0 && selected.length === 
selectable.length;
+
+  const toggleAll = () => {
+    setSelected(allSelected ? [] : selectable.map(item => item.name));
+  };
+
+  const toggleOne = (name: string) => {
+    setSelected(previous =>
+      previous.includes(name) ? previous.filter(item => item !== name) : 
[...previous, name],
+    );
+  };
+
+  const deleteItem = (item: AgentConfigItem) => {
+    setDeleting(item.name);
+    return AgentConfigBackend.deleteAgentConfigItem(item.agentId, item.owner, 
item.kind, item.name)
+      .then(res => {
+        if (res.status === "ok") {
+          Setting.showMessage("success", `${i18next.t("agentConfig:Deleted")}: 
${item.name}`);
+          refresh();
+        } else {
+          Setting.showMessage("error", res.msg || 
i18next.t("agentConfig:Failed to delete"));
+        }
+      })
+      .catch(err => Setting.showMessage("error", err.message || String(err)))
+      .then(() => setDeleting(""));
+  };
+
+  const columns: Column<AgentConfigItem>[] = [
+    {
+      key: "selected",
+      width: "40px",
+      title: (
+        <input
+          type="checkbox"
+          className="accent-primary size-4 align-middle"
+          aria-label={i18next.t("agentConfig:Select all")}
+          checked={allSelected}
+          disabled={selectable.length === 0}
+          onChange={toggleAll}
+        />
+      ),
+      render: (_value, record) =>
+        record.managed ? null : (
+          <input
+            type="checkbox"
+            className="accent-primary size-4 align-middle"
+            aria-label={record.name}
+            checked={selected.includes(record.name)}
+            onChange={() => toggleOne(record.name)}
+          />
+        ),
+    },
+    {
+      key: "name",
+      dataIndex: "name",
+      title: i18next.t("general:Name"),
+      sorter: (a, b) => a.name.localeCompare(b.name),
+      render: (_value, record) => (
+        <div className="min-w-0">
+          <div className="flex items-center gap-1.5">
+            <span className="truncate font-medium">{record.name}</span>
+            {record.managed ? (
+              <SimpleTooltip title={i18next.t("agentConfig:Managed by Gateway 
detail")}>
+                <Badge variant="info">{i18next.t("agentConfig:Managed by 
Gateway")}</Badge>
+              </SimpleTooltip>
+            ) : null}
+          </div>
+          {record.description ? (
+            <p className="text-muted-foreground line-clamp-2 
text-xs">{record.description}</p>
+          ) : null}
+        </div>
+      ),
+    },
+    {
+      key: "summary",
+      width: "220px",
+      title: kind === "skill" ? i18next.t("agentConfig:Contents") : 
i18next.t("agentConfig:Endpoint"),
+      render: (_value, record) =>
+        kind === "skill" ? (
+          <span className="text-muted-foreground text-xs">
+            {counted(record.files ?? 0, "agentConfig:1 file", 
"agentConfig:{files} files", "{files}")}
+            {record.bytes ? ` · ${formatBytes(record.bytes)}` : ""}
+          </span>
+        ) : (
+          <div className="flex min-w-0 items-center gap-1.5">
+            {record.transport ? <Badge 
variant="muted">{record.transport}</Badge> : null}
+            <span className="text-muted-foreground truncate font-mono text-xs" 
title={endpointOf(record)}>
+              {endpointOf(record)}
+            </span>
+          </div>
+        ),
+    },
+    ...peers.map<Column<AgentConfigItem>>(peer => ({
+      key: `peer-${inventoryKey(peer)}`,
+      width: "110px",
+      align: "center",
+      title: (
+        <SimpleTooltip title={peer.name}>
+          <span className="inline-flex items-center gap-1.5">
+            <AgentIcon agent={peer.name} size={14} />
+            <span className="truncate">{peer.name}</span>
+          </span>
+        </SimpleTooltip>
+      ),
+      render: (_value, record) =>
+        !supports(peer, kind) ? (
+          <span className="text-muted-foreground/50 text-xs">—</span>
+        ) : presence.get(record.name)?.has(inventoryKey(peer)) ? (
+          <Check className="text-success mx-auto size-4" />
+        ) : (
+          <Minus className="text-muted-foreground/40 mx-auto size-4" />
+        ),
+    })),
+    {
+      key: "actions",
+      width: "110px",
+      align: "right",
+      title: i18next.t("general:Action"),
+      render: (_value, record) => (
+        <div className="flex justify-end gap-1">
+          <SimpleTooltip title={i18next.t("agentConfig:View")}>
+            <Button
+              variant="ghost"
+              size="icon-sm"
+              aria-label={i18next.t("agentConfig:View")}
+              onClick={() => setDetail({item: record, agentName: source?.name 
?? record.agentId})}
+            >
+              <Eye className="size-4" />
+            </Button>
+          </SimpleTooltip>
+          <ConfirmDialog
+            title={i18next.t(
+              kind === "skill" ? "agentConfig:Delete this skill?" : 
"agentConfig:Delete this MCP server?",
+            )}
+            description={
+              <span className="flex flex-col gap-1">
+                <span>{i18next.t("agentConfig:Delete description")}</span>
+                <code className="text-foreground font-mono text-xs 
break-all">{record.path}</code>
+              </span>
+            }
+            onConfirm={() => deleteItem(record)}
+            disabled={record.managed || deleting === record.name}
+          >
+            <Button
+              variant="ghost"
+              size="icon-sm"
+              className="text-destructive"
+              aria-label={i18next.t("general:Delete")}
+              disabled={record.managed || deleting === record.name}
+            >
+              <Trash2 className="size-4" />
+            </Button>
+          </ConfirmDialog>
+        </div>
+      ),
+    },
+  ];
+
+  const location = source ? locationOf(source, kind) : "";
+  const blocked = source ? blockedReason(source, kind) : "";
+
+  return (
+    <PageContainer>
+      <PageHeader
+        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>
+        }
+      />
+
+      {error ? <MessageAlert description={error} /> : null}
+
+      {scanned && inventories.length === 0 ? (
+        <EmptyState
+          icon={Package}
+          title={i18next.t("agentConfig:No agents found")}
+          description={i18next.t("agentConfig:No agents found detail")}
+        />
+      ) : null}
+
+      {source ? (
+        <>
+          <SourcePicker
+            inventories={inventories}
+            selectedKey={inventoryKey(source)}
+            onSelect={inventory => setSourceKey(inventoryKey(inventory))}
+          />
+
+          {source.errors?.length ? <MessageAlert 
description={source.errors.join("; ")} /> : null}
+
+          <div className="flex flex-wrap items-center justify-between gap-3">
+            <Tabs value={kind} onValueChange={value => setKind(value as 
AgentConfigKind)}>
+              <TabsList>
+                <TabsTrigger value="skill">
+                  {i18next.t("agentConfig:Skills")}
+                  <Badge variant="muted">{source.skills.length}</Badge>
+                </TabsTrigger>
+                <TabsTrigger value="mcp">
+                  {i18next.t("agentConfig:MCP servers")}
+                  <Badge variant="muted">{source.mcpServers.length}</Badge>
+                </TabsTrigger>
+              </TabsList>
+            </Tabs>
+
+            {location ? (
+              <span className="flex items-center gap-2 text-xs">
+                <span 
className="text-muted-foreground">{i18next.t("agentConfig:Read from")}</span>
+                <CodeText copyable>{location}</CodeText>
+              </span>
+            ) : null}
+          </div>
+
+          {source.sharedWith?.length ? (
+            <p className="text-muted-foreground text-xs">
+              {i18next.t("agentConfig:Shared with").replace("{agents}", 
source.sharedWith.join(", "))}
+            </p>
+          ) : null}
+
+          {blocked ? (
+            <MessageAlert variant="info" description={blocked} />
+          ) : (
+            <DataTable<AgentConfigItem>
+              columns={columns}
+              dataSource={items}
+              rowKey="name"
+              loading={loading && !scanned}
+              searchable
+              searchPlaceholder={i18next.t("agentConfig:Search by name")}
+              pageSize={20}
+              emptyIcon={Package}
+              emptyText={i18next.t(
+                kind === "skill" ? "agentConfig:No skills yet" : 
"agentConfig:No MCP servers yet",
+              )}
+              toolbar={
+                <Button disabled={selected.length === 0} onClick={() => 
setCopyOpen(true)}>
+                  <Send className="size-4" />
+                  {selected.length === 0
+                    ? i18next.t("agentConfig:Copy to other agents")
+                    : i18next
+                      .t("agentConfig:Copy {count} to other agents")
+                      .replace("{count}", String(selected.length))}
+                </Button>
+              }
+            />
+          )}
+
+          <CopyDialog
+            open={copyOpen}
+            onOpenChange={setCopyOpen}
+            kind={kind}
+            source={source}
+            inventories={inventories}
+            names={selected}
+            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 3a67ffc..9ec4b37 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -262,3 +262,63 @@ export interface Application {
 export interface GatewayStatus {
   gatewayEnabled: boolean;
 }
+
+/** The two kinds of configuration the Skills & MCP page manages. */
+export type AgentConfigKind = "skill" | "mcp";
+
+export interface AgentConfigItem {
+  agentId: string;
+  owner: string;
+  kind: AgentConfigKind;
+  name: string;
+  description?: string;
+  /** The skill's own folder, or the config file the MCP server is an entry 
of. */
+  path: string;
+  transport?: string;
+  command?: string;
+  url?: string;
+  files?: number;
+  bytes?: number;
+  /** Written by Gateway's own agent monitoring, and not the operator's to 
move. */
+  managed?: boolean;
+}
+
+/** One installation's skills and MCP servers, as they exist in its own files. 
*/
+export interface AgentConfigInventory {
+  agentId: string;
+  owner: string;
+  name: string;
+  path?: string;
+  /** The account directory the locations below were resolved under. */
+  home?: string;
+  /** False when the configuration exists but no installation was detected. */
+  installed: boolean;
+  /** Other agents reading the same files, e.g. Cursor and its CLI. */
+  sharedWith?: string[];
+  skillsDir?: string;
+  mcpFile?: string;
+  skillsSupported: boolean;
+  mcpSupported: boolean;
+  mcpWritable: boolean;
+  mcpReadOnly?: string;
+  skills: AgentConfigItem[];
+  mcpServers: AgentConfigItem[];
+  errors?: string[];
+}
+
+export interface AgentConfigDetail {
+  item: AgentConfigItem;
+  content: string;
+  files?: string[];
+}
+
+export type AgentConfigAction = "create" | "overwrite" | "skip" | "failed";
+
+/** What a copy would do, or did, to one item at one target agent. */
+export interface AgentConfigPlanItem {
+  agentId: string;
+  name: string;
+  action: AgentConfigAction;
+  reason?: string;
+  path?: string;
+}


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

Reply via email to