---
title: "Building an MCP Server for Archivist AI with Laravel"
description: "What started as a thin read-only proxy turned into a lesson in LLM-friendly API design, OAuth, schema consistency, MCP resources and safe write access."
canonical: "https://gummibeer.dev/blog/2026/building-myarchivist-mcp"
---

# Building an MCP Server for Archivist AI with Laravel

What started as a thin read-only proxy turned into a lesson in LLM-friendly API design, OAuth, schema consistency, MCP resources and safe write access.

I wanted my Archivist campaign data available in the AI tools I already use.

That's pretty much how [mcp.myarchivist.ai](https://github.com/Astrotomic/mcp.myarchivist.ai) started.

[Archivist AI](https://www.myarchivist.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](https://www.myarchivist.ai/blog/archivist-mcp-interview), 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](https://github.com/laravel/mcp) had just been released.

So the initial architecture was boring:

```text
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:

```json
{
    "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:

1. The MCP server stays stateless.
2. 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:

```text
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:

```text
/.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: I 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:

```php
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:

```php
$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:

```text
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:

```text
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:

```text
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:

```text
Laravel validation rules
MCP input JSON Schema
tests
```

Because they will drift.

So the current actions own their validation rules:

```php
abstract public static function rules(): array;
```

And these same rules become the MCP schema:

```php
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:

```php
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:

```php
#[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:

```text
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:

```text
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:

```text
search_campaign
ask_campaign
create_character
update_character
find_related_entities
```

That gives you a much cleaner separation:

```text
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:

```text
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`](https://github.com/laravel/mcp/issues/222).

I expected normal Laravel behaviour:

```php
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](https://github.com/laravel/mcp/pull/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`](https://github.com/laravel/mcp/issues/221) while testing resources with URI templates.

Given a resource like:

```text
sessions/{id}/summary
```

this should work:

```php
McpServer::resource(SummaryResource::class, [
    'id' => 'abc',
]);
```

Instead I got:

```text
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`](https://github.com/laravel/mcp/issues/224).

The API I wanted is intentionally boring:

```php
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:

```text
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:

```text
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:

```text
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.
