Building an MCP Server for Archivist AI with Laravel
I wanted my Archivist campaign data available in the AI tools I already use.
That's pretty much how mcp.myarchivist.ai started.
Archivist AI records and processes TTRPG sessions and turns them into structured campaign memory - sessions, characters, factions, locations, items, quests, moments, transcripts and a lot more. I already had all that data there. But whenever I wanted to use it in Cursor, ChatGPT, Notion or another workflow, I was back to copying things around manually.
Archivist already wrote a much more TTRPG-focused article about what I use the MCP for, including my slightly ridiculous post-session workflow. I will probably write my own follow-up about that side at some point as well.
This one is about the technical part.
Because building the first MCP server was actually pretty easy.
Making the API good for an LLM was the interesting part.
The first version
Archivist already had a REST API and Laravel MCP had just been released.
So the initial architecture was boring:
MCP client
↓
Laravel MCP server
↓
Archivist REST API
No database.
No cache containing campaign data.
No second source of truth.
The MCP server takes the authenticated request, translates a tool call into an Archivist API request and returns the result.
That's it.
The very first version simply exposed the existing GET endpoints as MCP tools. Campaigns, characters, sessions, moments, factions, locations, items, quests and everything else that was already readable through the API.
And five days after I first mentioned the idea to Greg from Archivist, it worked.
Cursor and Claude could navigate my actual campaign instead of me pasting context into a prompt.
The initial remote config looked roughly like this:
{
"mcpServers": {
"MyArchivist": {
"type": "http",
"url": "https://mcpmyarchivistai.on-forge.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_KEY"
}
}
}
}
The server later moved to the official mcp.myarchivist.ai domain.
The first live version had 25 read tools.
That number didn't survive very long.
Read-only was intentional
The first version only exposed GET endpoints even though the Archivist API already supported writes.
That wasn't because creating an update tool is difficult.
It was because allowing an LLM to read the wrong character is annoying.
Allowing it to overwrite campaign canon is something else.
At that time an Archivist API key was also unscoped. It could access everything the user could access through the API.
So I made two decisions:
- The MCP server stays stateless.
- The first version stays read-only.
The API key only existed in memory while proxying the request. There was no database where I could accidentally store hundreds of full-access API keys.
But that still wasn't a great long-term authentication model.
OAuth was.
OAuth was more annoying than the MCP
Archivist already had OAuth infrastructure, so most of the work wasn't inventing authentication. It was making the MCP clients agree on how to use it.
Cursor in particular sent me down the OAuth discovery and client registration rabbit hole.
The server needed the correct well-known metadata. Cursor expected a registration endpoint. The registered redirect URI had to exactly match the URI Cursor later used during token exchange.
At one point the complete flow worked until the final token request:
client registration ✅
authorization redirect ✅
user approval ✅
authorization code ✅
token exchange 💣
The redirect_uri on the token exchange didn't match the one registered for the OAuth client.
Classic OAuth.
What started as "the API already has OAuth" quickly turned into discussions around:
/.well-known/oauth-protected-resource
/.well-known/oauth-authorization-server
client registration
PKCE
redirect URIs
scopes
That part is mostly invisible once everything works, which is exactly how authentication should be.
Today the server can use OAuth scopes to decide which tools a client is even allowed to discover.
That became especially important once I broke my own read-only rule.
The read-only rule didn't survive
The original plan was roughly:
Keep it read-only until somebody actually needs writes.
Turns out: we needed writes. 😅
At the time of writing the server is at v2.2.0 and registers 82 tools.
It now covers create/update/delete operations for most of Archivist's domain as well as transcripts, session handouts, hero awards, quotes, spotlights, entity links and image uploads.
But write access isn't simply enabled for every OAuth client.
Every write tool requires an agent_write scope.
The nice part is that this isn't only checked when a tool is called. The MCP server filters tool discovery itself.
A client without write permissions doesn't get 82 tools with 50 of them failing later.
It simply doesn't see them.
The relevant part currently lives in my base tool:
public function shouldRegister(AuthContext $authContext): bool
{
if (! $this->action() instanceof WriteApiAction) {
return true;
}
return $authContext->canWrite();
}
Laravel MCP uses that while resolving the primitives for the server, so the same rule controls tools/list and actual tool execution.
I like that a lot more than telling the model in a tool description:
Please don't call this unless you are allowed to.
Security rules should probably not depend on an LLM being polite.
LLMs are brutal API consumers
Getting the tools to call the API wasn't the real problem.
The API itself was.
Not because the Archivist API was fundamentally bad. It was primarily built for normal integrations, and normal integrations have very different behaviour than an LLM deciding which endpoint to call next.
A human developer will happily write:
$sessions = $client->sessions();
$latest = collect($sessions)
->sortByDesc('session_date')
->take(3);
An LLM with MCP tools sees something else.
The user asks:
What happened in the last three sessions?
If list_sessions can't sort and limit server-side, the model may have to fetch every page, put every returned object into its context and then discard almost everything.
That's stupidly wasteful.
The same problem appeared with search.
Characters could be searched by name.
Locations couldn't.
So finding one location could mean:
list page 1
list page 2
list page 3
...
search all returned objects locally
get matching location by ID
One user testing the MCP with Claude confirmed exactly what I expected: whenever search missed, Claude fell back to pulling large datasets and token usage jumped.
This changed how I looked at the API.
Questions that are completely normal for a human suddenly become API requirements:
give me the last 3 sessions
give me all open quests
find every location matching "Briar"
give me sessions after this date
find characters related to this faction
Pagination alone doesn't solve those.
For an LLM-facing API, filtering and cheap discovery aren't convenience features.
They directly control how much context and how many tool calls the model burns before it can answer.
List and detail responses suddenly matter a lot
The next problem was response consistency.
During development I built integration tests against my real Archivist account.
Not mocked responses.
The real production API.
For every endpoint I defined the response shape I expected and let the tests complain when reality differed.
And complain they did.
For example, the list response for factions, items and locations contained an approved property while their corresponding single-resource endpoint didn't.
Quest lists returned objective_count, while the detail endpoint didn't.
There were also things like world_id in one place and campaign_id everywhere else, undocumented enum values and internal merge-related attributes appearing in public character data.
The API docs and real responses weren't always aligned either.
None of these is a massive problem on its own.
Together they make an MCP implementation painful.
If the same entity has multiple representations, I either need multiple DTOs or a DTO full of optional properties depending on which endpoint populated it.
And if an LLM receives different shapes for conceptually identical objects, that's additional uncertainty for no real benefit.
My preferred target became simple:
One representation per entity wherever possible.
Heavy data is different.
A full transcript can absolutely live behind:
GET /sessions/{id}/transcript
instead of making every session response enormous.
Same for handouts or other genuinely expensive payloads.
But approved randomly existing on one representation of a Location and not another? Nope.
Let the DTOs complain
The API inconsistencies changed the architecture of the MCP server as well.
I didn't want three separate definitions for the same shape:
Laravel validation rules
MCP input JSON Schema
tests
Because they will drift.
So the current actions own their validation rules:
abstract public static function rules(): array;
And these same rules become the MCP schema:
public static function toJsonSchema(): array
{
return RulesToJsonSchema::make()->execute(
static::rules()
);
}
The DTOs do the same for output.
They validate known properties and report unexpected ones:
private function checkForUnexpectedKeys(array $attributes): void
{
$unexpected = collect($attributes)
->keys()
->diff(array_keys(static::rules()))
->values();
if ($unexpected->isNotEmpty()) {
report(new UnexpectedDtoAttributeException(
dtoClass: static::class,
keys: $unexpected->all(),
));
}
}
That means an additive API change doesn't silently disappear somewhere inside an array.
I see it.
And because the DTO rules also produce the MCP output schema, changing the representation in one place updates the contract exposed to clients.
The individual tools are tiny now.
For example:
#[Description('Get a specific character by ID including aliases, backstory, and speaker linkage.')]
#[IsReadOnly(true)]
#[IsDestructive(false)]
#[IsIdempotent(true)]
#[IsOpenWorld(false)]
class GetCharacterTool extends Tool
{
protected function action(): GetCharacter
{
return GetCharacter::make();
}
}
The common tool handles validation, action execution, errors, pagination and structured output.
That is a lot closer to what I wanted than 82 slightly different classes all manually defining JSON Schema.
MCP isn't REST with another transport
This is probably the biggest thing I changed my mind about while working on the server.
The first version essentially did this:
GET /characters/{id}
→ get_character
GET /locations/{id}
→ get_location
GET /sessions/{id}
→ get_session
Perfectly reasonable way to get something working.
But MCP has more primitives than tools.
A character that already exists and can be addressed by ID isn't really an action.
It's context.
Semantically I would now rather expose deterministic objects through resources:
archivist://campaigns/{campaign_id}/characters/{character_id}
archivist://campaigns/{campaign_id}/sessions/{session_id}
archivist://campaigns/{campaign_id}/sessions/{session_id}/transcript
archivist://campaigns/{campaign_id}/journals/{journal_id}
And leave tools for actual operations:
search_campaign
ask_campaign
create_character
update_character
find_related_entities
That gives you a much cleaner separation:
Resources = things that exist
Tools = things you do
Prompts = reusable workflows a user starts
In theory.
Then there is client reality.
A lot of MCP clients have historically been much better at tools than resources or prompts. Some effectively treated MCP as a tool registry.
If I remove get_character because archivist://.../characters/{id} is semantically prettier, but half the clients can't use that resource properly, I've made the MCP more correct and less useful.
Wonderful. 😅
So I currently see tools as the compatibility surface.
Long-term I would like the canonical read model to be resources, while thin read tools can stay around for clients that need them.
Something like:
Resource:
archivist://campaigns/123/characters/456
Compatibility tool:
get_character(
campaign_id: 123,
character_id: 456
)
Both should resolve through the same internal code.
No duplicated business logic.
Laravel MCP resources found bugs before I could really use them
Trying to move more things into MCP resources also made me exercise the fairly new laravel/mcp package beyond the happy path.
That resulted in a few upstream issues.
Test responses and dump()
The smallest one was TestResponse::dump() not returning $this.
I expected normal Laravel behaviour:
McpServer::tool(GetSessionTool::class, [
'session_id' => 'abc',
])
->dump()
->assertOk();
But dump() returned void, so assertOk() was called on null.
I opened the issue, sent PR #223, and it was merged a few minutes later.
Tiny fix.
But those tiny consistency things matter a lot when a package is supposed to feel like Laravel.
Testing URI templates
Then I hit #221 while testing resources with URI templates.
Given a resource like:
sessions/{id}/summary
this should work:
McpServer::resource(SummaryResource::class, [
'id' => 'abc',
]);
Instead I got:
Invalid params: The id field must be a string.
The problem wasn't the value.
Resource::toMethodCall() still returned the unresolved URI template while ReadResource tried to match it against the resolved URI.
That one was picked up in PR #225 and fixed upstream as well.
Embedded resources
The third one is still more interesting to me.
MCP tool and prompt responses can contain an embedded resource - not only a link to a URI, but the resource and its resolved content together.
Laravel MCP already had resource links, but not embedded resource responses.
I needed exactly that for Archivist prompts, so I implemented it locally and opened #224.
The API I wanted is intentionally boring:
Response::embeddedResource(
resource: app(SessionSummaryResource::class),
arguments: [
'session_id' => $sessionId,
],
)
Resolve the resource URI, run it and package the resulting content as an MCP resource content block.
The issue is still open at the time of writing.
And I think it nicely shows why I like building something real against new framework packages instead of only playing with example servers.
You very quickly find which parts of the protocol abstraction are actually missing.
Tools need descriptions, not marketing text
Another thing that's easy to underestimate is tool descriptions.
For a normal API, the endpoint name and OpenAPI schema are primarily read by a developer.
For MCP, the description is part of the model's decision about which tool to call.
So this:
Get characters.
is technically correct and practically useless.
The model needs to understand things like:
- whether the tool searches or needs an exact ID
- whether it returns a summary or a full object
- which filters should be used instead of fetching everything
- whether a call changes data
- whether another tool should usually be called first
This became even more important with write support.
Archivist stores links between entities directly inside text as wikilinks. A naive agent could read cleaned text, modify one sentence and write it back - deleting links that disappeared during the read.
So the current server instructions are pretty explicit: when modifying link-aware content, fetch it with links included first and preserve or deliberately change those links.
There is some ugly domain knowledge in there.
But that's exactly the knowledge the agent needs.
An MCP server isn't useful because it exposes a method called update_character.
It is useful if the model knows how to update the character without silently destroying other data around it.
82 tools aren't the goal
Going from 25 read tools to 82 read/write tools sounds like progress.
And functionally it is.
But I don't think the number is impressive by itself.
If I simply expose every REST endpoint as another MCP tool I can probably get that number much higher.
That doesn't automatically make the server better.
A giant flat CRUD surface also increases the model's tool-selection problem.
I think the more interesting direction is putting the right abstraction at the right level:
Resources
stable campaign context
Read tools
search, filtering, RAG and compatibility
Write tools
deliberate state changes
Prompts
reusable multi-step workflows
And for the dangerous stuff I still prefer more guardrails over fewer.
The current server has direct CRUD because it's useful and the API supports it. OAuth scopes make that much safer than the original full-access API key.
But for bigger canon changes I can easily see another layer becoming useful:
draft change
↓
show diff + sources
↓
human approval
↓
commit
Because "the model had permission to write" and "the model should silently rewrite 40 sessions of campaign canon" are definitely not the same thing.
What building the MCP changed for me
I started with the idea that I would write a thin Laravel adapter around an existing REST API.
And technically that's still what the server is.
But putting an LLM on the other side changed what I care about in the underlying API.
I care much more about cheap discovery.
I care about consistent response shapes.
I care whether every list endpoint can search and filter before data enters model context.
I care about machine-readable descriptions.
I care about stable resource identifiers.
And I really care about separating read access from destructive actions.
None of these things are exclusive to MCP.
But an LLM will punish every weak spot much faster than a normal integration.
A normal client fetches exactly what its developer told it to fetch.
An LLM tries to figure out what it needs.
If the only way to answer "who was that NPC from 30 sessions ago?" is seven list calls, three detail calls and 50 KB of irrelevant JSON, it will do exactly that.
And send you the token bill afterwards.
That's probably the main lesson I got from building this:
An API being easy to call doesn't mean it's easy for an agent to use.
Those are two different API design problems.