We’ve been working with MCP servers for a while, and this use case was a perfect opportunity to build out another one.
What is an MCP Server?
A very simple way to put it is that Model Context Protocol is an "API" that your AI tooling can use to get external data or perform actions by interacting with your application. If it's just an API, that seems very easy to implement. Let's think about our use case then.
The Use Case
The project is the TIL https://til.hashrocket.com/ website where we developers usually write about our own learning experiences throughout small TIL posts. So our idea with the MCP server was to provide a way to simply create a TIL post from inside our AI tooling, and then maybe go to the TIL site and refine that idea.
These days we spend a lot of time inside our AI tools asking the most variety of questions, and we end up learning something from those interactions. Eventually, if we learn from an AI chat interaction, we'd like to just grab that content and maybe scaffold it into a new TIL post.
This was the starting point of the project, and with that we started to take a look into libraries to achieve that. We found out that there were 2 libraries that were both forks of each other:
We played around a bit with Hermes but we ended up using Anubis in the end. I have to say it was a bit of a bumpy road. The documentation for both was not the best - we had some situations where the documentation was outdated or just simply not working - so follow our steps here if you want to setup an MCP server yourself.
MCP Server
The first component to write is an MCP server, which is very simple. For now it's just:
defmodule Tilex.MCP.Server do
use Anubis.Server, name: "TIL", version: "1.0.0", capabilities: [:tools]
component(Tilex.MCP.NewPost)
end
MCP Tools
So the first tool we made was to create a TIL Post:
defmodule Tilex.MCP.NewPost do
@moduledoc """
Create a new TIL ("Today I Learned") post.
TIL is a place for sharing something you've learned today with others.
"""
use Anubis.Server.Component, type: :tool
schema do
field :title, :string,
required: true,
description: "Max 50 chars."
field :body, :string,
required: true,
description: "Max 200 words in a Markdown format."
field :channel, :string,
required: true,
description: "Post channel."
end
@impl true
def execute(%{title: title, body: body, channel: channel}, frame) do
...
end
end
The first bit of learning from here is that we need to be very generous in the description of what the tool does. In this case, we do that by adding a proper @moduledoc, and also by describing each field that we are accepting - which type they are, what validation applies, and more description. The idea with all these description fields is to give more information so if your AI tool decides to call this tool, it can break down those args properly.
With that in place, I expect to say something like:
write me a TIL about the new React 19.2 Activity component
and I want my AI tooling to find out that I mean to use our new MCP server tool to perform that operation. And after that, I want that same AI tooling to break down the information into the proper schema fields that we are expecting as our input for that tool. So far it seems a bit like magic, so let's keep going.
Providing Data
So title and body I guess the AI could infer from my prompt or from the chat history. But the channel I think should be inferred from that too. The only issue is that we have a finite set of channels we could pick from. So can we expose which channels AI could use in order to create a new TIL post? The response was to provide a new tool for that. In this case, as we are just providing data to the AI, we decided to use a resource instead of a tool per se. So we changed our Server to:
defmodule Tilex.MCP.Server do
use Anubis.Server, name: "TIL", version: "1.0.0", capabilities: [:resources, :tools]
component(Tilex.MCP.ListChannels)
component(Tilex.MCP.NewPost)
end
And we created our new resource as:
defmodule Tilex.MCP.ListChannels do
@moduledoc """
List channels of TIL posts.
Channels are used to group posts by the same topic.
"""
use Anubis.Server.Component, type: :resource, uri: "til:///channels", name: "list_channels", mime_type: "application/json"
alias Anubis.Server.Response
@impl true
def read(_input, frame) do
channels = list_channels()
resp = Response.json(Response.resource(), channels)
{:reply, resp, frame}
end
defp list_channels() do
...
end
end
The implementation of the list_channels function is just an Ecto Repo query, nothing more than that. The trick here is to tell the AI how to relate that resource with the input to be used in the other tool. This way we changed our schema definition in the NewPost to:
defmodule Tilex.MCP.NewPost do
@moduledoc """
Create a new TIL ("Today I Learned") post.
TIL is a place for sharing something you've learned today with others.
"""
use Anubis.Server.Component, type: :tool
schema do
field :title, :string,
required: true,
description: "Max 50 chars."
field :body, :string,
required: true,
description: "Max 200 words in a Markdown format."
field :channel, :string,
required: true,
description: "Channel is given by the list_channels MCP resource from this same server."
end
@impl true
def execute(%{title: title, body: body, channel: channel}, frame) do
...
end
end
As you can see, this is pretty much what we had before, but we are telling the AI to look for channels in the list_channels MCP resource. That's it, that simple.
Authenticating into Your MCP Server
This was the topic we had the most difficulty with (if any...) in this whole process, and this is because the docs were not great - at least they were not at the time we were doing this research. We followed the Authentication Recipe from the library we are using, and that did not work well.
The main issue we had was that the Server instance was shared across all users. This way, if 2 users connect to their own AI tools configured with that MCP at the same time, then all posts coming from those users would be assigned to one of these users. The last one to connects takes the credits. That's not good. So we realized that we should not assign the authenticated user in the Server module, but get the current user from the authentication header directly in the tools/resources executions. So now we have:
defmodule Tilex.MCP.NewPost do
@moduledoc """
Create a new TIL ("Today I Learned") post.
TIL is a place for sharing something you've learned today with others.
"""
use Anubis.Server.Component, type: :tool
import Ecto.Query, only: [from: 2]
alias Anubis.Server.Response
alias Tilex.Blog.User
alias Tilex.Blog.Post
schema do
field :title, :string,
required: true,
description: "Max 50 chars."
field :body, :string,
required: true,
description: "Max 200 words in a Markdown format."
field :channel, :string,
required: true,
description: "Channel is given by the list_channels MCP resource from this same server."
end
@impl true
def execute(input, frame) do
resp = Response.tool()
resp =
with {:ok, current_user} <- get_current_user(frame),
{:ok, channel} <- get_channel(input),
{:ok, %Post{} = post} <- create_til_post(current_user, channel, input) do
Response.resource_link(resp, ~p"/post/#{post}", "til-post",
description: "Open this link in order to review the TIL and publish it!"
)
else
{:error, reason} ->
Response.error(resp, "ERROR => #{reason}")
end
{:reply, resp, frame}
end
defp get_current_user(frame) do
headers = Enum.into(frame.transport.req_headers, %{})
signed_token = headers["x-api-key"]
with "" <> _ <- signed_token,
{:ok, mcp_api_key} <- User.verify_mcp_api_key(signed_token) do
Tilex.Repo.one(from d in User, where: d.mcp_api_key == ^mcp_api_key)
else
_ -> nil
end
|> case do
nil -> {:error, "User is not authenticated to create TILs"}
%User{} = user -> {:ok, user}
end
end
defp get_channel(%{channel: channel}) do
...
end
defp create_til_post(%User{} = current_user, channel, %{title: title, body: body}) do
...
end
end
To be honest, this is the gist of it. Again the implementation of get_channel and create_til_post are just Ecto Repo queries and Ecto Changeset insert.
Providing a Way to Generate the MCP Tokens
We ended up adding into our own User profile page a new feature to generate a new MCP token:

