LeoWalker's picture
MCP source
33516f7 verified
Raw History Blame Contribute Delete
4.74 kB
"""FastMCP 4 Streamable HTTP server: public tools `search` and `fetch`.
Product transport is HTTP (`stateless_http=True`), endpoint `/mcp`.
Does not expose Neo4j / Cypher. Agent and UI LangChain tools stay on the CLIs.
"""
from __future__ import annotations
import os
from fastmcp import FastMCP
from mcp.types import ToolAnnotations
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from src.deep_research_agent.tools import SearchResults
from src.mcp_server.caps import (
DEFAULT_MAX_SEGMENTS,
DEFAULT_MAX_SUMMARIES,
DEFAULT_SNIPPET_LIMIT,
)
from src.mcp_server.rate_limit import IPRateLimitMiddleware
from src.mcp_server.retrieval import fetch as fetch_corpus
from src.mcp_server.retrieval import search as search_corpus
mcp = FastMCP(
name="learningfocused",
instructions=(
"Future of Education knowledge base (podcast, Substack, unique YouTube). "
"Use search for semantic retrieval and fetch for a known episode_id, "
"doc_id, or YouTube video_id. Podcast episodes have no public transcript site; "
"fetch(episode_id) returns every transcript segment in time order. "
"Substack and YouTube hits include canonical_url for the original page. "
"Default snippets are 2560 characters (~25% of a typical article). "
"Pass snippet_limit=0 to return the full stored segment or article "
"(capped at 32768 characters). Truncated hits include omitted_chars. "
"No graph/Cypher tools."
),
)
@mcp.tool(
name="search",
annotations=ToolAnnotations(
title="Search knowledge base",
readOnlyHint=True,
idempotentHint=True,
openWorldHint=False,
),
)
def search(
query: str,
max_segments: int = DEFAULT_MAX_SEGMENTS,
max_summaries: int = DEFAULT_MAX_SUMMARIES,
snippet_limit: int = DEFAULT_SNIPPET_LIMIT,
) -> SearchResults:
"""Search podcast transcripts, Substack articles, and unique YouTube videos.
Args:
query: Natural-language search query.
max_segments: Max transcript/article segments (clamped).
max_summaries: Max summaries (clamped).
snippet_limit: Max characters per snippet. Default 2560. Pass 0 for the
full stored chunk (segment or article), up to 32768 characters.
Truncated results include omitted_chars and a retry hint.
"""
return search_corpus(
query,
max_segments=max_segments,
max_summaries=max_summaries,
snippet_limit=snippet_limit,
)
@mcp.tool(
name="fetch",
annotations=ToolAnnotations(
title="Fetch source by id",
readOnlyHint=True,
idempotentHint=True,
openWorldHint=False,
),
)
def fetch(
id: str,
max_segments: int = DEFAULT_MAX_SEGMENTS,
max_summaries: int = DEFAULT_MAX_SUMMARIES,
snippet_limit: int = DEFAULT_SNIPPET_LIMIT,
) -> SearchResults:
"""Fetch a known source. Podcast episodes return the full transcript.
There is no public Art19 transcript page. fetch(episode_id) returns every
transcript_segment in time order (not a similarity search). Pass
snippet_limit=0 for the full text of each segment. Substack articles and
YouTube videos include canonical_url pointing at the original page.
Args:
id: Episode id (e.g. S2E335), Substack doc_id, or YouTube video_id.
max_segments: Unused for episode fetch (all segments are returned, up
to 40). Still used for unknown ids that fall back to search.
max_summaries: Max summaries (clamped).
snippet_limit: Max characters per snippet. Default 2560. Pass 0 for the
full stored chunk (segment or article), up to 32768 characters.
Truncated results include omitted_chars and a retry hint.
"""
return fetch_corpus(
id,
max_segments=max_segments,
max_summaries=max_summaries,
snippet_limit=snippet_limit,
)
@mcp.custom_route("/health", methods=["GET"])
async def health_check(_request: Request) -> JSONResponse:
return JSONResponse({"status": "ok", "service": "learningfocused-mcp"})
def http_middleware() -> list[Middleware]:
return [Middleware(IPRateLimitMiddleware)]
def build_http_app():
return mcp.http_app(
path="/mcp",
stateless_http=True,
middleware=http_middleware(),
)
def run() -> None:
host = os.getenv("MCP_HOST", "127.0.0.1")
port = int(os.getenv("PORT") or os.getenv("MCP_PORT") or "8000")
mcp.run(
transport="http",
host=host,
port=port,
path="/mcp",
stateless_http=True,
middleware=http_middleware(),
)