Vancouver - easily add MCP tools to your phoenix/bandit server

Vancouver makes it easy to add Model Context Protocol (MCP) functionality to your Phoenix/Bandit server. Vancouver handles initialization, request validation, and offers helper functions to simplify the creation of MCP tools and prompts.

The goal is to let you create MCP servers with working tools/prompts in minutes, without needing to know much about the MCP protocol.

How it works

You create tools like this:

defmodule MyApp.Tools.CalculateSum do
  use Vancouver.Tool

  def name, do: "calculate_sum"
  def description, do: "Add two numbers together"

  def input_schema do
    %{
      "type" => "object",
      "properties" => %{
        "a" => %{"type" => "number"},
        "b" => %{"type" => "number"}
      },
      "required" => ["a", "b"]
    }
  end

  def run(conn, %{"a" => a, "b" => b}) do
    send_text(conn, "#{a + b}")
  end
end

create prompts like this:

defmodule MyApp.Prompts.CodeReview do
  use Vancouver.Prompt

  def name, do: "code_review"
  def description, do: "Asks the LLM to analyze code quality and suggest improvements"

  def arguments do
    [
      %{
        "name" => "code",
        "description" => "The code to review",
        "required" => true
      }
    ]
  end

  def run(conn, %{"code" => code}) do
    send_text(conn, "Please review this code: #{code}")
  end
end

and configure via your (phoenix) router like this:

forward "/mcp", Vancouver.Router, 
  tools: [MyApp.Tools.CalculateSum],
  prompts: [MyApp.Prompts.CodeReview]

To use your tools locally with e.g. Claude Desktop, you can update your claude_desktop_config.json file (see below), run your server, and refresh Claude Desktop.

{
    "mcpServers": {
        "MyApp": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "http://localhost:4000/mcp"
            ]
        }
    }
}

More info

14 Likes

We’ve been looking for a generic MCP solution to sit Ash AI on top of that can act as a shared base instead of rolling our own which is what we’ve done so far. I’ll be evaluating this for that purpose.

4 Likes

Very nice! I’ve also started GitHub - mtrudel/excom: EXCOM is an MCP server for Elixir and it looks like our config ergonomics are more or less identical. TBH, EXCOM came out of a company hack week and I don’t really have the bandwidth to keep carrying it along, happy to see some other projects emerging to fill that need!

4 Likes

Great! Vancouver is still super new, so things will change a lot over the next couple of weeks. If you have any feedback let me know, and I should be able to act on it fairly quickly. :slightly_smiling_face:

1 Like

Awesome. I’ll take a look at EXCOM for inspiration. :heart:

1 Like

@tyr0 one major suggestion, something I’d ideally need to make AshAI use this, is to make it like plug which accepts opts, and all functions accept those options as their first argument, post initialization.

In practice, it looks like this:

defmodule MyApp.Tools.CalculateSum do
  use Vancouver.Tool

  # imagine (for whatever reason) you wanted a "fast" and a "slow" version of `sum`
  def init(opts) do
    # validate opts
    {:ok, %{speed: opts[:speed]}}
  end

  def name(%{speed: :slow}), do: "calculate_sum"
  def name(%{speed: :fast}), do: "calculate_sum_quickly"

  def description(opts) do
    description = "Add two numbers together"
    if opts.speed == :fast do
      """
      #{description}

      This costs extra money, only use when you need to 
      add many numbers together quickly.
      """
    else 
      description
    end
  end

  def input_schema(_) do
    %{
      "type" => "object",
      "properties" => %{
        "a" => %{"type" => "number"},
        "b" => %{"type" => "number"}
      },
      "required" => ["a", "b"]
    }
  end

  def run(conn, opts, %{"a" => a, "b" => b}) do
    if opts.speed == :fast do
      send_text(conn, "#{sum_fast(a, b)}")
    else
      send_text(conn, "#{sum_slow(a, b)}")
    end
  end
end

and then in the router:

forward "/mcp", Vancouver.Router, tools: [
  {MyApp.Tools.CalculateSum, speed: :fast}, 
  {MyApp.Tools.CalculationSum, speed: :slow}
]

And finally, add a concept of extensions that can dynamically add tools.

defmodule SomeExtensions do
  use Vancouver.Extension

  def add_tools(opts) do
     [...new_tools]
   end
end

which would then look like this:

forward "/mcp", Vancouver.Router, tools: [
  {MyApp.Tools.CalculateSum, speed: :fast}, 
  {MyApp.Tools.CalculationSum, speed: :slow}
], extensions: [{AshAi.Mcp, otp_app: :otp_app}]

This allows layers to be built dynamically on top of Vancouver. If that happens then I’ll be able to consider using it as a basis for AshAI’s MCP tooling (which is not something I’m saying you should care about :laughing:, just letting you know what would make it work for my use case).

1 Like

Aha. I was planning to make Tools (and Prompts) into plugs, so that they’d have an (overridable) init, and support pipelines etc. I think that should work for your use case too.

Will have a think about extension support - that’s not something I’d considered, but sounds interesting. :slightly_smiling_face:

2 Likes

I’m a huge fan of this API design–simple but extensible. My vote goes for something like this!

One thing that might be helpful is to allow some way to manage session state, more akin to a phoenix channel.

