Establishes the tool pattern: each tool module exposes register(mcp),
register_all() aggregates them, build_mcp_server() calls register_all.
fable_search mirrors the existing fable-mcp contract (q/content_type/limit
in; {results, total} out) but calls services.embeddings.semantic_search_notes
directly instead of going over HTTP. User comes from mcp.current_user_id().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
87 lines
3.3 KiB
Python
87 lines
3.3 KiB
Python
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
|
|
from __future__ import annotations
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
from quart import Quart
|
|
|
|
_INSTRUCTIONS = """
|
|
Fabled Scribe is the user's self-hosted second-brain and project-management
|
|
data store. You (Claude) are the assistant.
|
|
|
|
Hierarchy: Project -> Milestone -> Task/Note.
|
|
|
|
- Notes and Tasks share a model; tasks are notes with is_task=True.
|
|
- Use fable_*_note tools for notes, fable_*_task tools for tasks.
|
|
- Typed entities (person, place, list) are notes with a type field plus
|
|
type-specific columns; use the dedicated tools for those.
|
|
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
|
|
unchanged on updates.
|
|
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
|
|
"not set".
|
|
"""
|
|
|
|
|
|
def build_mcp_server() -> FastMCP:
|
|
"""Build the FastMCP instance with all tools registered."""
|
|
mcp = FastMCP("fable", instructions=_INSTRUCTIONS.strip())
|
|
from fabledassistant.mcp.tools import register_all
|
|
register_all(mcp)
|
|
return mcp
|
|
|
|
|
|
def mount_mcp(app: Quart) -> None:
|
|
"""Mount the FastMCP streamable-HTTP ASGI sub-app at /mcp on the Quart app.
|
|
|
|
A small ASGI middleware between Quart and the FastMCP sub-app validates the
|
|
Bearer token against the api_keys table. Authenticated requests have their
|
|
user_id attached to the ASGI scope under "fable_user_id" for tool handlers
|
|
to read in later phases.
|
|
"""
|
|
from fabledassistant.mcp.auth import resolve_bearer_to_user_id
|
|
|
|
mcp = build_mcp_server()
|
|
mcp_asgi = mcp.streamable_http_app()
|
|
app.mcp_instance = mcp
|
|
|
|
async def auth_wrapped(scope, receive, send):
|
|
if scope["type"] != "http":
|
|
return await mcp_asgi(scope, receive, send)
|
|
# ASGI headers are lowercase bytes per spec; lowercase explicitly to be safe.
|
|
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
|
user_id = await resolve_bearer_to_user_id(headers.get("authorization"))
|
|
if user_id is None:
|
|
await send({
|
|
"type": "http.response.start",
|
|
"status": 401,
|
|
"headers": [
|
|
(b"content-type", b"application/json"),
|
|
(b"www-authenticate", b'Bearer realm="fable-mcp"'),
|
|
],
|
|
})
|
|
await send({
|
|
"type": "http.response.body",
|
|
"body": b'{"error":"unauthorized"}',
|
|
})
|
|
return
|
|
scope["fable_user_id"] = user_id
|
|
from fabledassistant.mcp._context import _user_id_ctx
|
|
token = _user_id_ctx.set(user_id)
|
|
try:
|
|
await mcp_asgi(scope, receive, send)
|
|
finally:
|
|
_user_id_ctx.reset(token)
|
|
|
|
original_asgi = app.asgi_app
|
|
|
|
async def dispatch(scope, receive, send):
|
|
if scope["type"] == "http":
|
|
path = scope.get("path", "")
|
|
if path == "/mcp" or path.startswith("/mcp/"):
|
|
sub_scope = dict(scope)
|
|
sub_scope["path"] = path[len("/mcp"):] or "/"
|
|
sub_scope["root_path"] = scope.get("root_path", "") + "/mcp"
|
|
return await auth_wrapped(sub_scope, receive, send)
|
|
return await original_asgi(scope, receive, send)
|
|
|
|
app.asgi_app = dispatch
|