
list_knowledges() returns forty three notes. get_knowledge_by_uri() returns a 404 for three of them, and Claude Desktop never tells you which three until you ask directly, and even then it sometimes guesses. The official docs on this pattern cover the decorator and the schema generation. They don't cover what happens when a note goes missing, when the filesystem stores folder names with different capitalization than Obsidian displays, or when a model gets a plain FileNotFoundError and just hallucinates a confident paraphrase of a file it never read. That gap, between what the two tool pattern promises and what it actually does when a vault has three years of renamed folders and one stray latin1 file in it, is what breaks a weekend project at 4pm on a Tuesday. This post is about where that gap actually shows up, and what fixes it without turning a two tool server into a full search engine.
That was my Tuesday afternoon two weeks ago, rebuilding a local MCP server to connect my Obsidian vault to Claude after reading through someone else's writeup on doing exactly this. The idea is simple enough that it almost sounds like a weekend project. Two tools, a FastMCP object, a vault full of markdown files. The simplicity is the whole point, and also exactly where it breaks in ways the official docs don't mention. I went in believing this pattern works, and works better than most people expect from something this small. The failure modes just aren't where you'd guess.
The two tool pattern does less than it looks like it should
The Two Tool Pattern: How Claude Actually Fetches Notes
Source: Based on author's description of server behavior
A developer building their first Obsidian MCP server usually lands on the same shape I did: one tool called something like list_knowledges() that returns metadata for every note in the vault, and a second tool, get_knowledge_by_uri(uri), that opens a specific note and returns its content. That's it. No search tool, no tagging tool, no tool for writing back into the vault. Just enumerate, then fetch.
What surprised me is how far that gets you. I expected to need a search_by_keyword tool almost immediately. I didn't. Claude, given a full list of note titles and URIs, can reason about which ones are relevant to a question and then call get_knowledge_by_uri() three or four times in a row without being told to. Ask it about a project you wrote notes on in March, and it'll skim the list, pick two or three candidates by title alone, read them, and sometimes go back for a fourth when the first few don't answer the question. That's not search. That's the model doing search's job with worse tools and getting there anyway.
Here's a trimmed version of the server I ended up with, adjusted after my own note IDs started colliding:
from fastmcp import FastMCP
from pathlib import Path
mcp = FastMCP("obsidian-local")
VAULT_PATH = Path.home() / "Documents" / "ObsidianVault"
@mcp.tool()
def list_knowledges() -> list[dict]:
"""Return all markdown notes with a stable URI and title."""
notes = []
for f in VAULT_PATH.rglob("*.md"):
uri = f.relative_to(VAULT_PATH).as_posix()
notes.append({"uri": uri, "title": f.stem})
return notes
@mcp.tool()
def get_knowledge_by_uri(uri: str) -> str:
"""Return the raw content of one note given its URI."""
target = VAULT_PATH / uri
if not target.exists():
raise FileNotFoundError(f"No note at {uri}")
return target.read_text(encoding="utf-8")
if __name__ == "__main__":
mcp.run()
Nobody tells you, until you hit it yourself, that rglob order isn't stable across filesystem types. On a MacBook with APFS, I got one order. After syncing the same vault through a cloud drive to a Linux box for testing, the order changed, and with it, which note Claude saw first in a list of near duplicate titles. Small thing. Still cost me twenty minutes wondering why the model's answers changed between machines when the vault content hadn't.
The two tool pattern wins for anyone who wants a working integration this weekend, not a search engine. If your vault is under a few hundred notes, skip the urge to build a vector index on day one. The people chasing a fully featured retrieval system before proving the basic loop works are the ones whose projects stall before shipping.
That basic loop held up. What didn't hold up without extra work was what happened the moment a tool call failed, which is where the next problem starts.
FastMCP registration hides more than the decorator suggests
Where the Gap Shows Up: Docs vs Real Vault Behavior
| Issue | Covered in Docs | Actually Happens |
|---|---|---|
| Decorator and schema setup | Yes | Works as documented |
| Missing or 404 notes | No | 3 of 43 notes 404, not disclosed unless asked |
| Folder capitalization mismatch | No | Filesystem case differs from Obsidian display |
| Model response on FileNotFoundError | No | Hallucinated confident paraphrase of unread file |
| rglob order across filesystems | No | Order changes between APFS and Linux, alters results |
Source: Based on article's comparison of official docs coverage vs actual failure modes
Claude Desktop's tool picker showed both tools with clean names and docstrings pulled in as descriptions, which is the payoff FastMCP is selling. Write a Python function, decorate it, and the protocol plumbing disappears. For the most part that holds. FastMCP 2.x, which is what most people are running now since the project split from the reference SDK, handles schema generation from type hints well enough that I didn't write a single line of JSON schema by hand.
Where it got less clean was error handling. Raise a plain FileNotFoundError inside a tool function and FastMCP will serialize it into an MCP error response, which sounds fine until you watch Claude's behavior on receiving one. Sometimes it retries the call with a slightly different URI guess. Sometimes it apologizes and gives up. Sometimes it hallucinates content anyway, as if the error never arrived, and presents a confident paraphrase of a note that doesn't exist. That last one is worth watching for, because it doesn't look like a failure. It looks like a correct answer about a file that was never read.
The fix I settled on was returning a structured message instead of raising, so the model gets something it can reason about rather than an opaque failure:
@mcp.tool()
def get_knowledge_by_uri(uri: str) -> str:
target = VAULT_PATH / uri
if not target.exists():
return f"ERROR: no note found at '{uri}'. Call list_knowledges() again to check valid URIs."
try:
return target.read_text(encoding="utf-8")
except UnicodeDecodeError:
return f"ERROR: note at '{uri}' is not valid UTF-8 text, cannot be read as markdown."
That second branch exists because of one specific note in my vault, an old export from a PDF annotation tool that had somehow saved as latin1 instead of utf8. Claude never would have known why the tool failed. Returning the actual encoding problem as text let the model decide to skip the note and move on, instead of retrying the same call four times with cosmetic changes to the URI string, which is exactly what it did before I added that branch.
Claude Desktop's own logs, which live under ~/Library/Logs/Claude on macOS and are more useful than the in app error toasts, showed this pattern clearly once I started tailing them during testing:
tail -f ~/Library/Logs/Claude/mcp-server-obsidian-local.log
sample output during a failed call
Vault Snapshot: What Broke on One Tuesday Afternoon
43
Notes returned by list_knowledges()
3
Notes returning a 404 on fetch
2
Tools in the entire server
20
Minutes lost to rglob order confusion
Source: Based on figures stated in the article
[error] Tool get_knowledge_by_uri failed: FileNotFoundError: No note at 'projects/Q3-plan.md'
[info] Retrying with uri='Projects/Q3-plan.md'
[info] Tool get_knowledge_by_uri succeeded
That capitalization mismatch, lowercase p versus uppercase P, is the kind of bug that never shows up in a demo screenshot because demos use tidy, consistent folder names. Real vaults, especially ones renamed and reorganized over two or three years, don't. The case sensitivity gap between how Obsidian displays folder names and how the underlying filesystem stores them causes a quiet, recurring source of 404s in this setup, and you won't find it in the FastMCP documentation because it isn't a FastMCP problem. It's a vault problem that FastMCP just happens to expose.
FastMCP's decorator model is convenient, but it doesn't absolve you of designing what failure looks like to the model consuming your tool. Treat every tool function like it's an API endpoint with a client that can't ask you clarifying questions, because that's exactly what it is.
Fixing error handling solved the missing file and bad encoding cases. It didn't explain why the same server, with the same fixes in place, behaved differently depending on which Claude client called it, which is the next thing I ran into.
Claude Desktop and Claude Code handle the same server differently
I ran the identical obsidian-local server through both Claude Desktop and Claude Code within the same week, same vault, same config file structure, and got meaningfully different behavior out of each. Claude Desktop, configured through claude_desktop_config.json, surfaces the tools in a visible picker, and the user has to approve tool use interactively unless they've changed the permission settings. Claude Code, run from the terminal against a project directory, picked up the same MCP server definition through a project level .mcp.json and used the tools more aggressively, chaining four or five get_knowledge_by_uri() calls in a single turn without pausing for confirmation, because the terminal context implies a more autonomous session by default.
Here's the config that worked for both, with the one difference that actually mattered called out below:
{
"mcpServers": {
"obsidian-local": {
"command": "python",
"args": ["/Users/me/projects/obsidian-mcp/server.py"],
"env": {
"VAULT_PATH": "/Users/me/Documents/ObsidianVault"
}
}
}
}
That file is almost identical whether it lives in Claude Desktop's config directory or a project's .mcp.json for Claude Code. The difference isn't in the JSON. It's in session behavior once the tools are live. Claude Code, run in an active terminal session with no human watching every tool call, will read through fifteen notes in a row chasing down a cross reference, where Claude Desktop in its default permission mode stops and asks after the first few calls. Neither is wrong. They're tuned for different trust levels, and if you built your server assuming one, the other will surprise you.
That same unsupervised chaining in Claude Code is what exposed the next failure, because reading eight or more notes in a row is exactly the condition that breaks down over a long session.
The failure that cost me a half rebuilt server before I understood it was context window behavior during long chains of tool calls. Feed the model forty three note titles through list_knowledges() and then have it read eight of them in sequence, and by the sixth or seventh get_knowledge_by_uri() call in a long session, the model occasionally lost track of which original question it was answering. It would still return content, technically succeeding at the tool call, but drift from the actual task. This isn't a FastMCP bug or an MCP protocol bug. It's a consequence of stuffing enough raw markdown into context that earlier instructions start to get crowded out. Nothing in the MCP spec addresses this, because MCP's job ends at delivering the content, not managing what the model does with it afterward.
My workaround was unglamorous but effective: trim each note's returned content to a reasonable length inside get_knowledge_by_uri() rather than returning the whole file, and let the model request more explicitly if it needs it.
@mcp.tool()
def get_knowledge_by_uri(uri: str, max_chars: int = 4000) -> str:
target = VAULT_PATH / uri
if not target.exists():
return f"ERROR: no note found at '{uri}'."
text = target.read_text(encoding="utf-8")
if len(text) > max_chars:
remaining = len(text), max_chars
return text[:max_chars] + f"\n\n[TRUNCATED, {remaining} more characters not shown]"
return text
That one parameter change fixed more drift than any prompt engineering I tried on the Claude Desktop side. Giving the model an explicit signal that content was cut off, rather than silently feeding it a 12,000 character note, meant it would ask for more only when it actually needed it instead of burning context on notes it half read anyway.
If you're deciding between Claude Desktop and Claude Code for this kind of local tool work, Desktop is friendlier for someone validating that their server even works, and Code is better once you trust the server enough to let it run unattended through a real task. Build for one and test only there, and you end up with a server that looks finished in a demo and breaks the first time a colleague runs it through the other client. Test both from day one, or know exactly which one you're shipping for.
Going back to the three notes that 404'd at the start: they failed for three different reasons, a renamed folder with mismatched capitalization, a latin1 export that was never valid markdown, and a truncated read that looked like a miss but wasn't. None of those are FastMCP bugs, and none of them show up in the official two tool writeups, because they only appear once a vault has years of real editing history behind it. The two tool pattern still works, and it's still the right starting point for a weekend build. It just isn't finished until you've written the failure paths yourself, because the decorator gives you the happy path for free and nothing else.