Skip to content

Commit 55df367

Browse files
committed
Add top-level mcps section for reusable MCP server definitions
Introduce a top-level 'mcps' section in the agent config that allows defining MCP server configurations once and referencing them by name from agent toolsets. This eliminates duplication when multiple agents use the same MCP server. Agent toolsets reference definitions via 'ref: <name>'. Fields set on the toolset override the definition; env maps are merged with toolset values taking precedence on conflicts. Definitions are validated at parse time using the existing Toolset validation, rejecting nonsensical fields (e.g. shared, path) and invalid source combinations. Closes #1667 Assisted-By: cagent
1 parent 9a5c38a commit 55df367

15 files changed

+583
-7
lines changed

agent-schema.json

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@
4848
"$ref": "#/definitions/ModelConfig"
4949
}
5050
},
51+
"mcps": {
52+
"type": "object",
53+
"description": "Map of reusable MCP server definitions. Define MCP servers here and reference them by name from agent toolsets to avoid duplication.",
54+
"additionalProperties": {
55+
"$ref": "#/definitions/MCPToolset"
56+
}
57+
},
5158
"rag": {
5259
"type": "object",
5360
"description": "Map of RAG (Retrieval-Augmented Generation) configurations",
@@ -683,6 +690,95 @@
683690
},
684691
"additionalProperties": false
685692
},
693+
"MCPToolset": {
694+
"type": "object",
695+
"description": "Reusable MCP server definition. Define once at the top level and reference by name from agent toolsets.",
696+
"properties": {
697+
"command": {
698+
"type": "string",
699+
"description": "Command to run the MCP server (stdio transport)"
700+
},
701+
"args": {
702+
"type": "array",
703+
"description": "Arguments to pass to the command",
704+
"items": {
705+
"type": "string"
706+
}
707+
},
708+
"ref": {
709+
"type": "string",
710+
"description": "Docker MCP reference (e.g., 'docker:context7')",
711+
"pattern": "^docker:"
712+
},
713+
"remote": {
714+
"$ref": "#/definitions/Remote",
715+
"description": "Remote MCP server configuration (SSE/streamable-http transport)"
716+
},
717+
"config": {
718+
"description": "MCP server configuration (for docker refs)"
719+
},
720+
"version": {
721+
"type": "string",
722+
"description": "Version/package reference for auto-installation"
723+
},
724+
"env": {
725+
"type": "object",
726+
"description": "Environment variables for the MCP server",
727+
"additionalProperties": {
728+
"type": "string"
729+
}
730+
},
731+
"tools": {
732+
"type": "array",
733+
"description": "Optional list of tools to expose from the MCP server",
734+
"items": {
735+
"type": "string"
736+
}
737+
},
738+
"instruction": {
739+
"type": "string",
740+
"description": "Optional instruction for the tools"
741+
},
742+
"name": {
743+
"type": "string",
744+
"description": "Optional display name override for the MCP server"
745+
},
746+
"defer": {
747+
"description": "Deferred loading configuration for tools from this MCP server",
748+
"oneOf": [
749+
{
750+
"type": "boolean",
751+
"description": "Set to true to defer all tools"
752+
},
753+
{
754+
"type": "array",
755+
"description": "Array of tool names to defer",
756+
"items": {
757+
"type": "string"
758+
}
759+
}
760+
]
761+
}
762+
},
763+
"anyOf": [
764+
{
765+
"required": [
766+
"command"
767+
]
768+
},
769+
{
770+
"required": [
771+
"remote"
772+
]
773+
},
774+
{
775+
"required": [
776+
"ref"
777+
]
778+
}
779+
],
780+
"additionalProperties": false
781+
},
686782
"Toolset": {
687783
"type": "object",
688784
"description": "Tool configuration",
@@ -718,8 +814,7 @@
718814
},
719815
"ref": {
720816
"type": "string",
721-
"description": "Reference to external tool (e.g., docker:context7)",
722-
"pattern": "^docker:"
817+
"description": "Reference to a Docker MCP tool (e.g., 'docker:context7') or a named MCP definition from the top-level 'mcps' section"
723818
},
724819
"config": {
725820
"description": "Tool-specific configuration"

examples/mcp-definitions.yaml

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env docker agent run
2+
# yaml-language-server: $schema=../agent-schema.json
3+
4+
# This example demonstrates how to use the top-level `mcps` section to define
5+
# MCP servers once and reference them across multiple agents, avoiding duplication.
6+
7+
models:
8+
model:
9+
provider: anthropic
10+
model: claude-sonnet-4-0
11+
max_tokens: 64000
12+
13+
# Define MCP servers once at the top level.
14+
# Each entry can use `command`, `remote`, or `ref` (just like inline MCP toolsets).
15+
mcps:
16+
context7:
17+
ref: docker:context7
18+
19+
github:
20+
ref: docker:github
21+
env:
22+
GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PERSONAL_ACCESS_TOKEN}"
23+
tools:
24+
- create_or_update_file
25+
- search_repositories
26+
- create_repository
27+
- get_file_contents
28+
- push_files
29+
- create_issue
30+
- create_pull_request
31+
- list_issues
32+
33+
agents:
34+
root:
35+
model: model
36+
description: Lead developer
37+
instruction: |
38+
You are the lead developer. Coordinate the team.
39+
sub_agents: [frontend, backend]
40+
toolsets:
41+
- type: filesystem
42+
- type: think
43+
# Reference the MCP definition by name — no need to repeat the full config.
44+
- type: mcp
45+
ref: context7
46+
- type: mcp
47+
ref: github
48+
49+
frontend:
50+
model: model
51+
description: Frontend engineer
52+
instruction: |
53+
You are a frontend engineer.
54+
toolsets:
55+
- type: filesystem
56+
- type: shell
57+
# Same MCP server, referenced by name.
58+
- type: mcp
59+
ref: context7
60+
# Reference github MCP but override the tool list for this agent.
61+
- type: mcp
62+
ref: github
63+
tools:
64+
- get_file_contents
65+
- search_repositories
66+
67+
backend:
68+
model: model
69+
description: Backend engineer
70+
instruction: |
71+
You are a backend engineer.
72+
toolsets:
73+
- type: filesystem
74+
- type: shell
75+
- type: mcp
76+
ref: context7
77+
- type: mcp
78+
ref: github