This way, when we click to generate a new token, we also get:

Right now, among all devs we are using 2 main AI tools: Cursor and Claude Code, so we ended up adapting that page to give the devs final instructions to setup that MCP on their preferred tool.
Another point to note here is that we used Phoenix.Token to sign and verify tokens for the authentication header. That's out of the scope of this post but again very nice that the this feature is available already to be used.
Let's see it in action
So now I can open my claude code and input this type of prompt:
write me a TIL about the most common bug in JS
And this is the output that I got:
⏺ I'll help you write a TIL post about the most common bug in JavaScript. Let me first check if there's already a draft file, then I'll create the post using the TIL system.
⏺ Read(til_most_common_js_bug.md)
⎿ Read 1 line
⏺ listMcpResources(List MCP resources from server "til")
⎿ [
{
"name": "list_channels",
… +7 lines (ctrl+o to expand)
⏺ readMcpResource(Read resource "til:///channels" from server "til")
⎿ {
"contents": [
{
… +6 lines (ctrl+o to expand)
⏺ Now I'll create a TIL post about one of the most common bugs in JavaScript - the closure in loops issue:
⏺ til - new_post (MCP)(title: "The Classic Closure-in-Loop Bug", body: "One of the most common bugs in JavaScript is the closure-in-loop problem. Here's the classic example:\n\n```javascript\nfor (var i = 0; i < 5; i++) {\n setTimeout(() => console.log(i), 100);\n}\n// Prints: 5, 5, 5, 5, 5\n```\n\nWhy? Because `var` is function-scoped, not block-scoped. All
callbacks share the same `i` reference, which equals 5 after the loop completes.\n\n**Solutions:**\n\n1. Use `let` (block-scoped):\n```javascript\nfor (let i = 0; i < 5; i++) {\n setTimeout(() => console.log(i), 100);\n}\n// Prints: 0, 1, 2, 3, 4\n```\n\n2. Use an IIFE to capture the value:\n```javascript\nfor (var i = 0; i < 5; i++)
{\n (function(j) {\n setTimeout(() => console.log(j), 100);\n })(i);\n}\n```\n\nThis bug stems from misunderstanding how closures capture variables by reference, not by value. Modern JavaScript developers should prefer `let` and `const` to avoid this entirely.", channel: "javascript")
⎿ [Resource link: til-post] http://til.hashrocket.com/posts/e4pehjsbkt-the-classic-closure-in-loop-bug/edit (Open this link in order to review the TIL and publish it!)
⏺ Done! I've created a TIL post about the classic closure-in-loop bug in JavaScript - one of the most common bugs that trips up developers.
The post covers:
- Why the bug happens (function-scoped var vs block-scoped let)
- A clear example showing the problem
- Two solutions: using let (modern approach) and IIFE (legacy approach)
You can review and publish it here: http://til.hashrocket.com/posts/e4pehjsbkt-the-classic-closure-in-loop-bug/edit
We can see that uppon my prompt Claude decided to call list_channels to it's gathering of content and finally call the new_post tool. In our happy path the server return a Response.resource_link with the newly created TIL and in the text we are suggesting the developer to go to that link, review the whole content and if that's ok to publish it.
Final Thoughts
TIL started over 10 years ago as a Ruby on Rails project, and a few years ago that project was converted to be an Elixir Phoenix project. At that time, the developers that converted it wanted to experiment more with Elixir, so they migrated the project with the basic features. So there was 1 feature in particular that was left behind that I think we had to bring it back due to the use of this MCP server. It was the ability to create a TIL post as a draft. When we did this work, we wanted that feature back so badly.
The main issue is that no matter how we tell AI tooling to do their tasks the execution itself is always unpredictable. With the interactions we had so far to test all these new post MCP server we noticed that sometimes the TIL content created by the AI is ok but it's not the way we'd write, it does not expose the element of learning a particular thing in the post. Sometimes the examples are too complex and take the focus off. So we end up having to adjust the content in 100% of times. This was the main driver for us to put that draft posts feature back.
Some other times the post created are completely hallucinated trash. When it happens it's usually that our prompt was not adequate enough for AI to understand, so we'd like to prompt again, give more info and try to create again. We could create a new MCP server to update the last TIL or so, but we end up implementing a feature to delete a TIL. So when the user opens the TIL post page to check the content, now they can delete that post and go back to their AI and try again with a new post.
So in the end, implementing that MCP server was very easy. The power of scaffolding an idea into a new TIL is very helpful. But as the data now comes from an unpredictable source you may need to consider the implication of this new MCP servers. Adding a moderation to this process is quite important now.
We Can Help
At Hashrocket, we have deep expertise in Elixir, Phoenix, and integrating modern AI capabilities into production applications. Whether you're looking to build custom MCP servers, modernize your existing applications with Elixir, or explore how AI can enhance your development workflow, we'd love to help. Our team has decades of combined experience building robust, scalable web applications and we're always excited to tackle interesting technical challenges. Get in touch with us to discuss how we can help with your next project.