Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
157 changes: 157 additions & 0 deletions e2e/mcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package e2e

// Offline e2e for the MCP server: drives the real stdio transport with
// JSON-RPC and asserts over what a client would actually receive. The unit
// tests in internal/mcp pin the annotation policy; these prove the policy is
// wired to the tools that ship.

import (
"bytes"
"encoding/json"
"os/exec"
"strings"
"testing"
"time"
)

// mcpToolsList runs `akt mcp` with the given extra args, performs the
// initialize/tools list handshake over stdio, and returns the tool array.
func mcpToolsList(t *testing.T, home string, extra ...string) []map[string]any {
t.Helper()

args := append([]string{"--home", home, "mcp"}, extra...)
cmd := exec.Command(aktBinary(t), args...)

cmd.Stdin = strings.NewReader(strings.Join([]string{
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e","version":"1"}}}`,
`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`,
}, "\n") + "\n")

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

if err := cmd.Start(); err != nil {
t.Fatalf("failed to start akt mcp: %v", err)
}

done := make(chan error, 1)
go func() { done <- cmd.Wait() }()

select {
case <-done:
case <-time.After(60 * time.Second):
_ = cmd.Process.Kill()
t.Fatalf("akt mcp did not exit; stderr:\n%s", stderr.String())
}

// The server answers one JSON-RPC object per line; the tools list is the
// reply to id 2.
for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
var resp struct {
ID int `json:"id"`
Result struct {
Tools []map[string]any `json:"tools"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(line), &resp); err != nil {
continue
}
if resp.ID == 2 {
return resp.Result.Tools
}
}

t.Fatalf("no tools/list response\nstdout:\n%s\nstderr:\n%s", stdout.String(), stderr.String())

return nil
}

func annotation(tool map[string]any, key string) (bool, bool) {
ann, ok := tool["annotations"].(map[string]any)
if !ok {
return false, false
}

v, ok := ann[key].(bool)

return v, ok
}

// TestMCPQueryToolsAreAnnotatedReadOnly is the regression this guards: every
// tool once shipped with no annotations at all, so MCP's defaults applied and
// a client saw 19 read-only queries advertised as destructive.
func TestMCPQueryToolsAreAnnotatedReadOnly(t *testing.T) {
home := t.TempDir()
initHome(t, home)
mustRunAkt(t, home, "context", "network", "create", "net", "--chain-id", "akashnet-2", "--rpc", unreachableNode)
mustRunAkt(t, home, "context", "create", "ctx", "--network", "net", "--set-current")

tools := mcpToolsList(t, home)
if len(tools) == 0 {
t.Fatal("expected the read-only tool set to be registered")
}

for _, tool := range tools {
name, _ := tool["name"].(string)

ro, ok := annotation(tool, "readOnlyHint")
if !ok {
t.Errorf("%s: readOnlyHint absent; MCP would default it to false", name)
continue
}
if !ro {
t.Errorf("%s: registered without --enable-writes, so it must be readOnlyHint=true", name)
}

if d, ok := annotation(tool, "destructiveHint"); ok && d {
t.Errorf("%s: a query must not be destructiveHint=true", name)
}
}
}

// TestMCPWriteToolsRequireOptIn covers both halves of the --enable-writes
// contract: the mutating tools are absent by default, and when enabled they
// are the only ones that are not read-only.
//
// It also guards a bug where --enable-writes could not start at all: the
// command installs no tx flags, so the sign mode was empty and building the
// write client failed with `invalid sign mode ""`.
func TestMCPWriteToolsRequireOptIn(t *testing.T) {
home := t.TempDir()
initHome(t, home)
mustRunAkt(t, home, "context", "network", "create", "net", "--chain-id", "akashnet-2", "--rpc", unreachableNode)
mustRunAkt(t, home, "context", "create", "ctx", "--network", "net", "--set-current")

readOnly := mcpToolsList(t, home)
for _, tool := range readOnly {
if ro, ok := annotation(tool, "readOnlyHint"); ok && !ro {
name, _ := tool["name"].(string)
t.Errorf("%s: a mutating tool must not be registered without --enable-writes", name)
}
}

withWrites := mcpToolsList(t, home, "--enable-writes")
if len(withWrites) <= len(readOnly) {
t.Fatalf("--enable-writes registered no additional tools (%d vs %d)", len(withWrites), len(readOnly))
}

writes := 0
for _, tool := range withWrites {
ro, ok := annotation(tool, "readOnlyHint")
if !ok {
continue
}
if !ro {
writes++
name, _ := tool["name"].(string)
if d, ok := annotation(tool, "destructiveHint"); !ok || !d {
t.Errorf("%s: a mutating tool must be destructiveHint=true", name)
}
}
}

if writes == 0 {
t.Error("--enable-writes registered no tools marked as mutating")
}
}
57 changes: 54 additions & 3 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import (
"os"

sdkclient "github.com/cosmos/cosmos-sdk/client"
"github.com/cosmos/cosmos-sdk/client/flags"
"github.com/spf13/cobra"

"pkg.akt.dev/akt/internal/console"
aktctx "pkg.akt.dev/akt/internal/context"
aktmcp "pkg.akt.dev/akt/internal/mcp"
)

func mcpCmd() *cobra.Command {
func mcpCmd(mgrFn func() *aktctx.Manager) *cobra.Command {
cmd := &cobra.Command{
Use: "mcp",
Short: "Start MCP server for AI assistant integration",
Expand All @@ -19,7 +22,7 @@ func mcpCmd() *cobra.Command {
Exposes Akash Network tools for use by AI assistants. Configuration is
resolved from the active akt context (network, keyring, default account).

By default, only read-only query tools are available (21 tools). Write
By default, only read-only query tools are available. Write
tools (on-chain transactions and provider mutations) require explicit
opt-in via --enable-writes to prevent AI agents from sending unapproved
transactions.
Expand All @@ -41,7 +44,16 @@ close lease, and submit manifest.`,

enableWrites, _ := cmd.Flags().GetBool("enable-writes")

srv, err := aktmcp.New(ctx, cctx, enableWrites)
// The tx client validates the sign mode, which normally arrives
// from the --sign-mode flag that AddTxFlagsToCmd installs on tx
// commands. This command has no tx flags, so the field is empty
// and the write client fails to build at all. Default it to the
// same value the flag would have.
if enableWrites && cctx.SignModeStr == "" {
cctx = cctx.WithSignModeStr(flags.SignModeDirect)
}

srv, err := aktmcp.New(ctx, cctx, enableWrites, consoleClientFor(cmd, mgrFn))
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}
Expand All @@ -57,9 +69,48 @@ close lease, and submit manifest.`,
},
}

cmd.Flags().String("console-api-key", "",
"Console API key. Overrides the context credential and AKT_CONSOLE_API_KEY.")
cmd.Flags().Bool("enable-writes", false,
"Enable write tools (on-chain transactions and provider mutations). "+
"Without this flag, only read-only query tools are available.")

return cmd
}

// consoleClientFor resolves a Console API client for the MCP server, or nil
// when no key is available. Resolution matches the console command group
// (SPEC §7.1): --console-api-key flag > context credential (which already
// prefers AKT_CONSOLE_API_KEY over the stored value) > bare environment.
//
// nil rather than an error: a chain-only context is a perfectly good MCP
// setup, and registering Console tools that would fail on every call is worse
// than not offering them.
func consoleClientFor(cmd *cobra.Command, mgrFn func() *aktctx.Manager) *console.Client {
key, _ := cmd.Flags().GetString("console-api-key")

baseURL := ""
if m := mgrFn(); m != nil {
override := ""
if f := cmd.Flags().Lookup("context"); f != nil {
override = f.Value.String()
}

if rc, err := m.Resolve(m.ActiveContext(override)); err == nil && rc != nil {
if key == "" {
key = rc.ConsoleAPIKey
}
baseURL = rc.ConsoleAPIURL
}
}

if key == "" {
key = os.Getenv(aktctx.EnvConsoleAPIKey)
}

if key == "" {
return nil
}

return console.New(baseURL, key)
}
2 changes: 1 addition & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ func NewRootCmd(bi BuildInfo) *cobra.Command {
root.AddCommand(chaincli.QueryCmd())

root.AddCommand(monitorCmd(v))
root.AddCommand(mcpCmd())
root.AddCommand(mcpCmd(mgrFn))
root.AddCommand(cliprovider.Commands())
root.AddCommand(clisdl.Commands())
homeFn := func() string { return resolvedCfgRoot }
Expand Down
Loading
Loading