Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2ff522d
feat(cli): add mcpresolve package for catalog ref → ResolvedMCP
xytian315 May 18, 2026
ea5a450
refactor(cli): unify MCP_SERVERS_CONFIG writer into one writer over a…
xytian315 May 18, 2026
eb96fd7
feat(cli): arctl init --mcp auto-wires .env when catalog ref is remote
xytian315 May 18, 2026
a7d2ddb
feat(cli): arctl run errors clearly on remote-only mcp.yaml folders
xytian315 May 18, 2026
6d9c6d5
test(e2e): arctl init agent --mcp wires .env for remote catalog ref
xytian315 May 18, 2026
5b904e1
test(e2e): arctl run rejects remote-only mcp.yaml with helpful message
xytian315 May 18, 2026
3bf70b6
docs: arctl init --mcp auto-wires .env; arctl run rejects remote-only…
xytian315 May 18, 2026
a7bf888
fix(cli): lazy-init API client in apiClientMCPFetcher so arctl init -…
xytian315 May 18, 2026
35d7d88
refactor(cli): simplify mcpresolve and run-remote-mcp call sites
xytian315 May 18, 2026
3195dea
fix(cli): gofmt mcpresolve_test.go after multi-line Headers literal
xytian315 May 18, 2026
60f1915
Merge remote-tracking branch 'origin/main' into xytian315/run-remote-mcp
xytian315 May 19, 2026
5181773
fix(cli): address independent review on init/mcpresolve
xytian315 May 19, 2026
a1e12aa
chore(cli): drop dead ResolvedMCP.Tag; tidy remote-MCP run error
xytian315 May 19, 2026
efd2955
fix(cli): satisfy gci import grouping and modernize rangeint
xytian315 May 19, 2026
1e7aeb3
docs(cli): tighten remote-MCP wiring section
xytian315 May 19, 2026
6f5ec97
Merge remote-tracking branch 'origin/main' into xytian315/run-remote-mcp
xytian315 May 21, 2026
e9dddda
fix(cli): align with main's MCPRemote type + new runProject signature
xytian315 May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/declarative-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ arctl run --watch # rebuild and restart on file change
arctl run --dry-run # print the command without executing
```

`arctl run` rejects an `mcp.yaml` with `spec.remote` and no `spec.source` — nothing to build locally. To inspect a remote MCP's tools:

```bash
npx -y @modelcontextprotocol/inspector --server-url <url>
```

### Wiring MCP dependencies into a new agent

`arctl init agent` takes two repeatable flags:

- `--mcp <ref>` — adds the MCPServer to `agent.yaml.spec.mcpServers[]`. Accepts `name` or `name@tag` (defaults to `latest`). For remote catalog entries (`spec.remote` set), also appends an `MCP_SERVERS_CONFIG` entry to `.env`. Source-mode entries skip the `.env` write.
- `--local-mcp <path>` — wires `.env` against a sibling `arctl init mcp` project at `http://host.docker.internal:<port>/mcp` (port read from its `arctl.yaml`).

Repeatable; combined into one `MCP_SERVERS_CONFIG` line.

## MCP Servers

