Skip to content

Commit e42a71d

Browse files
committed
Introduce a GatewayToolset
Signed-off-by: David Gageot <david.gageot@docker.com>
1 parent 2dcc3a2 commit e42a71d

8 files changed

Lines changed: 178 additions & 73 deletions

File tree

pkg/agent/agent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ func (a *Agent) Tools(ctx context.Context) ([]tools.Tool, error) {
9898
for _, toolSet := range a.toolsets {
9999
ta, err := toolSet.Tools(ctx)
100100
if err != nil {
101-
return nil, fmt.Errorf("failed to get tools: %w", err)
101+
return nil, err
102102
}
103103
agentTools = append(agentTools, ta...)
104104
}

pkg/environment/1password.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
)
1212

1313
type OnePasswordProvider struct {
14-
secrets onepassword.SecretsAPI
14+
opToken string
1515
}
1616

1717
func NewOnePasswordProvider(ctx context.Context) (*OnePasswordProvider, error) {
@@ -20,7 +20,7 @@ func NewOnePasswordProvider(ctx context.Context) (*OnePasswordProvider, error) {
2020
return nil, errors.New("OP_SERVICE_ACCOUNT_TOKEN environment variable is required for 1Password integration")
2121
}
2222

23-
client, err := onepassword.NewClient(ctx,
23+
_, err := onepassword.NewClient(ctx,
2424
onepassword.WithServiceAccountToken(opToken),
2525
onepassword.WithIntegrationInfo("cagent 1Password Integration", "v1.0.0"),
2626
)
@@ -29,17 +29,22 @@ func NewOnePasswordProvider(ctx context.Context) (*OnePasswordProvider, error) {
2929
}
3030

3131
return &OnePasswordProvider{
32-
secrets: client.Secrets(),
32+
opToken: opToken,
3333
}, nil
3434
}
3535

3636
func (p *OnePasswordProvider) Get(ctx context.Context, name string) string {
37-
path := "op://cagent/" + name + "/password"
38-
slog.Debug("Looking for environment variable in 1Password", "path", path)
37+
// This thing is not thread-safe, so we create a new client each time (for now)
38+
// even though it's probably too slow.
39+
client, _ := onepassword.NewClient(ctx,
40+
onepassword.WithServiceAccountToken(p.opToken),
41+
onepassword.WithIntegrationInfo("cagent 1Password Integration", "v1.0.0"),
42+
)
3943

40-
secret, err := p.secrets.Resolve(ctx, "op://cagent/"+name+"/password")
44+
secret, err := client.Secrets().Resolve(ctx, "op://cagent/"+name+"/password")
4145
if err != nil {
4246
// Ignore error
47+
slog.Error("Failed to find secret in 1Password", "error", err)
4348
return ""
4449
}
4550

pkg/environment/keychain.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func (p *KeychainProvider) Get(ctx context.Context, name string) string {
4545
err := cmd.Run()
4646
if err != nil {
4747
// Ignore error
48+
slog.Error("Failed to find secret in keychain", "error", err)
4849
return ""
4950
}
5051

pkg/environment/pass.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func (p *PassProvider) Get(ctx context.Context, name string) string {
4444
err := cmd.Run()
4545
if err != nil {
4646
// Ignore error
47+
slog.Error("Failed to find secret in pass", "error", err)
4748
return ""
4849
}
4950

pkg/gateway/catalog.go

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111

1212
const DockerCatalogURL = "https://desktop.docker.com/mcp/catalog/v2/catalog.yaml"
1313

14-
func RequiredEnvVars(ctx context.Context, serverName, catalogURL string) ([]string, error) {
14+
func RequiredEnvVars(ctx context.Context, serverName, catalogURL string) ([]Secret, error) {
1515
catalog, err := readCatalog(ctx, catalogURL)
1616
if err != nil {
1717
return nil, err
@@ -22,12 +22,7 @@ func RequiredEnvVars(ctx context.Context, serverName, catalogURL string) ([]stri
2222
return nil, fmt.Errorf("MCP server %q not found in catalog %q", serverName, catalogURL)
2323
}
2424

25-
var secrets []string
26-
for _, secret := range server.Secrets {
27-
secrets = append(secrets, secret.Env)
28-
}
29-
30-
return secrets, nil
25+
return server.Secrets, nil
3126
}
3227

3328
func readCatalog(ctx context.Context, url string) (Catalog, error) {

pkg/teamloader/teamloader.go

Lines changed: 2 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313
"github.com/docker/cagent/pkg/config"
1414
latest "github.com/docker/cagent/pkg/config/v2"
1515
"github.com/docker/cagent/pkg/environment"
16-
"github.com/docker/cagent/pkg/gateway"
1716
"github.com/docker/cagent/pkg/memory"
1817
"github.com/docker/cagent/pkg/memory/database/sqlite"
1918
"github.com/docker/cagent/pkg/model/provider"
@@ -22,7 +21,6 @@ import (
2221
"github.com/docker/cagent/pkg/tools"
2322
"github.com/docker/cagent/pkg/tools/builtin"
2423
"github.com/docker/cagent/pkg/tools/mcp"
25-
"gopkg.in/yaml.v3"
2624
)
2725

2826
// LoadTeams loads all agent teams from the given directory or file path
@@ -265,56 +263,10 @@ func getToolsForAgent(ctx context.Context, a *latest.AgentConfig, parentDir stri
265263
t = append(t, builtin.NewFilesystemTool([]string{wd}, builtin.WithAllowedTools(toolset.Tools)))
266264

267265
case toolset.Type == "mcp" && toolset.Ref != "":
268-
serverName := strings.TrimPrefix(toolset.Ref, "docker:")
269-
args := []string{"mcp", "gateway", "run", "--servers=" + serverName}
270-
271-
var cleanUp func() error
272-
if toolset.Config != nil {
273-
file, err := os.CreateTemp("", "mcp-config-*.yaml")
274-
if err != nil {
275-
return nil, fmt.Errorf("failed to create temp file: %w", err)
276-
}
277-
cleanUp = func() error { return os.Remove(file.Name()) }
278-
279-
serverConfig := map[string]any{
280-
serverName: toolset.Config,
281-
}
282-
if err := yaml.NewEncoder(file).Encode(serverConfig); err != nil {
283-
return nil, fmt.Errorf("failed to write config to temp file: %w", err)
284-
}
285-
286-
args = append(args, "--config="+file.Name())
287-
}
288-
289-
// Isolate ourselves from the MCP Toolkit config by always using the Docker MCP catalog.
290-
// This improves shareability of agents.
291-
args = append(args, "--catalog="+gateway.DockerCatalogURL)
292-
293-
// Check which env vars are required to configure the MCP server secrets.
294-
requiredEnvs, err := gateway.RequiredEnvVars(ctx, serverName, gateway.DockerCatalogURL)
295-
if err != nil {
296-
return nil, fmt.Errorf("reading which secrets the MCP server needs: %w", err)
297-
}
298-
299-
// Check that we have all required env vars set.
300-
for _, requiredEnv := range requiredEnvs {
301-
v := envProvider.Get(ctx, requiredEnv)
302-
if v == "" {
303-
// TODO(dga): The secret might be configured in the MCP Toolkit.
304-
// so don't fail for now...
305-
// return nil, fmt.Errorf("MCP server %q requires environment variable %q to be set. Either set it before running cagent or run cagent with --env-from-file", serverName, requiredEnv)
306-
}
307-
}
308-
309-
// We have all the secrets, let's create a file with all of them for the MCP Gateway
310-
// ...
311-
312-
// TODO: `docker mcp` doesn't know how to read secrets from env variables.
313-
// TODO(dga): If the server's docker image had the right annotations, we could run it directly with `docker run` or with the MCP gateway as a go library.
314-
t = append(t, mcp.NewToolsetCommand("docker", args, env, toolset.Tools, cleanUp))
266+
t = append(t, mcp.NewGatewayToolset(toolset.Ref, toolset.Config, toolset.Tools, envProvider))
315267

316268
case toolset.Type == "mcp" && toolset.Command != "":
317-
t = append(t, mcp.NewToolsetCommand(toolset.Command, toolset.Args, env, toolset.Tools, nil))
269+
t = append(t, mcp.NewToolsetCommand(toolset.Command, toolset.Args, env, toolset.Tools))
318270

319271
case toolset.Type == "mcp" && toolset.Remote.URL != "":
320272
// TODO: the tool's config can set env variables that could be used in headers.

pkg/tools/mcp/gateway.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
"os"
9+
"strings"
10+
"sync"
11+
12+
"github.com/docker/cagent/pkg/environment"
13+
"github.com/docker/cagent/pkg/gateway"
14+
"github.com/docker/cagent/pkg/tools"
15+
"gopkg.in/yaml.v3"
16+
)
17+
18+
type GatewayToolset struct {
19+
ref string
20+
config any
21+
toolFilter []string
22+
envProvider environment.Provider
23+
24+
once sync.Once
25+
initErr error
26+
cmdToolset *Toolset
27+
cleanUpConfig func() error
28+
cleanUpSecrets func() error
29+
}
30+
31+
var _ tools.ToolSet = (*GatewayToolset)(nil)
32+
33+
func NewGatewayToolset(ref string, config any, toolFilter []string, envProvider environment.Provider) *GatewayToolset {
34+
slog.Debug("Creating MCP Gateway toolset", "ref", ref, "toolFilter", toolFilter)
35+
36+
return &GatewayToolset{
37+
ref: ref,
38+
config: config,
39+
toolFilter: toolFilter,
40+
envProvider: envProvider,
41+
cleanUpConfig: func() error { return nil },
42+
cleanUpSecrets: func() error { return nil },
43+
}
44+
}
45+
46+
func (t *GatewayToolset) Instructions() string {
47+
return ""
48+
}
49+
50+
func (t *GatewayToolset) configureOnce(ctx context.Context) error {
51+
mcpServerName := parseServerRef(t.ref)
52+
53+
// Check which secrets (env vars) are required by the MCP server.
54+
secrets, err := gateway.RequiredEnvVars(ctx, mcpServerName, gateway.DockerCatalogURL)
55+
if err != nil {
56+
return fmt.Errorf("reading which secrets the MCP server needs: %w", err)
57+
}
58+
59+
// Make sure all the required secrets are available in the environment.
60+
// TODO(dga): Ideally, the MCP gateway would use the same provider that we have.
61+
fileSecrets, err := writeSecretsToFile(ctx, mcpServerName, secrets, t.envProvider)
62+
if err != nil {
63+
return fmt.Errorf("writing secrets to file: %w", err)
64+
}
65+
t.cleanUpSecrets = func() error { return os.Remove(fileSecrets) }
66+
67+
fileConfig, err := writeConfigToFile(ctx, mcpServerName, t.config)
68+
if err != nil {
69+
return fmt.Errorf("writing config to file: %w", err)
70+
}
71+
t.cleanUpConfig = func() error { return os.Remove(fileConfig) }
72+
73+
// Isolate ourselves from the MCP Toolkit config by always using the Docker MCP catalog and custom config and secrets.
74+
// This improves shareability of agents.
75+
args := []string{
76+
"mcp", "gateway", "run",
77+
"--servers", mcpServerName,
78+
"--catalog", gateway.DockerCatalogURL,
79+
"--secrets", fileSecrets,
80+
"--config", fileConfig,
81+
}
82+
t.cmdToolset = NewToolsetCommand("docker", args, nil, t.toolFilter)
83+
84+
return nil
85+
}
86+
87+
func (t *GatewayToolset) ensureConfigured(ctx context.Context) error {
88+
t.once.Do(func() {
89+
t.initErr = t.configureOnce(ctx)
90+
})
91+
return t.initErr
92+
}
93+
94+
func (t *GatewayToolset) Tools(ctx context.Context) ([]tools.Tool, error) {
95+
if err := t.ensureConfigured(ctx); err != nil {
96+
return nil, err
97+
}
98+
return t.cmdToolset.Tools(ctx)
99+
}
100+
101+
func (t *GatewayToolset) Start(ctx context.Context) error {
102+
if err := t.ensureConfigured(ctx); err != nil {
103+
return err
104+
}
105+
return t.cmdToolset.Start(ctx)
106+
}
107+
108+
func (t *GatewayToolset) Stop() error {
109+
stopErr := t.cmdToolset.Stop()
110+
cleanUpSecretsErr := t.cleanUpSecrets()
111+
cleanUpConfigErr := t.cleanUpConfig()
112+
113+
return errors.Join(stopErr, cleanUpSecretsErr, cleanUpConfigErr)
114+
}
115+
116+
func parseServerRef(ref string) string {
117+
return strings.TrimPrefix(ref, "docker:")
118+
}
119+
120+
func writeSecretsToFile(ctx context.Context, mcpServerName string, secrets []gateway.Secret, envProvider environment.Provider) (string, error) {
121+
var secretValues []string
122+
for _, secret := range secrets {
123+
v := envProvider.Get(ctx, secret.Env)
124+
if v == "" {
125+
// TODO(dga): Add a link to some doc that explains the different ways to provide secrets.
126+
return "", fmt.Errorf("MCP server %q requires environment variable %q to be set. Either set it before running cagent or run cagent with --env-from-file", mcpServerName, secret.Env)
127+
}
128+
129+
secretValues = append(secretValues, fmt.Sprintf("%s=%s", secret.Name, v))
130+
}
131+
132+
// We have all the secrets, let's create a file with all of them for the MCP Gateway
133+
return writeTempFile("mcp-secrets-*", []byte(strings.Join(secretValues, "\n")))
134+
}
135+
136+
func writeConfigToFile(_ context.Context, mcpServerName string, config any) (string, error) {
137+
buf, err := yaml.Marshal(map[string]any{
138+
mcpServerName: config,
139+
})
140+
if err != nil {
141+
return "", err
142+
}
143+
144+
return writeTempFile("mcp-config-*", buf)
145+
}
146+
147+
func writeTempFile(nameTemplate string, content []byte) (string, error) {
148+
f, err := os.CreateTemp("", nameTemplate)
149+
if err != nil {
150+
return "", fmt.Errorf("creating temp file: %w", err)
151+
}
152+
defer f.Close()
153+
154+
if _, err := f.Write(content); err != nil {
155+
return "", err
156+
}
157+
158+
return f.Name(), nil
159+
}

pkg/tools/mcp/toolset.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,18 @@ import (
1313
type Toolset struct {
1414
c *Client
1515
toolFilter []string
16-
cleanup func() error
1716
}
1817

1918
// Make sure the MCP Toolset always implements _our_ ToolSet interface
2019
var _ tools.ToolSet = (*Toolset)(nil)
2120

2221
// NewToolsetCommand creates a new MCP toolset from a command.
23-
func NewToolsetCommand(command string, args, env, toolFilter []string, cleanup func() error) *Toolset {
22+
func NewToolsetCommand(command string, args, env, toolFilter []string) *Toolset {
2423
slog.Debug("Creating MCP toolset", "command", command, "args", args, "toolFilter", toolFilter)
2524

2625
return &Toolset{
2726
c: NewStdioClient(command, args, env),
2827
toolFilter: toolFilter,
29-
cleanup: cleanup,
3028
}
3129
}
3230

@@ -88,12 +86,6 @@ func (t *Toolset) Stop() error {
8886
slog.Error("Failed to stop MCP toolset", "error", err)
8987
return err
9088
}
91-
if t.cleanup != nil {
92-
if err := t.cleanup(); err != nil {
93-
slog.Error("Failed to cleanup MCP toolset", "error", err)
94-
return err
95-
}
96-
}
9789
slog.Debug("Stopped MCP toolset successfully")
9890
return nil
9991
}

0 commit comments

Comments
 (0)