pkg/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ func validateConfig(cfg *latest.Config) error {
113113
return err
114114
}
115115

116+
if err := resolveMCPDefinitions(cfg); err != nil {
117+
return err
118+
}
119+
116120
allNames := map[string]bool{}
117121
for _, agent := range cfg.Agents {
118122
allNames[agent.Name] = true

pkg/config/latest/types.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,34 @@ type Config struct {
2121
Agents Agents `json:"agents,omitempty"`
2222
Providers map[string]ProviderConfig `json:"providers,omitempty"`
2323
Models map[string]ModelConfig `json:"models,omitempty"`
24+
MCPs map[string]MCPToolset `json:"mcps,omitempty"`
2425
RAG map[string]RAGConfig `json:"rag,omitempty"`
2526
Metadata Metadata `json:"metadata"`
2627
Permissions *PermissionsConfig `json:"permissions,omitempty"`
2728
}
2829

30+
// MCPToolset is a reusable MCP server definition stored in the top-level
31+
// "mcps" section. It is identical to a Toolset but skips the normal
32+
// Toolset.validate() call during YAML unmarshaling because the "type"
33+
// field is implicit (always "mcp") and the source (command/remote/ref)
34+
// is validated later during config resolution.
35+
type MCPToolset struct {
36+
Toolset `json:",inline" yaml:",inline"`
37+
}
38+
39+
func (m *MCPToolset) UnmarshalYAML(unmarshal func(any) error) error {
40+
// Use a plain alias to avoid triggering Toolset.UnmarshalYAML
41+
// (which calls validate and requires "type" to be set).
42+
type alias Toolset
43+
var tmp alias
44+
if err := unmarshal(&tmp); err != nil {
45+
return err
46+
}
47+
m.Toolset = Toolset(tmp)
48+
m.Type = "mcp"
49+
return m.validate()
50+
}
51+
2952
type Agents []AgentConfig
3053

3154
func (c *Agents) UnmarshalYAML(unmarshal func(any) error) error {

pkg/config/latest/validate.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package latest
22

33
import (
44
"errors"
5-
"strings"
65
)
76

87
func (t *Config) UnmarshalYAML(unmarshal func(any) error) error {
@@ -139,10 +138,6 @@ func (t *Toolset) validate() error {
139138
if count > 1 {
140139
return errors.New("either command, remote or ref must be set, but only one of those")
141140
}
142-
143-
if t.Ref != "" && !strings.Contains(t.Ref, "docker:") {
144-
return errors.New("only docker refs are supported for MCP tools, e.g., 'docker:context7'")
145-
}
146141
case "a2a":
147142
if t.URL == "" {
148143
return errors.New("a2a toolset requires a url to be set")

pkg/config/mcps.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"maps"
6+
"strings"
7+
8+
"github.com/docker/cagent/pkg/config/latest"
9+
)
10+
11+
// resolveMCPDefinitions resolves MCP definition references in agent toolsets.
12+
// When an agent toolset of type "mcp" has a ref that matches a key in the
13+
// top-level mcps section (rather than a "docker:" ref), the toolset is expanded
14+
// with the definition's properties. Any properties set directly on the toolset
15+
// override the corresponding definition properties.
16+
func resolveMCPDefinitions(cfg *latest.Config) error {
17+
for name, def := range cfg.MCPs {
18+
if err := validateMCPDefinition(name, &def.Toolset); err != nil {
19+
return err
20+
}
21+
}
22+
23+
for i := range cfg.Agents {
24+
agent := &cfg.Agents[i]
25+
for j := range agent.Toolsets {
26+
ts := &agent.Toolsets[j]
27+
if ts.Type != "mcp" || ts.Ref == "" || strings.Contains(ts.Ref, "docker:") {
28+
continue
29+
}
30+
31+
def, ok := cfg.MCPs[ts.Ref]
32+
if !ok {
33+
return fmt.Errorf("agent '%s' references non-existent MCP definition '%s'", agent.Name, ts.Ref)
34+
}
35+
36+
applyMCPDefaults(ts, &def.Toolset)
37+
}
38+
}
39+
40+
return nil
41+
}
42+
43+
// validateMCPDefinition validates that a definition's ref uses the docker: prefix.
44+
// The basic source validation (exactly one of command/remote/ref) is already handled
45+
// by Toolset.validate() during YAML unmarshaling.
46+
func validateMCPDefinition(name string, def *latest.Toolset) error {
47+
if def.Ref != "" && !strings.Contains(def.Ref, "docker:") {
48+
return fmt.Errorf("MCP definition '%s': only docker refs are supported (e.g., 'docker:context7')", name)
49+
}
50+
return nil
51+
}
52+
53+
// applyMCPDefaults fills empty fields in ts from def. Toolset values win.
54+
// Env maps are merged (toolset values take precedence on key conflicts).
55+
func applyMCPDefaults(ts, def *latest.Toolset) {
56+
// Replace the definition-name ref with the actual source.
57+
ts.Ref = def.Ref
58+
if ts.Command == "" {
59+
ts.Command = def.Command
60+
}
61+
if ts.Remote.URL == "" {
62+
ts.Remote = def.Remote
63+
}
64+
if len(ts.Args) == 0 {
65+
ts.Args = def.Args
66+
}
67+
if ts.Version == "" {
68+
ts.Version = def.Version
69+
}
70+
if ts.Config == nil {
71+
ts.Config = def.Config
72+
}
73+
if ts.Name == "" {
74+
ts.Name = def.Name
75+
}
76+
if ts.Instruction == "" {
77+
ts.Instruction = def.Instruction
78+
}
79+
if len(ts.Tools) == 0 {
80+
ts.Tools = def.Tools
81+
}
82+
if ts.Defer.IsEmpty() {
83+
ts.Defer = def.Defer
84+
}
85+
if len(def.Env) > 0 {
86+
merged := make(map[string]string, len(def.Env)+len(ts.Env))
87+
maps.Copy(merged, def.Env)
88+
maps.Copy(merged, ts.Env)
89+
ts.Env = merged
90+
}
91+
}

0 commit comments

Comments
 (0)