Home / Articles

Seventeen Small MCP Tools Instead of One Giant Agent: An AI Writing System

How a research-and-writing system splits video ingestion, search, drafting, critique and code validation into bounded MCP tools, with artifacts and tracing.

Publisher: REACTAPP.TOP13 min read2957 words

This article is published in English.

Sources: Why I Built MaxyAI as 17 Small MCP Tools Instead of One Giant Agent

The hard part of an AI writing agent is not making a language model write. It's deciding what the model should not be responsible for. Early attempts at such agents tend to collapse into one architecture: hand a single model a topic, attach a search tool, and ask it to research, reason, write, validate and recover from errors in one long run. Prompts grow, traces become hard to debug, and deterministic failures get tangled up with semantic decisions. MaxyAI, an AI research-and-writing system, started from another idea: let every capability that has distinct inputs, failure modes, retry rules or a reusable output become its own bounded tool behind a single MCP server.

Inputs are rarely tidy. They might be a YouTube lecture, a GitHub repository, a docs page and a handful of reference links. MaxyAI takes all of it in, broadens the research with web search, stores the expanding corpus on disk and shapes it into an outline. It then drafts section by section, passes the draft to a numeric critic, pulls out every Python block, validates and runs the code, fixes syntax failures, and exports one standalone HTML article.

There is no giant prompt behind this. The system is a FastMCP server with 17 registered tools, 12 of them for research. The tools are small on purpose. One transcribes video, one understands repositories, one searches the web, one builds an outline, one critiques the article and one validates code. The model decides which capability to call, and the Python behind each tool does the deterministic work.

That split is the most useful thing about the design. The model can change without touching YouTube ingestion. The crawler can be swapped without disturbing the writer. The code-verification phase can be debugged without rerunning research. And since every important step leaves an artifact or a trace, a long autonomous run can be inspected afterwards instead of read as one enormous chat transcript.

The number 17 was never a target. It came from a simple rule: a capability gets its own bounded tool or handler when it talks to a different external system, produces a reusable artifact, needs separate retry behaviour, or benefits from deterministic validation. MCP then gives all those specialists a shared interface.

Why not one large tool? Because the system is solving several distinct engineering problems rather than a single reasoning problem. These jobs (video ingestion, repo parsing, web discovery, state handling, drafting, critique, running code, repair, publishing) each have their own inputs, costs, failure modes and retry needs. One mega-tool would shorten the prompt but make the system harder to observe and extend.

None of this argues that every agent needs 17 tools. It's a case study in decomposition: where semantic judgment belongs to a model, where ordinary code should stay in charge, how artifacts can stand in for bloated conversational state, and why generated code should pass deterministic checks before a reader sees it.

A run from start to finish

Take the topic of LLM function calling and structured outputs, with a starting set of guidelines such as one YouTube talk, one GitHub repository and a few documentation URLs.

Phase 1 produces no prose. Its job is to gather evidence. The MCP server picks out URLs, hands video to Gemini's multimodal API, converts repositories into model-friendly digests via gitingest, crawls pages with Firecrawl, fills gaps by searching Tavily, and tracks the inventory of sources in state files inside .nova/. What comes out is research.md, a big research artifact anyone can open and read, in place of hidden conversational memory.

Phase 2 takes that artifact and plans the publication. generate_article_outline hands out sections, word-count targets and code targets, and draft_article_section then writes a single bounded section per call. Next, critic_evaluate_article grades the complete draft for technical depth, code quality, structure and citation fidelity, and refine_and_save_article applies the revisions it demands before writing article.md.

Phase 3 treats the Markdown as something that has to survive execution and not merely look convincing. Python blocks are pulled out and parsed with ast.parse(). Valid snippets are saved under .nova/sandbox/ and run with a five-second timeout. A syntax failure sends the error and snippet to fix_code_snippets, and the result goes back through validation. Once the code passes, export_publication_bundle produces the standalone HTML document.

Opik tracing spans the whole run. Each model and tool trace exposes latency, provider metadata, the tool hierarchy and intermediate output. Seen as a diagram, the architecture is three phases (research, writer and critic, code QA and publisher) sharing model failover and observability.