```bash
Expand Down
90 changes: 90 additions & 0 deletions e2e/init_build_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package e2e

import (
"fmt"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -237,6 +238,95 @@ func TestE2E_RunWatch_RebuildsOnFileChange(t *testing.T) {
t.Fatalf("expected 'Change detected' within 5s; got:\n%s", got)
}

// TestE2E_InitAgent_MCP_RemoteRef_WiresEnv exercises the full happy path for
// `arctl init agent --mcp <ref>` against a real registry: seed a remote
// MCPServer via `arctl apply`, then init an agent referencing it, and verify
// that the resulting .env carries a MCP_SERVERS_CONFIG entry pointing at the
// remote URL and that agent.yaml records the ref.
func TestE2E_InitAgent_MCP_RemoteRef_WiresEnv(t *testing.T) {
regURL := RegistryURL(t)
tmp := t.TempDir()
require.NoError(t, os.Chdir(tmp))

name := "e2e-test/" + UniqueNameWithPrefix("remote-mcp-wires-env")
tag := "latest"

// Cleanup the registry row even on test failure.
t.Cleanup(func() {
RunArctl(t, tmp, "delete", "mcpserver", name, "--tag", tag, "--registry-url", regURL)
})

// Seed a remote MCPServer in the registry so --mcp can resolve it.
yaml := fmt.Sprintf(`
apiVersion: ar.dev/v1alpha1
kind: MCPServer
metadata:
name: %s
spec:
title: E2E Remote MCP for init-wire test
remote:
type: streamable-http
url: https://example.test/mcp
`, name)
yamlPath := writeDeclarativeYAML(t, tmp, "remote-mcp.yaml", yaml)
apply := RunArctl(t, tmp, "apply", "-f", yamlPath, "--registry-url", regURL)
RequireSuccess(t, apply)

// arctl init agent myagent --mcp <ref> should wire .env.
result := RunArctl(t, tmp,
"init", "agent", "myagent",
"--framework", "adk", "--language", "python",
"--mcp", name,
"--registry-url", regURL)
RequireSuccess(t, result)

pd := filepath.Join(tmp, "myagent")
env, err := os.ReadFile(filepath.Join(pd, ".env"))
require.NoError(t, err)
assert.Contains(t, string(env), "MCP_SERVERS_CONFIG=")
assert.Contains(t, string(env), fmt.Sprintf(`"name":"%s"`, name))
assert.Contains(t, string(env), `"url":"https://example.test/mcp"`)

agentYAML, err := os.ReadFile(filepath.Join(pd, "agent.yaml"))
require.NoError(t, err)
assert.Contains(t, string(agentYAML), "name: "+name)

// Status output should mention the .env wire (printed to stderr).
assert.Contains(t, result.Stderr, "wired .env: "+name)
}

func TestE2E_Run_RemoteOnlyMCP_Errors(t *testing.T) {
tmp := t.TempDir()
require.NoError(t, os.Chdir(tmp))

// Hand-craft a project folder shaped like an mcp project (arctl.yaml)
// but whose mcp.yaml is Remote-only. arctl init mcp doesn't scaffold
// this shape; users would hit it via manual editing or after pulling
// a remote MCPServer down with `arctl get`.
require.NoError(t, os.WriteFile(filepath.Join(tmp, "arctl.yaml"), []byte(`
framework: fastmcp
language: python
port: 3000
`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "mcp.yaml"), []byte(`
apiVersion: ar.dev/v1alpha1
kind: MCPServer
metadata:
name: acme/remote-only
spec:
remote:
type: streamable-http
url: https://example.test/mcp
`), 0o644))

result := RunArctl(t, tmp, "run", "--dry-run")
require.NotEqual(t, 0, result.ExitCode, "remote-only mcp.yaml should fail-fast")
combined := result.Stderr + result.Stdout
assert.Contains(t, combined, "remote MCPServer")
assert.Contains(t, combined, "npx -y @modelcontextprotocol/inspector")
assert.Contains(t, combined, "https://example.test/mcp")
}

func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
Expand Down
64 changes: 64 additions & 0 deletions internal/cli/declarative/declarative.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package declarative

import (
"context"
"os"
"strings"

"github.com/spf13/cobra"

cliCommon "github.com/agentregistry-dev/agentregistry/internal/cli/common"
"github.com/agentregistry-dev/agentregistry/internal/cli/scheme"
Expand All @@ -17,6 +21,66 @@ func SetAPIClient(c *client.Client) {
apiClient = c
}

// apiClientMCPFetcher adapts the live registry client to mcpresolve.Fetcher
// for use by `arctl init --mcp`. The init subtree skips PersistentPreRunE
// (see pkg/cli/root.go's preRunSkipCommands), so apiClient is normally nil
// here — Fetch lazily constructs a lightweight client from the resolved
// --registry-url/--registry-token flags or their env-var defaults when that
// happens. Plain `arctl init` without --mcp stays fully offline because
// Fetch is only called when there's a ref to resolve.
type apiClientMCPFetcher struct {
cmd *cobra.Command
}

func (f apiClientMCPFetcher) Fetch(ctx context.Context, name, tag string) (*v1alpha1.MCPServer, error) {
c := apiClient
if c == nil {
c = client.NewClient(lookupRegistryURL(f.cmd), lookupRegistryToken(f.cmd))
}
return client.GetTyped(ctx, c, v1alpha1.KindMCPServer, v1alpha1.DefaultNamespace, name, tag, func() *v1alpha1.MCPServer { return &v1alpha1.MCPServer{} })
}

// lookupPersistentFlag walks the cmd→parent chain to find a persistent
// flag value. Needed for commands that skip PersistentPreRunE: cobra
// normally merges parent persistent flags into child flag sets at Execute
// time, but commands routed through that path won't see them. Returns
// "" if the flag isn't declared anywhere in the chain.
func lookupPersistentFlag(cmd *cobra.Command, name string) string {
for c := cmd; c != nil; c = c.Parent() {
if f := c.PersistentFlags().Lookup(name); f != nil {
return f.Value.String()
}
if f := c.Flags().Lookup(name); f != nil {
return f.Value.String()
}
}
return ""
}

// lookupRegistryURL resolves --registry-url for commands that skip the
// root pre-run hook, falling back to env then client.DefaultBaseURL.
// Mirrors pkg/cli/root.go's resolveRegistryTarget+normalizeBaseURL.
func lookupRegistryURL(cmd *cobra.Command) string {
raw := strings.TrimSpace(lookupPersistentFlag(cmd, "registry-url"))
if raw == "" {
raw = strings.TrimSpace(os.Getenv("ARCTL_API_BASE_URL"))
}
if raw == "" {
return client.DefaultBaseURL
}
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
raw = "http://" + raw
}
return raw
}

func lookupRegistryToken(cmd *cobra.Command) string {
if v := lookupPersistentFlag(cmd, "registry-token"); v != "" {
return v
}
return os.Getenv("ARCTL_API_TOKEN")
}

func init() {
scheme.Register(typedKind(
"agent", "agents", []string{"Agent"},
Expand Down
Loading
Loading