For example, I am prototyping an MCP server for Livebook, and upon connecting the AI agent will register as a collaborator in the notebook. On disconnect, it will leave. This means some MCP servers may need to be stateful.

I don’t know if this is within the scope but wanted to share that this comes up pretty often.

3 Likes

Love it! Very nice and simple API for defining tools. I know this is limited to tools right now, but do you plan to support prompts or resources?

I’m working on a framework that provides a controller-like DSL that can respond synchronously and asynchronously: GitHub - dbernheisel/phantom_mcp: MCP server implemented in Elixir

I would love to collaborate. I’m in the process of using Phantom in anger before I publicize it.

2 Likes

Nice! I’m working on my own MCP library but haven’t wanted to announce it yet since I’m unsure of how I want to design the API for defining tools. GitHub - azmaveth/ex_mcp: Model Context Protocol client/server library for Elixir if you want to take a look. I would love to join forces instead of having competing libraries.

@dbern I really like the way Phantom defines tools/resources/prompts too. I think it’s the cleanest DSL I’ve seen so far.

2 Likes

Yes. :+1: I’m currently adding prompt support, and will also add support for resources soon.

I’m working on a framework that provides a controller-like DSL that can respond synchronously and asynchronously: GitHub - dbernheisel/phantom_mcp: MCP server implemented in Elixir

Nice! I’m working on my own MCP library but haven’t wanted to announce it yet since I’m unsure of how I want to design the API for defining tools. GitHub - azmaveth/ex_mcp: Model Context Protocol client/server library for Elixir if you want to take a look. I would love to join forces instead of having competing libraries.

So many libraries! Awesome, I’ll take a look at both. :slightly_smiling_face: Re collaboration, I’m just working solo right now to get the basic functionality in, and the code is changing quite a lot every day. However, let’s regroup once the libraries are a bit more stable, and once we have a bit more feedback on which designs are working best.

2 Likes

v0.3.0 is released :tada:

The big addition is support for prompts. You can create prompts like this:

defmodule MyApp.Prompts.CodeReview do
  use Vancouver.Prompt

  def name, do: "code_review"
  def description, do: "Asks the LLM to analyze code quality and suggest improvements"

  def arguments do
    [
      %{
        "name" => "code",
        "description" => "The code to review",
        "required" => true
      }
    ]
  end

  def run(conn, %{"code" => code}) do
    send_text(conn, "Please review this code: #{code}")
  end
end

and update your router like this:

# router.ex
  forward "/mcp", Vancouver.Router,
    tools: [MyApp.Tools.CalculateSum],
    prompts: [MyApp.Prompts.CodeReview] # new option in v0.3.0

For more info on all the changes/improvements, see the CHANGELOG.

What’s next?

I’m keen to convert tools/prompts into full plugs, including support for options as suggested by @zachdaniel. I’m also looking into adding support for resources.

If you have any requests/feedback, let me know. :slightly_smiling_face:

2 Likes

I see the example MCP servers in the README, but it would be good to have an example of what you would type into Claude Desktop to use them.

For example, I have a Blender MCP and a SketchUp MCP configured and to run either I just write, “Build me a kitchen cabinet in Blender”.

But how do you tell Claude Desktop to use your CalculateSum MCP rather than just add two numbers using another way. And do you need to use the same named “a” and “b” properties?

It does sound super exciting to be able to get Claude Desktop to query the business logic of a running Phoenix server in development. Presumably “mcp-remote” permits this on a production server.

Given some recent security scares over MCP servers, including the GitHub one, that probably needs to be addressed in the README. As well as rate limiting and authentication/authorization concerns.

But thanks for this. Exciting times!

1 Like

how do you tell Claude Desktop to use your CalculateSum MCP rather than just add two numbers using another way.

Good question! The example tool/prompt in the README are copied directly from the MCP spec examples, however I agree that they’re not that realistic.

Getting an LLM to use any tool can be a bit hit or miss in general, however, Claude is fairly good at detecting intent. E.g. I just tried the CalculateSum example and it did use the tool (without any setup prompts):

With more specific tools, the LLM is even more likely to determine your intent and call the appropriate tool.

And do you need to use the same named “a” and “b” properties?

The MCP spec requires that you define an input schema for each tool - as long as the schema is a valid JSON schema, things should work, and you have a lot of flexibility in how you define the allowed arguments.

1 Like

with a little help from claude/tidewave I added vancouver to my FaultyTower error tracking.

It now allows me to query for errors, call in claude’s aid for fixing and resolving them.

Thanks!

2 Likes

Amazing! If you have any feedback, or if anything was confusing, let me know. :slightly_smiling_face:

No, it was very straightforward.

The only thing that bothers me, is claude code calling 2 urls I don’t know how to handle.

[info] GET /.well-known/oauth-authorization-server
[debug] ** (Phoenix.Router.NoRouteError) no route found for GET /.well-known/oauth-authorization-server (FaultyTowerWeb.Router)

and

[info] POST /register
[debug] ** (Phoenix.Router.NoRouteError) no route found for POST /register (FaultyTowerWeb.Router)
1 Like

This is due to the client trying to start an OAuth flow. See here for more info.

Authorization is currently out of scope for Vancouver, and the MCP spec/support in this area is changing quite quickly. However, it’s something I’m thinking about, as it’s probably the most difficult part of creating a remote MCP server at the moment. :face_exhaling:

1 Like