The MCP server as backbone

The first design choice was at the protocol level: expose capabilities through Model Context Protocol and don't hard-wire each function into a single application loop.

MCP gives a client a standard vocabulary for discovering and calling tools. FastMCP makes this pleasant in Python, because an ordinary typed function becomes an MCP tool through a decorator, and its signature turns into part of the tool's contract. The implementation can remain plain Python even when the client is an agent.

In the project, mcp_server/src/routers/tools.py is the entry point to the tool layer. A simplified registration looks like this:

import opik
from fastmcp import FastMCP

from ..tools import run_web_research_tool

def register_mcp_tools(mcp: FastMCP) -> None:
    @mcp.tool(name="run_web_research")
    @opik.track(type="tool", tags=["research", "tavily"])
    async def run_web_research(
        queries: list[str],
        research_directory: str,
    ) -> dict:
        return await run_web_research_tool(
            queries=queries,
            research_directory=research_directory,
        )

The decorator does more than sweeten syntax. The client sees a named tool with typed arguments, while the implementation stays in the tool and handler layer. That separation matters once the server grows past a handful of capabilities.

A single do_everything(topic) tool was deliberately avoided, since it would reintroduce the coupling the design set out to remove. Smaller tools let the model compose behaviour: extract sources, check what's missing, search again, outline, draft, critique, repair. They also make traces much easier to read, because each span corresponds to one meaningful action.

So the 17-tool server isn't 17 prompts. It's a set of bounded capabilities. Research gets the largest share, 12 tools, since source acquisition is where the workflow needs the most variety.

Research: different sources, different tools

Research absorbed the most design effort, since "search the web" hides several operations. A video, a GitHub repo and a JavaScript-heavy docs site raise three different data problems, and running them through one scraper gives the writer poor context from the start.

Gemini for YouTube

Solving long-form video locally is expensive: download the audio, run speech-to-text, manage GPU memory, then summarise the transcript. The youtube_handler.py module takes a simpler route. It passes the public YouTube URL straight to Gemini as multimodal input and asks for timestamped, structured Markdown.

The reason for choosing Gemini there isn't that it's the best model for the whole project. It's that this capability fits the job: native multimodal ingestion means the server can handle video without a local Whisper stack or a GPU on the client machine.

Provider server errors are handled by the same module with exponential retries, keeping transient model-side failures inside the tool so the outer agent needn't understand networking details.

@retry(
    retry=retry_if_exception_type(errors.ServerError),
    wait=wait_exponential(multiplier=1, min=5, max=60),
    stop=stop_after_attempt(3),
)
async def transcribe_youtube(url: str, output_path: Path, timestamp: int = 30):
    client = track_genai_client(
        genai.Client(api_key=settings.google_api_key.get_secret_value())
    )

    parts = [
        types.Part(file_data=types.FileData(file_uri=url)),
        types.Part(text=PROMPT_YOUTUBE_TRANSCRIPTION.format(timestamp=timestamp)),
    ]

    response = await client.aio.models.generate_content(
        model=settings.youtube_transcription_model,
        contents=parts,
    )

    output_path.write_text(response.text, encoding="utf-8")

In one reported run, a 39-minute lecture became structured notes in 17.8 seconds with no client-side GPU memory used. That's an observed run and not a universal benchmark, since provider load and network conditions can change it. The recorded trace showed the model identifier gemini-3.7-flash and the persisted .nova/guidelines_filenames.json state artifact.

gitingest for repositories

A repository isn't a web page. The model needs the file structure and the actual source, not GitHub's navigation chrome, so the GitHub handler uses gitingest to produce an LLM-friendly digest of the repository.

AST validation is a separate matter. gitingest only ingests, supplying the research model with a directory map and file contents, whereas Python's AST parser comes in much later when the publishing pipeline checks generated code. Blending the two would cloud the architecture.

Keeping them apart also eases extension. A GitLab or local-folder ingester later would just need to emit the same research artifact, because the writer is indifferent to where code originated.

Tavily and Firecrawl together

