diff --git a/e2e/mcp_test.go b/e2e/mcp_test.go new file mode 100644 index 00000000..dae90ed0 --- /dev/null +++ b/e2e/mcp_test.go @@ -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") + } +} diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 3ed2ddb6..3fc922da 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -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", @@ -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. @@ -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) } @@ -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) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index c52dcc2c..36251be8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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 } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 010c8637..ffcd6c0c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -14,9 +14,11 @@ import ( client "pkg.akt.dev/go/node/client/v1beta3" + aktconsole "pkg.akt.dev/akt/internal/console" "pkg.akt.dev/akt/internal/mcp/tools/audit" "pkg.akt.dev/akt/internal/mcp/tools/bank" "pkg.akt.dev/akt/internal/mcp/tools/cert" + consoletools "pkg.akt.dev/akt/internal/mcp/tools/console" "pkg.akt.dev/akt/internal/mcp/tools/deployment" "pkg.akt.dev/akt/internal/mcp/tools/market" "pkg.akt.dev/akt/internal/mcp/tools/node" @@ -37,7 +39,7 @@ type Server struct { // When enableWrites is true, write tools (on-chain transactions and provider // REST mutations) are additionally registered. The user must explicitly opt // in by passing --enable-writes. -func New(ctx context.Context, cctx sdkclient.Context, enableWrites bool) (*Server, error) { +func New(ctx context.Context, cctx sdkclient.Context, enableWrites bool, consoleClient *aktconsole.Client) (*Server, error) { srv := mcpserver.NewMCPServer( "akash-mcp", "0.1.0", @@ -61,9 +63,40 @@ func New(ctx context.Context, cctx sdkclient.Context, enableWrites bool) (*Serve s.registerQueryTools(cl) } + // The Console rail is additive: a managed context reaches deployments, + // wallet and providers through the Console API instead of a chain node, so + // an assistant with only a Console key still has tools. Registered only + // when a key resolved -- otherwise every call would fail on auth. + if consoleClient != nil { + s.registerConsoleQueryTools(consoleClient) + + if enableWrites { + s.registerConsoleWriteTools(consoleClient) + } + } + return s, nil } +// registerConsoleQueryTools registers the read-only Console API tools. +func (s *Server) registerConsoleQueryTools(cl *aktconsole.Client) { + s.addQueryTool(consoletools.ToolListDeployments(), consoletools.HandleListDeployments(cl)) + s.addQueryTool(consoletools.ToolGetDeployment(), consoletools.HandleGetDeployment(cl)) + s.addQueryTool(consoletools.ToolListBids(), consoletools.HandleListBids(cl)) + s.addQueryTool(consoletools.ToolWalletBalance(), consoletools.HandleWalletBalance(cl)) + s.addQueryTool(consoletools.ToolUsageHistory(), consoletools.HandleUsageHistory(cl)) + s.addQueryTool(consoletools.ToolListProviders(), consoletools.HandleListProviders(cl)) + s.addQueryTool(consoletools.ToolGetProvider(), consoletools.HandleGetProvider(cl)) + s.addQueryTool(consoletools.ToolGPUPrices(), consoletools.HandleGPUPrices(cl)) +} + +// registerConsoleWriteTools registers the Console API tools that spend funds +// or tear down workloads. +func (s *Server) registerConsoleWriteTools(cl *aktconsole.Client) { + s.addWriteTool(consoletools.ToolCloseDeployment(), consoletools.HandleCloseDeployment(cl)) + s.addWriteTool(consoletools.ToolDeposit(), consoletools.HandleDeposit(cl)) +} + // ServeStdio starts the server over stdio transport. func (s *Server) ServeStdio(ctx context.Context) error { return mcpserver.ServeStdio(s.mcp, mcpserver.WithStdioContextFunc(func(_ context.Context) context.Context { @@ -74,55 +107,94 @@ func (s *Server) ServeStdio(ctx context.Context) error { // registerQueryTools registers all read-only query tools. func (s *Server) registerQueryTools(cl client.LightClient) { // Node tools - s.addTool(node.ToolNodeStatus(), node.HandleNodeStatus(cl)) - s.addTool(node.ToolBlockHeight(), node.HandleBlockHeight(cl)) + s.addQueryTool(node.ToolNodeStatus(), node.HandleNodeStatus(cl)) + s.addQueryTool(node.ToolBlockHeight(), node.HandleBlockHeight(cl)) // Bank tools - s.addTool(bank.ToolAccountBalance(), bank.HandleAccountBalance(cl)) + s.addQueryTool(bank.ToolAccountBalance(), bank.HandleAccountBalance(cl)) // Deployment query tools - s.addTool(deployment.ToolListDeployments(), deployment.HandleListDeployments(cl)) - s.addTool(deployment.ToolGetDeployment(), deployment.HandleGetDeployment(cl)) - s.addTool(deployment.ToolGetGroup(), deployment.HandleGetGroup(cl)) + s.addQueryTool(deployment.ToolListDeployments(), deployment.HandleListDeployments(cl)) + s.addQueryTool(deployment.ToolGetDeployment(), deployment.HandleGetDeployment(cl)) + s.addQueryTool(deployment.ToolGetGroup(), deployment.HandleGetGroup(cl)) // Market query tools - s.addTool(market.ToolListOrders(), market.HandleListOrders(cl)) - s.addTool(market.ToolGetOrder(), market.HandleGetOrder(cl)) - s.addTool(market.ToolListBids(), market.HandleListBids(cl)) - s.addTool(market.ToolGetBid(), market.HandleGetBid(cl)) - s.addTool(market.ToolListLeases(), market.HandleListLeases(cl)) - s.addTool(market.ToolGetLease(), market.HandleGetLease(cl)) + s.addQueryTool(market.ToolListOrders(), market.HandleListOrders(cl)) + s.addQueryTool(market.ToolGetOrder(), market.HandleGetOrder(cl)) + s.addQueryTool(market.ToolListBids(), market.HandleListBids(cl)) + s.addQueryTool(market.ToolGetBid(), market.HandleGetBid(cl)) + s.addQueryTool(market.ToolListLeases(), market.HandleListLeases(cl)) + s.addQueryTool(market.ToolGetLease(), market.HandleGetLease(cl)) // Provider on-chain query tools - s.addTool(provider.ToolListProviders(), provider.HandleListProviders(cl)) - s.addTool(provider.ToolGetProvider(), provider.HandleGetProvider(cl)) + s.addQueryTool(provider.ToolListProviders(), provider.HandleListProviders(cl)) + s.addQueryTool(provider.ToolGetProvider(), provider.HandleGetProvider(cl)) // Provider REST query tools - s.addTool(provider.ToolProviderStatus(), provider.HandleProviderStatus(cl)) - s.addTool(provider.ToolLeaseStatus(), provider.HandleLeaseStatus(cl)) - s.addTool(provider.ToolServiceStatus(), provider.HandleServiceStatus(cl)) + s.addQueryTool(provider.ToolProviderStatus(), provider.HandleProviderStatus(cl)) + s.addQueryTool(provider.ToolLeaseStatus(), provider.HandleLeaseStatus(cl)) + s.addQueryTool(provider.ToolServiceStatus(), provider.HandleServiceStatus(cl)) // Audit tools - s.addTool(audit.ToolListAuditedProviders(), audit.HandleListAuditedProviders(cl)) + s.addQueryTool(audit.ToolListAuditedProviders(), audit.HandleListAuditedProviders(cl)) // Cert tools - s.addTool(cert.ToolListCertificates(), cert.HandleListCertificates(cl)) + s.addQueryTool(cert.ToolListCertificates(), cert.HandleListCertificates(cl)) } // registerWriteTools registers write tools that require --enable-writes. // These tools perform on-chain transactions or mutating provider REST calls. func (s *Server) registerWriteTools(cl client.Client) { // Deployment tx tools - s.addTool(deployment.ToolCloseDeployment(), deployment.HandleCloseDeployment(cl)) + s.addWriteTool(deployment.ToolCloseDeployment(), deployment.HandleCloseDeployment(cl)) // Market tx tools - s.addTool(market.ToolCreateLease(), market.HandleCreateLease(cl)) - s.addTool(market.ToolCloseLease(), market.HandleCloseLease(cl)) + s.addWriteTool(market.ToolCreateLease(), market.HandleCreateLease(cl)) + s.addWriteTool(market.ToolCloseLease(), market.HandleCloseLease(cl)) // Provider REST mutation tools - s.addTool(provider.ToolSubmitManifest(), provider.HandleSubmitManifest(cl)) + s.addWriteTool(provider.ToolSubmitManifest(), provider.HandleSubmitManifest(cl)) } -func (s *Server) addTool(tool mcp.Tool, handler mcpserver.ToolHandlerFunc) { +// addQueryTool registers a read-only tool. The MCP spec defaults an +// unannotated tool to destructiveHint=true and readOnlyHint=false, so a client +// deciding what to auto-approve treats every unannotated query as dangerous. +// Annotating here rather than at each declaration keeps the policy in one +// place: registration through this method is what makes a tool read-only, so +// the two cannot drift apart. +func (s *Server) addQueryTool(tool mcp.Tool, handler mcpserver.ToolHandlerFunc) { + tool.Annotations = queryToolAnnotations() s.mcp.AddTool(tool, handler) } + +// queryToolAnnotations is the annotation set every read-only tool carries. +func queryToolAnnotations() mcp.ToolAnnotation { + return mcp.ToolAnnotation{ + ReadOnlyHint: mcp.ToBoolPtr(true), + DestructiveHint: mcp.ToBoolPtr(false), + // Repeating a query changes nothing beyond what the chain or the + // provider already reports. + IdempotentHint: mcp.ToBoolPtr(true), + // Every tool reaches a chain node, a provider gateway or the Console + // API, none of which are a closed local domain. + OpenWorldHint: mcp.ToBoolPtr(true), + } +} + +// addWriteTool registers a mutating tool. These broadcast transactions or +// mutate provider state, so they stay destructive and non-idempotent -- a +// client should confirm them with the user rather than auto-approve. +func (s *Server) addWriteTool(tool mcp.Tool, handler mcpserver.ToolHandlerFunc) { + tool.Annotations = writeToolAnnotations() + s.mcp.AddTool(tool, handler) +} + +// writeToolAnnotations is the annotation set every mutating tool carries. +func writeToolAnnotations() mcp.ToolAnnotation { + return mcp.ToolAnnotation{ + ReadOnlyHint: mcp.ToBoolPtr(false), + DestructiveHint: mcp.ToBoolPtr(true), + IdempotentHint: mcp.ToBoolPtr(false), + OpenWorldHint: mcp.ToBoolPtr(true), + } +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 00000000..ff5e9cd5 --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,71 @@ +package mcp + +import "testing" + +// mustBool reads an annotation pointer, failing when the hint was never set. +// +// "unset" and "false" are not the same thing here: the MCP spec treats an +// absent hint as a default, and those defaults (destructiveHint=true, +// readOnlyHint=false) are the opposite of what a query needs. Leaving them +// nil is exactly the bug these tests exist to catch. +func mustBool(t *testing.T, field string, p *bool) bool { + t.Helper() + + if p == nil { + t.Fatalf("%s is unset; the MCP default would apply instead", field) + } + + return *p +} + +// TestQueryToolAnnotations pins what a read-only tool advertises. Clients +// decide what to auto-approve from these hints, so an unannotated query is +// presented to the user as a destructive operation on every call. +func TestQueryToolAnnotations(t *testing.T) { + a := queryToolAnnotations() + + if !mustBool(t, "readOnlyHint", a.ReadOnlyHint) { + t.Error("a query must be readOnlyHint=true") + } + if mustBool(t, "destructiveHint", a.DestructiveHint) { + t.Error("a query must be destructiveHint=false") + } + if !mustBool(t, "idempotentHint", a.IdempotentHint) { + t.Error("a query must be idempotentHint=true") + } + // Every tool reaches a chain node, a provider gateway or the Console API. + if !mustBool(t, "openWorldHint", a.OpenWorldHint) { + t.Error("a query must be openWorldHint=true") + } +} + +// TestWriteToolAnnotations pins the inverse: these broadcast transactions or +// spend credits, so a client should confirm them rather than auto-approve. +func TestWriteToolAnnotations(t *testing.T) { + a := writeToolAnnotations() + + if mustBool(t, "readOnlyHint", a.ReadOnlyHint) { + t.Error("a write must be readOnlyHint=false") + } + if !mustBool(t, "destructiveHint", a.DestructiveHint) { + t.Error("a write must be destructiveHint=true") + } + if mustBool(t, "idempotentHint", a.IdempotentHint) { + t.Error("a write must be idempotentHint=false") + } +} + +// TestQueryAndWriteAnnotationsDiffer guards the distinction itself. Before +// annotations were set at all, every tool looked identical to a client -- +// all destructive -- leaving no way to tell a balance check from a +// deployment close. +func TestQueryAndWriteAnnotationsDiffer(t *testing.T) { + q, w := queryToolAnnotations(), writeToolAnnotations() + + if *q.ReadOnlyHint == *w.ReadOnlyHint { + t.Error("read and write tools must not advertise the same readOnlyHint") + } + if *q.DestructiveHint == *w.DestructiveHint { + t.Error("read and write tools must not advertise the same destructiveHint") + } +} diff --git a/internal/mcp/tools/console/tools.go b/internal/mcp/tools/console/tools.go new file mode 100644 index 00000000..dcc97dc4 --- /dev/null +++ b/internal/mcp/tools/console/tools.go @@ -0,0 +1,320 @@ +// Package console exposes the Console API as MCP tools, so an assistant +// working against a managed (console-api) context reaches the same rail the +// `akt console` commands use rather than the chain directly. +package console + +import ( + "context" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "pkg.akt.dev/akt/internal/console" + "pkg.akt.dev/akt/internal/mcp/marshal" +) + +const ( + defaultListLimit = 50 + maxListLimit = 200 +) + +// ToolListDeployments returns the tool definition for listing deployments. +func ToolListDeployments() mcp.Tool { + return mcp.NewTool( + "console_list_deployments", + mcp.WithDescription("List the Console-managed deployments belonging to the configured API key."), + mcp.WithNumber("skip", + mcp.Description("Number of deployments to skip, for paging. Defaults to 0."), + ), + mcp.WithNumber("limit", + mcp.Description(fmt.Sprintf("Maximum deployments to return. Defaults to %d, capped at %d.", defaultListLimit, maxListLimit)), + ), + ) +} + +// HandleListDeployments returns the handler for console_list_deployments. +func HandleListDeployments(cl *console.Client) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + skip := 0 + if v, ok := marshal.OptionalUint64(req, "skip"); ok { + skip = int(v) + } + + limit := defaultListLimit + if v, ok := marshal.OptionalUint64(req, "limit"); ok && v > 0 { + limit = int(v) + } + // An assistant asking for everything should not be able to turn one + // tool call into an unbounded page request. + if limit > maxListLimit { + limit = maxListLimit + } + + resp, err := cl.ListDeployments(ctx, skip, limit) + if err != nil { + return marshal.ErrResultf("failed to list deployments: %v", err), nil + } + + return marshal.ToTextResult(resp) + } +} + +// ToolGetDeployment returns the tool definition for fetching one deployment. +func ToolGetDeployment() mcp.Tool { + return mcp.NewTool( + "console_get_deployment", + mcp.WithDescription("Get a Console-managed deployment by its deployment sequence number (dseq), including its leases."), + mcp.WithString("dseq", + mcp.Required(), + mcp.Description("Deployment sequence number, e.g. 1784752234037."), + ), + ) +} + +// HandleGetDeployment returns the handler for console_get_deployment. +func HandleGetDeployment(cl *console.Client) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + dseq, err := marshal.RequireString(req, "dseq") + if err != nil { + return marshal.ErrResult(err.Error()), nil + } + + resp, err := cl.GetDeployment(ctx, dseq) + if err != nil { + return marshal.ErrResultf("failed to get deployment %s: %v", dseq, err), nil + } + + return marshal.ToTextResult(resp) + } +} + +// ToolListBids returns the tool definition for listing bids on a deployment. +func ToolListBids() mcp.Tool { + return mcp.NewTool( + "console_list_bids", + mcp.WithDescription("List the provider bids received for a Console-managed deployment. Bids may take a few seconds to arrive after a deployment is created."), + mcp.WithString("dseq", + mcp.Required(), + mcp.Description("Deployment sequence number to fetch bids for."), + ), + ) +} + +// HandleListBids returns the handler for console_list_bids. +func HandleListBids(cl *console.Client) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + dseq, err := marshal.RequireString(req, "dseq") + if err != nil { + return marshal.ErrResult(err.Error()), nil + } + + bids, err := cl.FetchBids(ctx, dseq) + if err != nil { + return marshal.ErrResultf("failed to fetch bids for %s: %v", dseq, err), nil + } + + return marshal.ToTextResult(bids) + } +} + +// ToolWalletBalance returns the tool definition for the managed wallet balance. +func ToolWalletBalance() mcp.Tool { + return mcp.NewTool( + "console_wallet_balance", + mcp.WithDescription("Get the balance of the Console-managed wallet: available credits, total, and any amount held in escrow by open deployments."), + ) +} + +// HandleWalletBalance returns the handler for console_wallet_balance. +func HandleWalletBalance(cl *console.Client) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resp, err := cl.GetBalances(ctx) + if err != nil { + return marshal.ErrResultf("failed to get wallet balance: %v", err), nil + } + + return marshal.ToTextResult(resp) + } +} + +// ToolUsageHistory returns the tool definition for spend history. +func ToolUsageHistory() mcp.Tool { + return mcp.NewTool( + "console_usage_history", + mcp.WithDescription("Get spend history for the Console account. Both dates are optional; omit them for the account's full history."), + mcp.WithString("start_date", + mcp.Description("Start of the range as YYYY-MM-DD. Omit for no lower bound."), + ), + mcp.WithString("end_date", + mcp.Description("End of the range as YYYY-MM-DD. Omit for no upper bound."), + ), + ) +} + +// HandleUsageHistory returns the handler for console_usage_history. +func HandleUsageHistory(cl *console.Client) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // The address is resolved server-side from the API key, so it is not + // exposed as a tool argument. + resp, err := cl.GetUsageHistory(ctx, "", + marshal.OptionalString(req, "start_date"), + marshal.OptionalString(req, "end_date"), + ) + if err != nil { + return marshal.ErrResultf("failed to get usage history: %v", err), nil + } + + return marshal.ToTextResult(resp) + } +} + +// ToolListProviders returns the tool definition for listing providers. +func ToolListProviders() mcp.Tool { + return mcp.NewTool( + "console_list_providers", + mcp.WithDescription("List providers known to Console, with their capacity and pricing."), + mcp.WithString("scope", + mcp.Description("Optional listing scope, e.g. 'active'. Omit for the default set."), + ), + ) +} + +// HandleListProviders returns the handler for console_list_providers. +func HandleListProviders(cl *console.Client) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resp, err := cl.ListProviders(ctx, marshal.OptionalString(req, "scope"), nil) + if err != nil { + return marshal.ErrResultf("failed to list providers: %v", err), nil + } + + return marshal.ToTextResult(resp) + } +} + +// ToolGetProvider returns the tool definition for one provider. +func ToolGetProvider() mcp.Tool { + return mcp.NewTool( + "console_get_provider", + mcp.WithDescription("Get a single provider's detail from Console by its bech32 address."), + mcp.WithString("address", + mcp.Required(), + mcp.Description("Provider bech32 address, e.g. akash1..."), + ), + ) +} + +// HandleGetProvider returns the handler for console_get_provider. +func HandleGetProvider(cl *console.Client) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + addr, err := marshal.RequireString(req, "address") + if err != nil { + return marshal.ErrResult(err.Error()), nil + } + + resp, err := cl.GetProvider(ctx, addr) + if err != nil { + return marshal.ErrResultf("failed to get provider %s: %v", addr, err), nil + } + + return marshal.ToTextResult(resp) + } +} + +// ToolGPUPrices returns the tool definition for GPU availability and pricing. +func ToolGPUPrices() mcp.Tool { + return mcp.NewTool( + "console_gpu_prices", + mcp.WithDescription("Get GPU model availability and pricing across the network, for sizing a deployment before creating it."), + ) +} + +// HandleGPUPrices returns the handler for console_gpu_prices. +func HandleGPUPrices(cl *console.Client) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resp, err := cl.GetGPUPrices(ctx) + if err != nil { + return marshal.ErrResultf("failed to get GPU prices: %v", err), nil + } + + return marshal.ToTextResult(resp) + } +} + +// ToolCloseDeployment returns the tool definition for closing a deployment. +func ToolCloseDeployment() mcp.Tool { + return mcp.NewTool( + "console_close_deployment", + mcp.WithDescription("Close a Console-managed deployment. This terminates its leases and stops the workload; remaining escrow is returned to the wallet. This cannot be undone."), + mcp.WithString("dseq", + mcp.Required(), + mcp.Description("Deployment sequence number to close."), + ), + ) +} + +// HandleCloseDeployment returns the handler for console_close_deployment. +func HandleCloseDeployment(cl *console.Client) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + dseq, err := marshal.RequireString(req, "dseq") + if err != nil { + return marshal.ErrResult(err.Error()), nil + } + + if err := cl.CloseDeployment(ctx, dseq); err != nil { + return marshal.ErrResultf("failed to close deployment %s: %v", dseq, err), nil + } + + return marshal.ToTextResult(map[string]string{ + "dseq": dseq, + "status": "closed", + }) + } +} + +// ToolDeposit returns the tool definition for topping up a deployment. +func ToolDeposit() mcp.Tool { + return mcp.NewTool( + "console_deposit", + mcp.WithDescription("Add funds to a Console-managed deployment's escrow, extending how long it runs. Spends real credits from the managed wallet."), + mcp.WithString("dseq", + mcp.Required(), + mcp.Description("Deployment sequence number to deposit into."), + ), + mcp.WithNumber("amount_usd", + mcp.Required(), + mcp.Description(fmt.Sprintf("Amount to deposit in USD. Must be at least %.2f.", console.MinDepositUSD)), + ), + ) +} + +// HandleDeposit returns the handler for console_deposit. +func HandleDeposit(cl *console.Client) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + dseq, err := marshal.RequireString(req, "dseq") + if err != nil { + return marshal.ErrResult(err.Error()), nil + } + + amount, ok := marshal.OptionalFloat(req, "amount_usd") + if !ok { + return marshal.ErrResult("amount_usd is required"), nil + } + // Checked here as well as server-side so a wrong amount costs a tool + // call rather than a failed API request against a funded account. + if amount < console.MinDepositUSD { + return marshal.ErrResultf("amount_usd must be at least %.2f, got %.2f", console.MinDepositUSD, amount), nil + } + + if err := cl.Deposit(ctx, dseq, amount); err != nil { + return marshal.ErrResultf("failed to deposit into %s: %v", dseq, err), nil + } + + return marshal.ToTextResult(map[string]any{ + "dseq": dseq, + "amount_usd": amount, + "status": "deposited", + "description": "escrow topped up", + }) + } +}