Trading with Claude (and writing your own MCP server)
Ever wanted to check on your portfolio and trade some stocks directly with Claude? Well you’re in luck.
Claude and MCP
In November 2024, Anthropic open-sourced MCP (Model-Context Protocol) to standardize the way AI assistants interact with other tools. This standardization allows AI assistants to seamlessly integrate with various tools and platforms, enhancing their capabilities and usability. Since then, it’s been growing in popularity and adoption, slowly becoming a key component in the development of AI-powered tools and applications.
While the original version had some support for remote MCP servers, it was somewhat complicated to implement and did not see widespread adoption. So, as a natural evolution to the original MCP, they released the 2025-03-26 version with support for OAuth 2.1 authorization and replaced the HTTP+SSE transport with “Streamable HTTP” transport.
Perhaps most importantly, all of this only worked in Claude Desktop - till now. A few days ago, they added “Integrations” to Claude, allowing everyone to use MCP tools directly from the web chat interface.
Unfortunately, they labeled it as beta and only made it available on their more expensive Max, Team, and Enterprise plans, launching with a limited number of partners.
So for now, we will stick to exploring the possibilities of MCP with Claude Desktop.
What is an MCP server anyway?
An MCP server is just a server, which can be hosted remotely or a local binary (or a local command) that exposes a list of functions, resources and other information to an LLM.
It exposes this via a standardized mechanism (see above), and then LLMs such as Claude can choose when to call it and ask to use a tool (much like a function call).
SnapTrade
Which brings me to my next point: I work at SnapTrade (and a mandatory disclaimer: I’m writing this in a personal capacity, not as an employee of SnapTrade) - which allows anyone with some technical knowledge to integrate with a vast array of financial platforms and brokerages using a simple, unified API.
So what better way to explore MCP than to build a financial trading bot using SnapTrade’s API and MCP?
Building the MCP server
When I first looked into doing this, I thought why not use Claude to write the server? The same company (Anthropic) owns both Claude and drove the development of MCP. Surely they’ve made it really easy to use Claude to write the server. Right? Turns out, I had much better luck using Gemini than Claude. I was able to feed Gemini all of the prompts and docs that MCP’s own “Building with LLMs” documentation suggested and it wrote a working version on the first try, while Claude struggled with context limits and a number of other issues.
The generated code was still more than what I wanted to maintain, and as others have noted it can be quite frustrating to implement it from scratch.
Even so, none of that turned out to really be necessary. I was able to iterate and work on the core product much faster by using the go-mcp framework for Go.
Requirements
- Go - I quite enjoy working with Go, it’s a great language and it makes it really easy to build single binaries that are incredibly easy to point to from Claude Desktop.
- go-mcp - I evaluated a couple of alternatives, but go-mcp was the most straightforward and easy to use.
- SnapTrade Go SDK - the SnapTrade Go SDK.
- markdown-table-formatter - A tool for formatting Markdown tables. Useful when presenting some structured data such as positions, orders, and other financial data.
- cli - A simple command-line interface library for Go. Only used to make it easy to build a helper companion CLI app to iterate faster during development, not necessary nor used in the MCP server itself.
- a SnapTrade client ID and secret - Sign up through this link to get a free test key which gives you a limited number of connections for free, plus access to paper trading.
Directory structure
Sometimes I find it helpful to see a general overview of the directory structure before I begin working on a project. Here’s roughly what we will be building. We have bin
holding our built binary, cmd
holding the “commands” (cli apps) we want to build (just 1 for now), and internal
holding a number of packages that are internal to our server.
The most important subdirectory is tools
, which holds code for each of the tools we want to make available to Claude via our MCP server.
.
├── bin
│ ├── cli
│ ├── .env
│ └── .env.example
├── cmd
│ ├── cli
│ │ └── main.go
│ └── manage
│ └── main.go
├── .gitignore
├── go.mod
├── go.sum
├── internal
│ ├── snaptradeclient
│ │ └── snaptrade.go
│ └── tools
│ ├── connect
│ │ └── connect.go
│ ├── help
│ │ └── help.go
│ ├── orders
│ │ └── orders.go
│ ├── portfolio
│ │ └── portfolio.go
│ └── trades
│ └── trades.go
└── Makefile
Help! I want to trade with Claude (and SnapTrade)
Let’s start with our first tool. This will define the pattern or structure we will reuse for each of our tools. This file should live under internal/tools/help/help.go
.
package help
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"snaptrade.com/mcp-server/internal/snaptradeclient"
)
// This just defines a new tool using the MCP framework. It's important to have
// clear and helpful tool name and descriptions, as that's what Claude uses
// to determine when and whether to run your tool.
var Tool = mcp.NewTool("get_started_with_brokerage_connection",
mcp.WithDescription("Provides information on how to connect your brokerage account and lists the supported brokerages."),
)
// Each tool has a handler that returns a function matching the `go-mcp`
// tool handler function signature. We use this to make it easy to pass
// an instance of `snaptradeclient.SnapTradeClient` which holds all the
// code interacting with SnapTrade via the SnapTrade Go SDK
func Handler(cl *snaptradeclient.SnapTradeClient) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Note: This specific handler doesn't currently *use* the client,
// but we adapt the signature for consistency and future use.
return mcp.NewToolResultText("To get started with investing and portfolio management, please let us know which brokerage you have an account with. We can help you connect your account to any of the following brokerages: Trading212, Vanguard, Schwab, Alpaca, Alpaca Paper, Tradier, Robinhood, Fidelity, ETrade."), nil
}
}
Easy right? There isn’t much more to it. When you ask for information about brokerages or to check your portfolio value, Claude will now helpfully reply with the information we’ve gathered here. You can see what snaptradeclient.SnapTradeClient
is and what it does here on GitHub, I’ve tried to leave plenty of comments to help you navigate the codebase.
Connect
Naturally, next up is actually connecting to the brokerage of your choice. We again expose a tool to Claude via go-mcp
along with a list of accepted strings as a sort of an enum. We’ll then reply with a link to SnapTrade’s connection portal, which allows you to easily and securely connect to the brokerage of your choice. I recommend Alpaca Paper for this demo due to its great paper trading capabilities and easy OAuth connection flow.
package connect
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"snaptrade.com/mcp-server/internal/snaptradeclient"
)
var Tool = mcp.NewTool("connect_brokerage",
mcp.WithDescription("Connect your brokerage account to see your portfolio and trades for that account."),
mcp.WithString("brokerage",
mcp.Required(),
mcp.Description("The brokerage to connect to"),
mcp.Enum("Trading212", "Vanguard", "Schwab", "Alpaca", "Alpaca Paper", "Tradier", "Robinhood", "Fidelity", "ETrade"),
),
)
func Handler(cl *snaptradeclient.SnapTradeClient) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
brokerage, ok := request.Params.Arguments["brokerage"].(string)
if !ok {
return mcp.NewToolResultError("Invalid brokerage"), nil
}
// The SnapTrade API takes these slugs to pre-select the brokerage to connect to, so we need to map the user's choice to the correct slug.
nameToSlug := map[string]string{
"Trading212": "TRADING212",
"Vanguard": "VANGUARD",
"Schwab": "SCHWAB",
"Alpaca": "ALPACA",
"Alpaca Paper": "ALPACA-PAPER",
"Tradier": "TRADIER",
"Robinhood": "ROBINHOOD",
"Fidelity": "FIDELITY",
"ETrade": "ETRADE",
}
slug, ok := nameToSlug[brokerage]
if !ok {
return mcp.NewToolResultError("Invalid brokerage"), nil
}
// This calls our custom SnapTrade client and grabs a URL to present to
// the user. They will then need to click it to connect.
redirectURI, err := cl.LoginUserAndGetRedirectURI(slug)
if err != nil {
return mcp.NewToolResultError("Failed to generate connection link for SnapTrade"), nil
}
return mcp.NewToolResultText(fmt.Sprintf("To connect to %s, you must present this link to the user. Please note that it expires, so even if you've shown it before, you need to show this new one:\n %s", brokerage, redirectURI)), nil
}
}
This is where user action is required and you will need to click the link and follow the instructions in order to connect your Alpaca Paper account. Once you’ve connected, go back to Claude to continue the conversation.
Checking your portfolio
This code might look a bit more complicated on first glance, but in reality:
- We’re pulling all of the accounts you’ve connected
- Grouping them by institution
- Providing a table with a list of positions held
package portfolio
import (
"context"
"fmt"
"github.com/fbiville/markdown-table-formatter/pkg/markdown"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
st "github.com/passiv/snaptrade-sdks/sdks/go"
"snaptrade.com/mcp-server/internal/snaptradeclient"
)
var Tool = mcp.NewTool("portfolio",
mcp.WithDescription("Check your portfolio and brokerage accounts for their positions and values.")
)
func Handler(cl *snaptradeclient.SnapTradeClient) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Use the client method
response, err := cl.ListUserAccounts()
if err != nil {
fmt.Println("Error retrieving accounts:", err)
return mcp.NewToolResultError("Error retrieving accounts"), nil
}
if len(response) == 0 {
fmt.Println("No brokerage accounts connected")
return mcp.NewToolResultError("No brokerage accounts connected"), nil
}
reply := "Your connected brokerage accounts are:\n"
// Group the accounts by institution
accounts := make(map[string][]st.Account)
for _, account := range response {
institution := account.GetInstitutionName()
if _, ok := accounts[institution]; !ok {
accounts[institution] = []st.Account{}
}
accounts[institution] = append(accounts[institution], account)
}
// Find all accounts
for institution, accountsList := range accounts {
reply += fmt.Sprintf("%s:\n", institution)
for _, account := range accountsList {
marketValueDescription := ""
if account.Balance.GetTotal().Amount != nil && account.Balance.GetTotal().Currency != nil {
marketValue := *account.Balance.GetTotal().Amount
marketValueCurrency := *account.Balance.GetTotal().Currency
marketValueDescription = fmt.Sprintf("(value including cash: %.2f %s)", marketValue, marketValueCurrency)
}
reply += fmt.Sprintf("%s %s\n\nPlease show this markdown formatted table of all the positions under this account\n\n", *account.Name.Get(), marketValueDescription)
// Now get all the positions for this account using the client method
positions, err := cl.GetUserAccountPositions(account.Id)
if err != nil {
fmt.Println("Error retrieving positions for account", account.Name, ":", err)
continue // Or add an error message to the reply
}
if len(positions) == 0 {
reply += fmt.Sprintf(" - No positions found for account %s\n", *account.Name.Get())
continue
} else {
tableData := make([][]string, len(positions))
for i, position := range positions {
tableData[i] = []string{
position.Symbol.Symbol.Symbol,
fmt.Sprintf("%.4f", *position.Units.Get()),
fmt.Sprintf("%.2f %s", *position.Price.Get()**position.Units.Get(), *position.Symbol.Symbol.Currency.Code),
}
}
basicTable, err := markdown.NewTableFormatterBuilder().
Build("Instrument", "Units", "Value").
Format(tableData)
if err != nil {
fmt.Println("Error creating table:", err)
continue // Or add an error message to the reply
}
// Add the table to the reply
reply += fmt.Sprintf("%s\n", basicTable)
}
}
}
fmt.Println(reply)
return mcp.NewToolResultText(reply), nil
}
}
Making a trade
Now we get to the part that’s most exciting. I intentionally kept the list trading options and configuration simple in the MCP server, but you could easily extend it by looking at the relevant docs for placing a trade on SnapTrade.
Apart from doing some basic validation, we shoot over all the data we got directly to SnapTrade via the SDK and let it take care of it.
package trades
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"snaptrade.com/mcp-server/internal/snaptradeclient" // Use snaptradeclient instead of snaptrade
)
var Tool = mcp.NewTool("place_order",
mcp.WithDescription("Place an order with your brokerage account"),
mcp.WithString("brokerage",
mcp.Required(),
mcp.Description("The brokerage to place an order with"),
mcp.Enum("Trading212", "Vanguard", "Schwab", "Alpaca", "Alpaca Paper", "Tradier", "Robinhood", "Fidelity", "ETrade"),
),
mcp.WithString("action",
mcp.Required(),
mcp.Description("The action to perform (BUY/SELL)"),
mcp.Enum("BUY", "SELL"),
),
mcp.WithString("ticker",
mcp.Required(),
mcp.Description("The ticker symbol of the stock"),
),
mcp.WithNumber("quantity",
mcp.Required(),
mcp.Description("The quantity of shares to buy/sell"),
),
)
func Handler(cl *snaptradeclient.SnapTradeClient) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
brokerage, ok := request.Params.Arguments["brokerage"].(string)
if !ok {
return mcp.NewToolResultError("Invalid brokerage"), nil
}
action, ok := request.Params.Arguments["action"].(string)
if !ok {
return mcp.NewToolResultError("Invalid action"), nil
}
ticker, ok := request.Params.Arguments["ticker"].(string)
if !ok {
return mcp.NewToolResultError("Invalid ticker"), nil
}
quantity, ok := request.Params.Arguments["quantity"].(float64)
if !ok {
return mcp.NewToolResultError("Invalid quantity"), nil
}
quantityFloat := float32(quantity)
// Use the client method
response, err := cl.ListUserAccounts()
if err != nil {
fmt.Println("Error retrieving accounts:", err)
return mcp.NewToolResultError("Error retrieving accounts"), nil // Return error to MCP
}
if len(response) == 0 {
return mcp.NewToolResultError("No brokerage accounts connected"), nil
}
accountId := ""
for _, account := range response {
if account.GetInstitutionName() == brokerage {
accountId = account.Id
break // Found the account
}
}
if accountId == "" {
return mcp.NewToolResultError(fmt.Sprintf("No matching account found for brokerage: %s", brokerage)), nil
}
// Use the client method
orderRecord, err := cl.PlaceForceOrder(accountId, action, ticker, quantityFloat)
if err != nil {
fmt.Println("Error placing order:", err)
return mcp.NewToolResultError(fmt.Sprintf("Error placing order: %s", err.Error())), nil // Return error to MCP
}
return mcp.NewToolResultText(fmt.Sprintf("Order placed successfully: %s. You can monitor the status of your order by asking me to show your recent orders.", *orderRecord.BrokerageOrderId)), nil
}
}
As shown at the beginning of this blog post, here’s what that might look like.
Caveats and downsides
Be careful when placing trades or performing any sort of high-impact actions through an LLM like Claude. While this worked well on most attempts, there was a couple of times during testing where it failed to place a trade, and then it kept trying to place ever-higher quantity trades - eg: instead of 1 share, it tried to buy 2, then 3 - on it’s own. So you could potentially end up placing orders that you didn’t intend to.
Similarly, due to the unpredictable nature of LLMs, I couldn’t get a 100% hit rate when trying to get the connect a brokerage link to show - it kept saying it already showed it instead of just displaying the new one. So there’s definitely some bugs and kinks to work out if you do decide to connect an LLM to a tool like this.
What next?
The sample MCP server also has a tool to check recent orders, but I won’t bore you with the code in this very blog post - you can head over to the snaptrade-mcp GitHub repo and check it all out for yourself.
MCP is a powerful tool that can be used to build a wide range of applications. It is still in its early stages, but it has the potential to revolutionize the way we build applications and offer serviecs - as long as we’re aware of it’s limitations.