The two solve different parts of web research. Tavily helps with discovery, turning "what am I still missing?" into a set of targeted queries. Firecrawl helps once the server has picked a specific page and wants its content cleanly extracted.

The project exploits this difference to make research iterative. Ingesting the initial references isn't the end. The agent looks over the corpus, writes fresh queries for what's still missing, runs them, adds what comes back, and may loop up to three rounds.

The design is deliberately asymmetric: the LLM decides what is missing, and Tavily and Firecrawl do the retrieval. The same pattern recurs throughout, with the model used where semantic judgment pays off and ordinary code and services used where deterministic work is better. Firecrawl activity from a research run shows it crawling heterogeneous technical sources before drafting starts.

Files in .nova/ rather than one huge conversation

Research can easily produce tens of thousands of tokens, and carrying every scraped page and intermediate decision into each later tool call would be wasteful.

Instead the system writes small control files under .nova/, such as guidelines_filenames.json and next_queries.json, and keeps large content in artifacts like research.md. The model receives only the piece needed for the current operation.

That's less magical than "agent memory" but far easier to inspect. When a run stops midway, the state directory shows which references were gathered or which search round was due next. In effect the filesystem serves as a rough but workable agreement between phases.

The writer is a pipeline

Once research.md exists, the obvious temptation is to send the entire file to a large model and request a 2,000-word article. The project avoided that from the start, because output limits and long-context attention make it hard to control structure, code coverage and citations all at once.

The writing phase instead splits the job across four tools:

  • generate_article_outline converts the research corpus to a list of sections with word budgets and code targets.
  • draft_article_section writes bounded sections in place of one huge completion.
  • critic_evaluate_article assesses the assembled draft against a structured 0 to 100 rubric.
  • refine_and_save_article carries out the required revisions, creates the table of contents, checks citation structure and saves article.md.

What matters is that the critic doesn't merely say "looks good". Its output is structured feedback another tool can act on:

{
 "overall_score": 85,
 "status": "APPROVED",
 "category_scores": {
 "technical_depth": 27,
 "code_quality": 26,
 "structure_clarity": 17,
 "citation_fidelity": 15
 },
 "specific_critiques": [
 "Section 3.2 mentions timeout handling but omits the asyncio.wait_for example.",
 "Citation [14] is cited twice with conflicting author names."
 ],
 "required_revisions": [
 "Add the timeout example.",
 "Deduplicate bibliography reference [14]."
 ]
}

This pattern narrows the model's role each time. The outline model decides structure, the drafter synthesises one section, the critic hunts for defects and the refinement step applies a checklist. No single model is asked to perform all four jobs within one completion.

The 85 out of 100 score isn't a scientific quality metric. It's a workflow control signal, and the valuable part is the machine-readable critique and revision list.

A trace of a real writer chain shows a structured prompt, a chat-model call and a Pydantic parser, the sort of record that makes an autonomous writing pipeline understandable after the fact.

Code as an executable artifact

A technical article can be well written and still fail readers if its code doesn't run. That's why a third phase exists at all.

The builder opens article.md, pulls out its fenced Python, validates each block with ast.parse(), saves verified snippets into .nova/sandbox/, runs them as a child process while capturing stdout and stderr, and lets failures take another pass through an LLM repair tool.

Static parsing and execution catch different problems. ast.parse() is cheap and rejects invalid Python at once. The subprocess catches runtime errors that syntax alone can't see.

def validate_python_syntax(code: str):
 try:
 ast.parse(code)
 return True, None
 except SyntaxError as exc:
 return False, f"SyntaxError at line {exc.lineno}: {exc.msg}"
result = subprocess.run(
 [sys.executable, str(file_path)],
 capture_output=True,
 text=True,
 timeout=5,
 env={"PYTHONUNBUFFERED": "1"},
)

A failed snippet is repaired and sent back through validation before the article is compiled. In one case a generated article contained pip install google-genai inside a Python block. It looked plausible in Markdown, and Python rejected it immediately. fix_code_snippets received the traceback and the broken block, rewrote the snippet and returned it for validation.

# Broken
pip install google-genai
from google import genai

