Building a second brain w/ PHP MCP SDK and the PARA method
Introduction
Every AI agent I work with has the same problem: it starts every session from zero. Info is scattered across sessions, things are forgotten, we find and lookup the same stuff multiple times because our previous session didnt store it.
So I’ve been designing and working on a second brain. This is a popular term, and has been for years, for humans. But my second brain, is not one for me to browse, but one that is written and read by AI, with me as the editor. The first real use case is our house renovation, which produces exactly the kind of messy input that’s hard to keep track of: three quotes for the same job, an email that moves a date, an invoice that doesn’t match the quote.
In this post I’ll walk you through the idea behind it, how the PARA method fits in, and how the first building blocks look in Symfony with the PHP MCP SDK.
The PARA method, adapted for AI
PARA is a method by Tiago Forte to organise information by how actionable it is, instead of by topic:
- Projects: work with a goal and an end date.
- Areas: ongoing responsibilities without an end date.
- Resources: topics and reference material you might need later.
- Archives: everything that’s no longer active.
This is not an exact science, but an easy framework that allows you to quickly identify where to put something. It works well for humans, but it needs some adaptation for AI.
An AI agent doesn’t browse, it searches and filters. So I kept the vocabulary, but changed how it’s applied:
- Areas are the domains: Development projects, my blog and our house renovation. Every piece of information belongs to exactly one.
- Within an area, an item has a role:
- Project: work with an end, like “bathroom plumbing” or “insulate the attic”.
- Knowledge: what lasts beyond a project: decisions, rules, materials we chose.
- Resource: curated reference material, like floor plans and contractor contacts.
- Source: raw input, like emails, quotes and invoices. Never edited.
- Archive is a status, not a folder. Finished work stays searchable, it just ranks lower.
These are fields on a note, not folders. That keeps IDs and links stable when something gets archived, which matters when agents hold on to references.
Raw input is not knowledge
The most important design choice: a forwarded email is not knowledge, it’s a source.
If you dump everything in one place, searching for “what did we decide about the tiles?” returns fifty email threads, and the actual decision gets buried. So sources stay raw and immutable, and knowledge gets extracted from them, always with a link back to the source it came from.
The full loop for raw input data looks kinda like this:
Source (email, PDF quote, ...)
→ extraction agent reads the source and the existing notes
→ proposal: "update the bathroom note: tiles chosen, quote €4,200"
→ I review: accept, edit or reject each change
→ notes are updated, citing the source
→ agents query the brain over MCPThe agent never writes directly, yet. It proposes, I approve. Agents will later act on what’s in the brain, so it has to be trustworthy, and that review step is what makes this happen, for now. My idea is that i can use an agent like JEV to review the proposals, but for now, I want to be in the loop.
Notes with a fixed shape
Every type of note has a fixed set of sections per area. A room in the House area has Current state, Decisions, Costs, Contractors and Next steps. A ticket in the Dev area has Current state, Decisions, Next steps and Open questions.
That sounds restrictive, but it’s what keeps an AI-written knowledge base usable: the agent has to condense instead of appending, and every note is predictable for both the agent and me. Notes can link to each other, and to other sources.
This fixed shape, we define in a config. Per area.
The stack
- Symfony with PostgreSQL and pgvector: one database for relations, flexible data (JSONB), full-text search and vectors.
- Symfony AI for the extraction agent and the embeddings.
- Claude Opus to extract knowledge from sources.
- microsoft/harrier-oss-v1-0.6b via Hugging Face for multilingual embeddings, because our renovation emails are in Dutch and my questions often aren’t.
- symfony/mcp-bundle, built on the official PHP MCP SDK, to expose it all to agents.
Exposing the brain over MCP
MCP (Model Context Protocol) is the standard way to give AI tools access to your data. Any agent that speaks MCP, like Claude Code or opencode, gets the same memory.
Symfony’s MCP bundle makes this surprisingly little work. Install it, and pin the version: the bundle is still experimental and doesn’t promise backwards compatibility yet.
composer require symfony/mcp-bundleThen configure a server. I use the Streamable HTTP transport under /api, so the same firewall and bearer token that
protect my API also protect the MCP endpoint.
# config/packages/mcp.yaml
mcp:
servers:
brain:
name: 'ultimate-brain'
version: '1.0.0'
transports:
http: true
http:
path: /api/mcp
registry:
tools: ['App\Mcp\']
instructions: |
The brain holds notes per area (dev, blog, house). Search before answering
questions about these areas, and read the notes and sources behind a hit.# config/routes/mcp.yaml
mcp:
resource: .
type: mcpThe instructions are sent to every client when it connects. They’re your chance to tell agents how to use the
tools, not just what they do. Remember these are just samples. In practise, finetuning these instructions is where the magic happens.
The #[McpTool] attribute
A tool is a public method with the #[McpTool] attribute. The bundle picks up every class in the namespaces under
registry.tools, and builds the input schema from the method signature.
<?php
....
#[McpTool(
name: 'brain_read',
description: 'Reads a note with its sections, the notes it links to and the sources it was extracted from.',
annotations: new ToolAnnotations(readOnlyHint: true),
)]
public function read(
#[Schema(description: 'The area the note belongs to: dev, blog or house.')]
string $area,
#[Schema(description: 'The note name, e.g. "bathroom".')]
string $name,
): string {
$note = $this->notes->find($area, $name)
?? throw new ToolCallException("No note named \"$name\" in $area. Use brain_search or brain_list to find it.");
return $this->render($note);
}
...A few things worth pointing out:
nameanddescriptionare what the agent sees. Write the description for the agent: what the tool does and when to use it.#[Schema]on a parameter adds a description to the generated JSON schema. Types and optional parameters come straight from the PHP signature, so?string $section = nullbecomes an optional string.ToolAnnotationshint how the tool behaves:readOnlyHint,destructiveHint,idempotentHint. Clients use them to decide what needs confirmation.ToolCallExceptionturns into a tool error instead of a crashed request. Put the fix in the message (“use brain_search to find it”), and the agent will usually correct itself on the next call.- Return Markdown with clear identifiers in it. The result is read by a language model, not by code; if code needs to read it too, MCP lets you add structured JSON next to the text.
You can inject Mcp\Server\RequestContext as a parameter as well. It gives access to the session, including the
client’s name, which is handy to record which agent wrote something.
To check what your server exposes:
bin/console debug:mcpAnd to connect Claude Code to it:
claude mcp add --transport http brain http://localhost:8000/api/mcp \
--header "Authorization: Bearer <your token>"The extraction agent
Extraction runs in a Messenger job whenever a new source comes in. The agent gets read-only tools over the brain,
so it can find existing notes and update those instead of creating duplicates. In Symfony AI, a tool is a class with
the #[AsTool] attribute:
<?php
...
#[AsTool('search_brain', 'Searches existing notes and sources in an area')]
final readonly class SearchBrain
{
public function __construct(private Search $search)
{
}
/**
* @param string $area The area to search in: dev, blog or house
* @param string $query What to look for
*/
public function __invoke(string $area, string $query): array
{
return $this->search->hits($area, $query);
}
...The agent’s only output is a proposal: a list of operations (“create note”, “replace section”, “add link”), each with a rationale pointing at the part of the source it came from. Those end up in a review screen where I accept, edit or reject them one by one.
Search: one gotcha
Every note section and every source is split into chunks and embedded, and search combines vector similarity with Postgres full-text search. That way “what did we decide about the tiles?” finds a Dutch email about tegels, while a contractor’s name still matches exactly.
For harrier, our prompt can be as follows:
Instruct: Given a question, retrieve notes and documents that answer it
Query: what did we decide about the bathroom tiles?Dont forget the instruct. Retrieval gets noticeably worse without any error to tell you.
The MVP
The first version does one thing: it keeps track of our house renovation, from the moment an email comes in to the moment I ask a question about it.
- Add a source. I paste a contractor’s email, upload a PDF quote, or use the MCP to have claude add a bunch of data.
- Claude proposes changes. It reads the source, looks up what the brain already knows about the house, and proposes updates, like “bathroom: tiles chosen, quote €4,200 from contractor X”.
- I review. In a simple screen I accept, edit or reject each proposed change. Nothing changes without my approval.
- I ask questions. From any ai agent, connected over MCP, I ask “What’s the status of the bathroom, and what has it cost so far?” and it answers from the brain.
We can easily expand and build upon this, make it run more automated, and fine tune the details. Some ideas:
- Automatic email import: for now I add sources by hand.
- Agents writing directly: for now, every change goes through my review.
- Detecting outdated knowledge: for example a note that hasn’t been checked in months.
Conclusion
The biggest lesson so far: in an AI-first second brain, how knowledge is written, checked and traced matters more than how it’s organised. PARA gives a useful vocabulary, but the separation between raw sources and knowledge, the review step and the links back to the source are what make it trustworthy.