# Repaired
# Requires: pip install google-genai
import os
from google import genai

client = genai.Client(
    api_key=os.environ.get("GEMINI_API_KEY", "mock-key")
)

The repair loop is kept local on purpose. The model isn't allowed to rewrite the whole article because one snippet failed. It gets the smallest useful unit, the code and its error, and the deterministic checks stay the gate.

Put simply: article.md is scanned for fenced Python, each snippet is parsed, materialised under .nova/sandbox/ and executed in a bounded child process. On failure, the code and traceback are packaged for fix_code_snippets, and the proposed repair travels the same validation path again. The model never approves its own repair. The LLM proposes, and ast.parse() plus execution decide. Only when the loop closes does export_publication_bundle turn the reviewed artifact into the standalone HTML output.

One boundary deserves emphasis. The execution layer is a guarded subprocess runner and not a hardened security sandbox. A timeout and a child process serve article QA well, catching syntax errors, runtime failures and hangs. Truly untrusted code would need stronger isolation, such as containers, namespaces and seccomp, microVMs or a dedicated sandbox service, with filesystem, network, syscall, CPU and memory limits.

Two shared layers: model routing and tracing

Three phases form the visible architecture, yet two shared pieces count just as much in daily use.

Quota-aware model routing

One research run may issue a lot of model calls, and it's easy to exhaust free-tier quotas. To avoid provider-specific initialisation in each handler, model creation is centralised in llm_utils.py, with provider and API-key configuration held in settings.

The project grew up around a cascade of Gemini Flash, Gemini Flash Lite and Groq-hosted Llama. Those particular models aren't permanent, and that's beside the point. Every tool talks to a model abstraction, with no provider details baked into the research and writing code.

Experiments get cheaper too, because moving a task onto a cheaper or currently available model leaves the MCP tool contract alone.

def get_chat_model(model_id: str, schema=None):
    selected = settings.llm_configs[model_id]
    identifier = selected["identifier"]
    params = selected.get("params", {}).copy()

    # resolve provider-specific API key
    ...

    model = init_chat_model(identifier, **params)
    return track_langchain_model(model)

Opik makes long runs inspectable

Logs lose their usefulness once an MCP server calls a model that calls a tool that writes an artifact that triggers another model. Instrumenting the model clients and MCP tools with Opik tracing displays that hierarchy directly, with no need to piece it together from print statements.

The value is practical. A slow Firecrawl step can be compared with a model call, the model that actually answered can be identified, the payload a parser received can be seen, and the path that produced a bad artifact can be replayed. The trace screenshots are the development interface for understanding the workflow, not decoration.

What the project teaches about agent design

The ability to generate an article is not what makes MaxyAI interesting, since one frontier model can already turn a prompt into a decent one. What stands out is how the work is broken up. Ingesting video, digesting a repository, searching, outlining, drafting, critiquing, extracting code, validating, repairing and exporting are each separate tools. MCP provides them a shared boundary, artifacts on disk let phases pass large state around without forcing it into chat memory, and Opik leaves the whole trajectory visible.

Extension points are easy to spot. A PDF ingester gives Phase 1 another kind of source. Swapping Tavily out doesn't affect the writer. JavaScript validation can be added to Phase 3 while the research engine stays untouched. Changing model providers leaves the MCP surface as is.

Hardening for production would start with stronger sandbox isolation, then a fixed evaluation suite covering research, citations and code quality, clearer provider-capability metadata and durable run checkpoints. Even today, the project makes the point that a model may pick the next action while the system's quality depends on the tools, boundaries, artifacts and feedback loops surrounding it.

Three engineering lessons carry over to other agent systems:

  1. Tool boundaries beat tool counts. Give a capability its own boundary if it reaches a different system, fails or retries differently, or yields an artifact someone will reuse.
  2. Models propose, deterministic checks decide, whenever that's possible. An LLM may fix a snippet, but parsing and execution make the final call.
  3. State you can inspect is better than vague "memory". Saved research, control files and traces made long runs debuggable and allowed single phases to be rerun without rebuilding a giant chat history.