Agentic AI Engineering

Lesson 24: Human-in-the-Loop Through MCP Servers

In the previous lesson, we used the evaluator-optimizer pattern to automatically refine articles against the writing guidelines. While these automated loops are powerful, writing is inherently subjective. Even the most sophisticated AI models hallucinate and miss the nuance of your personal voice or specific instructions in your input. This escalates further in our use case, where it must follow 100+ instructions simultaneously. The reality is that AI is not yet capable of producing perfect results. But that’s fine. Its goal is not to replace you, but to automate your repetitive tasks, allowing you to focus on what makes the final output special and creative.

That’s why, to build a practical writing assistant, we cannot rely solely on automation. We need a way to intervene, review, and steer the AI. The perfect balance involves using AI to generate and automate the heavy lifting, while you, the domain expert, review and refine the output.

In this lesson, we will finalize the Brown capstone project by adding this essential human element. We will cover:

  • The AI generation / human validation loop and why it is essential for practical AI applications.
  • How to introduce human feedback into the article reviewer node.
  • Implementing two new editing workflows: edit_article and edit_selected_text.
  • Serving Brown as an MCP server with tools, prompts, and resources.
  • Integrating Brown with MCP clients like Cursor for a coding-like writing experience.

Understanding the AI Generation Human Validation Loop

AI systems are imperfect. LLMs can display “jagged intelligence,” performing brilliantly on complex reasoning tasks while failing at simple instructions that no human would miss. They can hallucinate facts or misinterpret tone. Therefore, we cannot simply delegate thinking, creating, and planning entirely to the AI.

To build reliable applications, we must design a cooperative workflow in which the human handles planning and high-level thinking, while the AI handles execution.

We call this the AI Generation / Human Validation Loop :

  1. Generation: The AI generates a draft or performs an action.
  2. Validation: A human reviews the output, validates correctness, manually edits parts of it if necessary, and provides feedback.
  3. Iteration: The loop repeats until the human is satisfied.

Image 1:Image 1: The AI Generation - Human Validation Loop

Andrej Karpathy, former Director of AI at Tesla, emphasizes two key strategies to make this loop effective [1]:

Speeding Up Verification

The bottleneck in this loop is often the human’s ability to verify the AI’s work. To expedite this process, we require application-specific interfaces that utilize our visual processing capabilities. Reading raw text output is slow and cognitively demanding. It forces the brain to parse syntax and semantics linearly.

Instead, presenting changes as visual “diffs”, like red and green lines in code editors, allows humans to audit changes instantly. This visual representation acts as a highway to the brain, bypassing the slow process of reading. We should also enable direct actions. Users should be able to accept or reject changes with a single click or keyboard shortcut, rather than typing out new instructions in a chat box. This reduces friction and keeps the verification loop spinning fast.

We saw this issue in the previous lesson when we tried to understand how the evaluator’s reviews were applied to the new article. The process was cumbersome. That’s why, in this lesson, we use MCP clients like Cursor to automatically diff the old and improved articles.

Keeping the AI on a Leash by Avoiding “Overreaction”

We must avoid “overreaction” from the AI. Receiving a 10,000-line code refactor from an agent is not helpful because verifying it takes longer than writing it yourself. The AI might introduce subtle bugs or security issues that are buried deep within the massive output.

To keep the loop tight, we should constrain the AI to work in small, incremental chunks, focusing on a single, concrete task at a time. We also need to use concrete prompts to increase the probability of a successful generation. Vague prompts lead to vague outputs that fail verification and force the user to restart the loop. By using auditable intermediate artifacts, such as a course syllabus before a full course, we constrain the AI and prevent it from getting “lost in the woods.”

Again, we encountered this issue in our use case when editing the entire article. When fully automated, that can be useful, but when we introduce a human-in-the-loop, we want to improve only the scoped parts of the article. Here is where the selected text logic, which we began implementing in the previous lesson, comes in.

The “Keeping the AI on a Leash” concept directly connects to the “autonomy slider” we discussed in the “Workflows vs. Agents” lesson at the beginning of the course. As you increase an agent’s autonomy, you decrease human control and reliability. Your job as an AI engineer is to determine how much autonomy to give your AI app, which directly translates into a trade-off between more or less control when introducing a human-in-the-loop.

Image 2:Image 2: A diagram illustrating Andrej Karpathy's "autonomy slider" concept, showing the inverse relationship between an AI agent's level of control and application reliability.

To conclude with a fun analogy from Karpathy. He suggests building products that are “less Iron Man robots and more Iron Man suits.” We aim to augment humans with a rapid generation-verification loop rather than replace them with a fully autonomous agent [1]. In a sentence, that’s our moat behind Nova and Brown.

Now, let’s understand how we can apply these principles to Brown.

Adding Human-In-The-Loop In Our Writing Workflow

We have the generation step from Lessons 22 and 23, but we lack a mechanism for the human to intervene after the initial draft is generated. We need a way, after the article is generated, to allow humans to review it, validate it, and provide further instructions on how to improve or modify it.

Writing content is incredibly similar to writing code. Both processes involve drafting, reviewing, refactoring, and polishing. As engineers, we can adopt the “coding-like” experience found in AI IDEs like Cursor. In these tools, you highlight a section of code, provide instructions (e.g., “refactor this to use async”), and the AI returns a diff of the changes. We want this exact experience for writing articles. We want to highlight a paragraph, ask for a tone shift, and see the changes applied instantly.

To achieve this, we implemented two new editing workflows that apply the evaluator-optimizer pattern with human feedback:

  1. Edit Article Workflow: Reviews and edits the entire article based on human feedback.
  2. Edit Selected Text Workflow: Reviews and edits only a specific portion of the text (still based on human feedback).

We instructed our evaluator-optimizer loop to prioritize human feedback over the static profile guidelines. This ensures that if you explicitly request a change, the AI respects your decision, even if it contradicts a general rule.

As shown in Image 3, the editing workflow applies the reviewing and editing step only once. We completely trimmed down the loop from the original article generation workflow. Because, as we presented in the previous section, at this point, the loop is naturally created between you and the AI, not between two LLMs.

Image 3:Image 3: Brown’s new edit article and selected text workflows, which work in parallel with the article generation workflow presented in previous lessons.

To anchor how these new editing workflows relate to the article generation workflow implemented in the previous lesson, refer to image 4. The main idea is that we reuse the same article reviewer and writer nodes and static inputs, such as the profiles and examples. They are merely combined into a different format at the application level.

Image 4:Image 4: The complete article generation workflow, with the orchestrator-worker and evaluator-optimizer patterns.

To be more hands-on, to enable this flexible approach between generating the first draft and further editing the article, we decoupled Brown into three independent workflows:

  • generate_article
  • edit_article
  • edit_selected_text

Also, to expose these workflows to clients such as Cursor or Claude, we served the entire Brown application as an MCP server that wraps each workflow as an MCP tool.

This architecture enables you to generate a draft, review it, and then apply editing workflows selectively until you are satisfied. By exposing these as independent tools, we are not tied to a single monolithic script.

Image 2: A flowchart illustrating the Brown writing workflow with human-in-the-loop, showing three independent MCP tools and the human feedback loop.Image 5: Flowchart illustrating the human in the loop flow leveraging the new edit tools.

We specifically added the edit_selected_text workflow to avoid “overreaction.” Editing the whole article when you only want to change one paragraph is inefficient and risky. The AI might accidentally change sections you liked. By focusing on selected text, we keep the AI on a leash.

💡To see how the design presented in this lesson relates to Brown's overall architecture, open the whiteboard below.

Live Whiteboard: The End-To-End Architecture of Brown: The Writing Workflow. Click here to expand.

Now, let’s get into the implementation. We will start by introducing the human feedback logic into the entities and nodes. Then we will implement the two new editing LangGraph workflows and ultimately serve everything as an MCP server.

Introducing Human Feedback Into the Article Reviewer

Let’s start by looking at how we modified our ArticleReviewer node to accept human feedback.

class HumanFeedback(BaseModel, ContextMixin):
    content: str
 
    def to_context(self) -> str:
        return f"""
<{self.xml_tag}>
    {self.content}
</{self.xml_tag}>
"""
  1. Next, we updated the ArticleReviewer class to accept an optional human_feedback parameter during initialization. This enables the reviewer to operate with or without human input, ensuring backward compatibility with our automated workflows.
class ArticleReviewer(Node):
		system_prompt_template = """..."""
		
    def __init__(
        self,
        to_review: Article | SelectedText,
        article_guideline: ArticleGuideline,
        model: Runnable,
        article_profiles: ArticleProfiles,
        human_feedback: HumanFeedback | None = None,
    ) -> None:
        self.to_review = to_review
        self.article_guideline = article_guideline
        self.article_profiles = article_profiles
        self.human_feedback = human_feedback
 
        super().__init__(model, toolkit=Toolkit(tools=[]))
  1. We updated the system prompt to include explicit instructions for handling this feedback. We instruct the LLM to ignore it if it’s empty. But if it’s present, we want to decompose it across one or more human feedback reviews. The idea behind this design choice is that for every independent piece of human feedback, we want to create a distinct Review object that is addressed independently. For example, if we get the human feedback “Make the introduction more engaging and catchy. Also, expand on the definition of both workflows and agents from the first section,” we expect two Review objects from the article reviewer. One that addresses the introduction and another one that addresses the issue from the first section. __ Also, note how we reduced the human feedback to the same Review interface, as with the other types of reviews, but with profile="human_feedback". By doing so, we can easily leverage all the infrastructure we used for the evaluator-optimizer pattern.
system_prompt_template = """
You are Brown, an expert article writer...

## Human Feedback

Along with the expected requirements, a human has already reviewed the article and provided the following feedback:

{human_feedback}

If empty, completely ignore it; otherwise the feedback will ALWAYS be used in two ways:
1. First you will use the <human_feedback> to guide your reviewing process again the requirements. This will help you understand 
on what rules to focus on as this directly highlights what the user wants to improve.
2. Secondly you will extract one or more action points based on the <human_feedback>. Depending on how many ideas, topics or suggestions 
the <human_feedback> contains you will generate from 1 to N action points. Each <human_feedback> review will contain a single action point. 
3. As long the <human_feedback> is not empty, you will always return at least 1 action point, but you will return more action points 
if the feedback touches multiple ideas. 

Here is an example of a reviewed based on the human feedback:
<example_of_human_feedback_action_point>
Review(
    profile="human_feedback",
    location="Article level",
    comment="Add all the points from the article guideline to the article."
)
</example_of_human_feedback_action_point>

...
"""
  1. In the ainvoke method, we inject the feedback into the prompt template. If no feedback exists, we inject an empty string.
async def ainvoke(self) -> ArticleReviews | SelectedTextReviews:
    system_prompt = self.system_prompt_template.format(
        human_feedback=self.human_feedback.to_context() if self.human_feedback else "",
        article=self.article.to_context(),
# ... other context
    )
  1. Let’s see this in action. We load our sample article and profiles, create a HumanFeedback object with specific instructions, and run the reviewer.
from brown.entities.reviews import HumanFeedback
from brown.nodes.article_reviewer import ArticleReviewer
 
# ... Load article, guideline, and profiles ...
 
human_feedback = HumanFeedback(
    content="""Make the introduction more engaging and catchy.
Also, expand on the definition of both workflows and agents from the first section"""
)
 
article_reviewer = ArticleReviewer(
    to_review=article,
    article_guideline=article_guideline,
    article_profiles=profiles,
    human_feedback=human_feedback,
    model=model,
)
 
reviews = await article_reviewer.ainvoke()

When we examine the output, we see that the reviewer creates specific reviews with profile= “human_feedback” that directly addresses our request, alongside the standard profile-based reviews for structure and mechanics. Exactly what we wanted!

--------------------------- All Reviews Summary ---------------------------------------
Generated reviews from 7 different profile types: 
- article_guideline
- article_profile
- mechanics_profile
- structure_profile
- terminology_profile
- tonality_profile
- human_feedback
----------------------------------------------------------------------------------------------------

ARTICLE_GUIDELINE: 7 reviews
  1. [Introduction: The Critical Decision Every AI Engineer Faces - Article level] The introduction's length is 123 words, exceeding the guideline of 100 words....
  2. [Understanding the Spectrum: From Workflows to Agents - Second paragraph] The article states 'We will look at their core properties and how they are used, rather than their t...

ARTICLE_PROFILE: 3 reviews
  1. [Introduction: The Critical Decision Every AI Engineer Faces - First paragraph] The introduction, while stating the problem, is not as engaging and captivating as it could be, as h...
  2. [Introduction: The Critical Decision Every AI Engineer Faces - First paragraph] The introduction is primarily focused on the 'why' (problem) and then states 'By the end of this les...

HUMAN_FEEDBACK: 2 reviews
  1. [Article level] Make the introduction more engaging and catchy....
  2. [Understanding the Spectrum: From Workflows to Agents - First section] Expand on the definition of both workflows and agents from the first section....

Now, let’s integrate this into the editing workflows, starting with the one for editing the whole article.

Implementing the Article Editing Workflow

This workflow orchestrates the review and editing process for the entire article based on your feedback. You might think this violates the “overreaction” principle, and it does. However, based on our tests, this is still necessary if the initial number of review/editing iterations used to generate the article is insufficient. Thus, this is useful when you don’t want to check the final output too carefully, but you realize it still needs more refinement at the global level before starting to edit more targeted parts of the article.

As we have said throughout the course. You should always question “the book” and apply only what makes sense for your app. In our use case, that means the next workflow, which edits only a specific part of the text.

Image 6:Image 6: The Brown project structure.

Because of our clean architecture, we can easily reuse all entities and nodes from the domain layer and recompose them into different business logic in the app layer. In other words, we will use LangGraph to orchestrate the same components into different workflows, such as gluing the ArticleWriter and ArticleReviewer into a simple editing workflow. In software, this is known as building orthogonal components that make sense on their own, and you can reuse them as LEGO blocks. This pattern is extremely popular in frameworks like PyTorch, which provide the building blocks you can use to build whatever you want.

Now, back to our implementation.

  1. We follow the exact same pattern as we did when building the article generation workflow. Thus, we will expand only on what’s new here. We use LangGraph’s Functional API to orchestrate the workflow. The build_edit_article_workflow function acts as a factory, creating an entry point with a checkpointer for state persistence of its short-term memory.
def build_edit_article_workflow(checkpointer: BaseCheckpointSaver):
    """Create an edit article workflow with checkpointer.
 
    Args:
        checkpointer: Checkpointer to use for workflow persistence.
 
    Returns:
        Configured workflow entrypoint
    """
 
    return entrypoint(checkpointer=checkpointer)(_edit_article_workflow)

The input definition is a TypedDict with the input directory and human feedback:

class EditArticleInput(TypedDict):
    dir_path: Path
    human_feedback: str
  1. The main workflow function, _edit_article_workflow, manages the sequence. It accepts the EditArticleInput along with the standard LangChain config. Within it, we first initialize the stream writer and load the context.
async def _edit_article_workflow(inputs: EditArticleInput, config: RunnableConfig) -> str:
    writer = get_stream_writer()
		
		# Progress: Loading context
    writer(WorkflowProgress(progress=0, message="Loading context").model_dump(mode="json"))
    context = {}
    loaders = build_loaders(app_config)
    for context_name, loader in loaders.items():
        loader = cast(Loader, loader)
        context[context_name] = loader.load(working_uri=inputs["dir_path"])
 
    human_feedback = HumanFeedback(content=inputs["human_feedback"])
    writer(WorkflowProgress(progress=5, message="Loaded context").model_dump(mode="json"))
  1. Next, it runs the review and editing steps. We use the generate_reviews task to get feedback and then pass that to edit_based_on_reviews to apply changes (which we will expand soon).
    		# Progress: Reviewing
        writer(WorkflowProgress(progress=20, message="Reviewing article").model_dump(mode="json"))
        reviews = await generate_reviews(context["article"], human_feedback, context["article_guideline"], context["profiles"])
        writer(WorkflowProgress(progress=40, message="Generated reviews").model_dump(mode="json"))
    
    		# Progress: Editing
        writer(WorkflowProgress(progress=60, message="Editing article").model_dump(mode="json"))
        article = await edit_based_on_reviews(
            context["article_guideline"], context["research"], context["profiles"], context["examples"], reviews
        )
        writer(WorkflowProgress(progress=80, message="Edited article").model_dump(mode="json"))
    
    		# Progress: Complete
        writer(WorkflowProgress(progress=100, message="Article editing completed").model_dump(mode="json"))
  1. Finally, it returns the edited article along with specific instructions for the workflow consumer, such as the MCP client (like Cursor). These instructions are critical because they tell the client how to interpret the results. For example, the always apply the changes to the source... bullet point is the prompt engineering magic that tells the MCP client to do a diff between the old and new article. This works because this workflow is always called relative to the source file. Thus, the workflow consumer has in context the concept of the old and new article.
    return f"""
Here is the edited article:
{article.to_context()}

Here is what you have to do with the edited article:
- print the edited article to the console for the user to see
- give a quick summary of the changes you made
- always apply the changes to the source file without waiting for the user to explicitly ask you to do so
- in case you haven't changed anything, just say that you haven't changed anything
"""

For example, this is how the “diff” logic looks when using Brown within Cursor with human_feedback= “make sure the article adheres to the article_guideline 100%”(We will show how this works at the end of the lesson.)

Image 7:Image 7: A “diff” in Cursor after calling the edit article workflow through MCP from Cursor.

As you can see, we can fully leverage all Cursor’s built-in features.

  1. The generate_reviews task wraps the ArticleReviewer. It uses the @task decorator for checkpointing and retries. Note that it specifically uses the “review_article” configuration from our app config. This allows us to tune the model parameters specifically for reviewing, separate from generation.
@task(retry_policy=retry_policy)
async def generate_reviews(
    article: Article,
    human_feedback: HumanFeedback,
    article_guideline: ArticleGuideline,
    article_profiles: ArticleProfiles,
) -> ArticleReviews:
    model, _ = build_model(app_config, node="review_article")
    article_reviewer = ArticleReviewer(
        to_review=article,
        article_guideline=article_guideline,
        article_profiles=article_profiles,
        human_feedback=human_feedback,
        model=model,
    )
    reviews = await article_reviewer.ainvoke()
 
    return cast(ArticleReviews, reviews)
  1. The edit_based_on_reviews task wraps the ArticleWriter. Importantly, when we provide reviews to the ArticleWriter, it switches to “editing mode.” It uses the reviews to guide changes rather than writing from scratch. This reuse of the ArticleWriter node demonstrates the power of our modular design.
@task(retry_policy=retry_policy)
async def edit_based_on_reviews(
    article_guideline: ArticleGuideline,
    research: Research,
    article_profiles: ArticleProfiles,
    article_examples: ArticleExamples,
    reviews: ArticleReviews,
) -> Article:
    model, _ = build_model(app_config, node="edit_article")
    article_writer = ArticleWriter(
        article_guideline=article_guideline,
        research=research,
        article_profiles=article_profiles,
        media_items=MediaItems.build(),
        article_examples=article_examples,
        reviews=reviews,
        model=model,
    )
    article = await article_writer.ainvoke()
 
    return cast(Article, article)
  1. To run this workflow, we initialize it with an in-memory checkpointer and stream the events. The output shows the progression from loading context to generating reviews and finally producing the edited article.
1. Building workflow...
2. Configuring workflow...
   ✓ Thread ID: a8309001-6f52-48a0-b2b7-92f73d5f005e
3. Running workflow...
--------------------------------- Event -------------------------------------
{
"progress": 0,
"message": "Loading context"
}
--------------------------------- Event -------------------------------------
{
"progress": 20,
"message": "Reviewing article"
}
--------------------------------- Event -------------------------------------
{
"progress": 60,
"message": "Editing article"
}
--------------------------------- Event -------------------------------------
{
"progress": 100,
"message": "Article editing completed"
}
--------------------------------- Event -------------------------------------

Here is the edited article:
<article>
	<content>
		# AI Agents vs. LLM Workflows: The Critical Decision Every AI Engineer Faces
		### A practical guide to choosing the right architecture for your AI application
		
		When building AI applications, engineers face a critical architectural decision early on.
		...
	</content>
</article>

Here is what you have to do with the edited article:
- print the edited article to the console for the user to see
- give a quick summary of the changes you made
- always apply the changes to the source file without waiting for the user to explicitly ask you to do so
- in case you haven't changed anything, just say that you haven't changed anything

It outputs:

async with build_in_memory_checkpointer() as checkpointer:
    print("1. Building workflow...\n")
    workflow = build_edit_article_workflow(checkpointer=checkpointer)

    print("2. Configuring workflow...\n")
    thread_id = str(uuid.uuid4())
    config = {"configurable": {"thread_id": thread_id}}
    print(f"   ✓ Thread ID: {thread_id}")

    print("3. Running workflow...")

    async for event in workflow.astream(
        {
            "dir_path": SAMPLE_DIR,
            "human_feedback": """
Make the introduction more engaging, catchy and shorter.
Also, expand on the definition of both workflows and agents from the first section""",
        },
        config=config,
        stream_mode=["custom", "values"],
    ):
        event_type, event_data = event
        if event_type == "custom":
            pretty_print.wrapped(event_data, title="Event")
        elif event_type == "values":
            pretty_print.wrapped(event_data, title="Output")

This workflow shows the power of human feedback. Instead of running 10 automated review/editing loops in hopes of a perfect result, we use fewer review/editing loops in our initial article generation (2 in our use case). Then the user enters and decides whether more is required. This makes the initial generation faster and cheaper. We don’t assume how many iterations are needed. We let you choose when the article is done.

Implementing the Selected Text Editing Workflow

While editing the entire article is useful for broad changes, you often want to refine a specific section without risking mental overload from changes across the entire document.

The edit_selected_text workflow enables this precision. Its structure is nearly identical to edit_article, but it operates on a SelectedText entity instead of a full Article.

  1. We build the workflow using the same factory pattern.
def build_edit_selected_text_workflow(checkpointer: BaseCheckpointSaver):
    """Create an edit selected text workflow with checkpointer.
 
    Args:
        checkpointer: Checkpointer to use for workflow persistence.
 
    Returns:
        Configured workflow entrypoint
    """
 
    return entrypoint(checkpointer=checkpointer)(_edit_selected_text_workflow)

The input EditSelectedTextInput requires the selected_text string and its line numbers (number_line_before_selected_text, number_line_after_selected_text). These line numbers help the AI locate the text within the article’s larger context, ensuring it understands what precedes and follows the selection to avoid editing the text in a vacuum (remember, context is key!):

class EditSelectedTextInput(TypedDict):
    dir_path: Path
    human_feedback: str
    selected_text: str
    number_line_before_selected_text: int
    number_line_after_selected_text: int
  1. The main logic mirrors the article workflow, but creates a SelectedText entity. This entity holds the snippet we want to edit while keeping a reference to the full article for context. Like this, we ensure the selected text is always coupled with the article it originated from.
async def _edit_selected_text_workflow(inputs: EditSelectedTextInput, config: RunnableConfig) -> str:
    writer = get_stream_writer()
 
		# Progress: Loading context
    writer(WorkflowProgress(progress=0, message="Loading context").model_dump(mode="json"))
    context = {}
    loaders = build_loaders(app_config)
    for context_name, loader in loaders.items():
        loader = cast(Loader, loader)
        context[context_name] = loader.load(working_uri=inputs["dir_path"])
 
    selected_text = SelectedText(
        article=context["article"],
        content=inputs["selected_text"],
        first_line_number=inputs["number_line_before_selected_text"],
        last_line_number=inputs["number_line_after_selected_text"],
    )
    human_feedback = HumanFeedback(content=inputs["human_feedback"])
    writer(WorkflowProgress(progress=5, message="Loaded context").model_dump(mode="json"))
  1. We then proceed with the review and edit steps, similar to the article workflow, but operating on the selected_text object.
    		# Progress: Reviewing
        writer(WorkflowProgress(progress=20, message="Reviewing selected text").model_dump(mode="json"))
        reviews = await generate_reviews(selected_text, human_feedback, context["article_guideline"], context["profiles"])
        writer(WorkflowProgress(progress=40, message="Generated reviews").model_dump(mode="json"))
    
    		# Progress: Editing
        writer(WorkflowProgress(progress=60, message="Editing selected text").model_dump(mode="json"))
        selected_text = await edit_based_on_reviews(
            context["article_guideline"], context["research"], context["profiles"], context["examples"], reviews
        )
        writer(WorkflowProgress(progress=80, message="Edited selected text").model_dump(mode="json"))
    
    		# Progress: Complete
        writer(WorkflowProgress(progress=100, message="Selected text editing completed").model_dump(mode="json"))
  1. The returned string is similar to the previous one but is tailored to the selected text, instructing the client to apply changes only to that specific section.
    return f"""
Here is the edited selected text:
{selected_text.to_context()}

Here is what you have to do with edited selected text:
- print the edited selected text to the console for the user to see
- give a quick summary of the changes you made
- always apply the changes to the source file without waiting for the user to explicitly ask you to do so
- in case you haven't changed anything, just say that you haven't changed anything
"""

Here is an example of the “diff” logic when running the edit selected text workflow within Cursor. Note that this time the green bars appear only on a specific section of the article.

Image 8:Image 8: A “diff” in Cursor after calling the edit selected text workflow through MCP from Cursor.

  1. The tasks generate_reviews and edit_based_on_reviews are adapted to handle SelectedText. The ArticleReviewer and ArticleWriter nodes are polymorphic. They behave differently depending on whether they receive an Article or SelectedText. When receiving SelectedText, the reviewer focuses its critique only on that snippet, ignoring the rest of the document.
@task(retry_policy=retry_policy)
async def generate_reviews(
    selected_text: SelectedText,
    human_feedback: HumanFeedback,
    article_guideline: ArticleGuideline,
    article_profiles: ArticleProfiles,
) -> SelectedTextReviews:
    model, _ = build_model(app_config, node="review_selected_text")
    selected_text_reviewer = ArticleReviewer(
        to_review=selected_text,
        human_feedback=human_feedback,
        article_guideline=article_guideline,
        article_profiles=article_profiles,
        model=model,
    )
    reviews = await selected_text_reviewer.ainvoke()
 
    return cast(SelectedTextReviews, reviews)
  1. The ArticleWriter returns a SelectedText object containing only the edited portion. This ensures that the output is strictly limited to what the user requested to change.
@task(retry_policy=retry_policy)
async def edit_based_on_reviews(
    article_guideline: ArticleGuideline,
    research: Research,
    article_profiles: ArticleProfiles,
    article_examples: ArticleExamples,
    reviews: SelectedTextReviews,
) -> SelectedText:
    model, _ = build_model(app_config, node="edit_selected_text")
    article_writer = ArticleWriter(
        article_guideline=article_guideline,
        research=research,
        article_profiles=article_profiles,
        media_items=MediaItems.build(),
        article_examples=article_examples,
        reviews=reviews,
        model=model,
    )
    edited_selected_text = cast(SelectedText, await article_writer.ainvoke())
 
    return edited_selected_text

Why is this important? As we said in the beginning, editing only the selected text prevents “overreaction.” If you want to rewrite a single paragraph, you don’t want the AI to rephrase your introduction or change your conclusion. It allows for faster, cheaper, and safer iterations. You can surgically improve parts of your content without destabilizing the whole.

  1. Running this workflow follows the same pattern. We extract the text we want to edit (e.g., lines 8-42 of our sample), pass it to the workflow along with feedback, and receive the refined snippet (this will be 10x smoother when using Cursor or Claude as the MCP Client):
# Load the article and extract text
article = MarkdownArticleLoader(uri="article.md").load(working_uri=SAMPLE_DIR)
start_line = 8
end_line = 42
selected_text = "\n".join(article.content.split("\n")[start_line:end_line])

# Run the workflowasync with build_in_memory_checkpointer() as checkpointer:
print("1. Building workflow...\n")
workflow = build_edit_selected_text_workflow(checkpointer=checkpointer)

print("2. Configuring workflow...\n")
thread_id = str(uuid.uuid4())
config = {"configurable": {"thread_id": thread_id}}
print(f"   ✓ Thread ID: {thread_id}")

print("3. Running workflow...")
async for event in workflow.astream(
    {
        "dir_path": SAMPLE_DIR,
        "human_feedback": "Expand on the definition of both workflows and agents.",
        "selected_text": selected_text,
        "number_line_before_selected_text": start_line,
        "number_line_after_selected_text": end_line,
    },
    config=config,
    stream_mode=["custom", "values"],
):
    event_type, event_data = event
    if event_type == "custom":
        pretty_print.wrapped(event_data, title="Event")
    elif event_type == "values":
        pretty_print.wrapped(event_data, title="Output")

Based on the input selected text:

To make the right choice, you first need to understand what LLM workflows and AI agents are. We will look at their core properties and how they are used, rather than their technical specifics.

### LLM Workflows

An LLM workflow is a sequence of tasks orchestrated by developer-written code. It can include LLM calls, but also other operations like reading from a database or calling an API. Think of it like a recipe where each step is explicitly defined. The key characteristic is that the path is determined in advance, resulting in a deterministic or rule-based system. This gives you predictable execution, explicit control over the application's flow, and makes the system easier to test and debug. Because you control every step, you know exactly where a failure occurred and how to fix it.

```mermaid
graph TD
    A["Start"] --> B["LLM Call"]
    B --> C["Process Data"]
    C --> D["Store Data"]
    D --> E["End"]

Image 1: A flowchart illustrating a deterministic LLM workflow with clear start and end points, including an LLM call and data operations.

AI Agents

AI agents are systems where an LLM dynamically decides the sequence of steps, reasoning, and actions to achieve a goal. The path is not predefined. Instead, the agent uses a reasoning process to plan its actions based on the task and the current state of its environment. This process is often modeled on frameworks like ReAct (Reason, Act, Observe). This allows agents to be adaptive and capable of handling new or unexpected situations through LLM-driven autonomy. They can select tools, execute actions, evaluate the outcomes, and correct their course until the goal is achieved [1].

graph TD
    A["Start"] --> B["Agent (LLM) Receives Goal"]
    B --> C["Plan/Reason (LLM)"]
...
Image 2: Flowchart illustrating an AI agent's dynamic decision-making process driven by an LLM.
 
## Choosing Your Path

It outputs:

1. Building workflow...

2. Configuring workflow...

   ✓ Thread ID: f281995f-fef6-4e9f-8887-9cedc09c0710
3. Running workflow...
   This will take several minutes...

---------------------------------- Event ------------------------------------
  {
  "progress": 0,
  "message": "Loading context"
}
---------------------------------- Event ------------------------------------
  {
  "progress": 5,
  "message": "Loaded context"
}
---------------------------------- Event ------------------------------------
  {
  "progress": 20,
  "message": "Reviewing selected text"
}
---------------------------------- Event ------------------------------------
  {
  "progress": 40,
  "message": "Generated reviews"
}
---------------------------------- Event ------------------------------------
  {
  "progress": 60,
  "message": "Editing selected text"
}
---------------------------------- Event ------------------------------------
  {
  "progress": 80,
  "message": "Edited selected text"
}
---------------------------------- Event ------------------------------------
  {
  "progress": 100,
  "message": "Selected text editing completed"
}
---------------------------------- Event ------------------------------------
  
Here is the edited selected text:

<selected_text>
    <content>
	    To make the right choice, you first need to understand what LLM workflows and AI agents are. We will look at their core properties and how they are used, rather than their technical specifics.
	
	### LLM Workflows
	
	An LLM workflow is a sequence of tasks orchestrated by developer-written code. It combines LLM calls with other operations like reading from a database or calling an API. Each step is explicitly defined, much like a recipe. The path is predefined, resulting in a deterministic, rule-based system, similar to classic programming.
	
	This offers predictable execution and explicit control over the application's flow. It makes the system easier to test and debug, as you know exactly where a failure occurred and how to fix it.
	
	```mermaid
	graph TD
	    A["Start"] --> B["LLM Call"]
	    B --> C["Process Data"]
	    C --> D["Store Data"]
	    D --> E["End"]
	```
	Image 1: A flowchart illustrating a deterministic LLM workflow with clear start and end points, including an LLM call and data operations.
	
	### AI Agents
	
	AI agents are systems where an LLM dynamically decides the sequence of steps, reasoning, and actions to achieve a goal. The path is not predefined. The agent uses a reasoning process to plan its actions based on the task and environment. This is often modeled on frameworks like ReAct, which cycles through Reason, Act, and Observe.
	
	Agents are adaptive and handle new situations through LLM-driven autonomy. They strategize, break down tasks, and plan steps, acting like an intelligent assistant. They select tools, execute actions, evaluate outcomes, and correct their course until the goal is achieved [[1]](https://www.youtube.com/watch?v=kQxr-uOxw2o&t=1s).
	
	```mermaid
	graph TD
	    A["Start"] --> B["Agent (LLM) Receives Goal"]
	    B --> C["Plan/Reason (LLM)"]
	    C --> D["Select Tool"]
	    D --> E["Execute Action (Tool Call)"]
	    E --> F["Observe Environment/Feedback"]
	    F --> G{"Evaluate Outcome"}
	    G -->|"Satisfactory"| H["Stop/Achieve Goal"]
	    G -->|"Needs Adjustment"| C
	```
	Image 2: Flowchart illustrating an AI agent's dynamic decision-making process driven by an LLM.
	
	## Choosing Your Path
		</content>
    <first_line_number>8</first_line_number>
    <last_line_number>42</last_line_number>
</selected_text>


Here is what you have to do with edited selected text:
- print the edited selected text to the console for the user to see
- give a quick summary of the changes you made
- always apply the changes to the source file without waiting for the user to explicitly ask you to do so
- in case you haven't changed anything, just say that you haven't changed anything

Finally, the last step to make interacting with Brown smooth is to run it as an MCP server.

Serving Brown as an MCP Server

To integrate these workflows into tools like Cursor or Claude Desktop, we serve them using MCP. We implemented the server in the brown.mcp module, keeping the serving layer completely separate from our domain and app logic. Which means all the other modules from brown are not aware of brown.mcp module, while the brown.mcp module imports all the domain, app, and infrastructure components it needs from the rest of the application. This clean separation means we can easily replace MCP with a REST API or a CLI in the future without touching the core workflows [3].

Now, let’s start implementing the MCP server.

Tools

  1. We initialize the MCP server using FastMCP, similar to how we did in the Nova research agent. Similarly, we have a dedicated lesson on MCP. In this lesson, we will expand only on the particularities used in Brown.

We define three tools corresponding to our workflows. Each tool handles the setup of the workflow, including creating a unique thread ID for state management, the checkpointer, and custom handling of the events within the parse_message function.

Another essential aspect is that the tool definition includes type hints and docstrings, which the MCP client uses to determine how to call the tool. The standard practices for defining the tools consumed by tool calling models.

from fastmcp import Context, FastMCP
from langgraph.pregel.main import RunnableConfig
from loguru import logger
 
from brown.builders import build_loaders, build_short_term_memory
from brown.workflows import (
    build_edit_article_workflow,
    build_edit_selected_text_workflow,
    build_generate_article_workflow,
)
from brown.workflows.types import WorkflowProgress
 
 
mcp = FastMCP("Brown Writing Assistant")
 
@mcp.tool()
async def generate_article(dir_path: str, ctx: Context) -> str:
    """Generate an article based on the context from the given directory.
 
    Args:
        dir_path: The path to the directory containing the context.
        ctx: The context to report progress.
    """
 
    async with build_in_memory_checkpointer() as checkpointer:
        workflow = build_generate_article_workflow(checkpointer=checkpointer)
        thread_id = str(uuid.uuid4())
        config = {"configurable": {"thread_id": thread_id}}
 
        async for event in workflow.astream(
            {"dir_path": Path(dir_path)}, config=config, stream_mode=["custom", "values"]
        ):
            parse_message(event, ctx)
 
        return "Article generated successfully."
  1. We also expose the edit_article tool, which takes the article path and human feedback as input.
@mcp.tool()
async def edit_article(article_path: str, human_feedback: str, ctx: Context) -> str:
    """Edit an article based on the human feedback.
 
    Args:
        article_path: The path to the article file.
        human_feedback: The feedback from the human.
        ctx: The context to report progress.
    """
 
    async with build_in_memory_checkpointer() as checkpointer:
        workflow = build_edit_article_workflow(checkpointer=checkpointer)
        thread_id = str(uuid.uuid4())
        config = {"configurable": {"thread_id": thread_id}}
 
        result = None
        async for event in workflow.astream(
            {"dir_path": Path(article_path).parent, "human_feedback": human_feedback},
            config=config,
            stream_mode=["custom", "values"],
        ):
            parsed_result = parse_message(event, ctx)
            if parsed_result:
                result = parsed_result
 
        return result if result else "Article edited successfully."
  1. Finally, the edit_selected_text tool allows for precise edits. It requires the selected text and its line numbers.
@mcp.tool()
async def edit_selected_text(
    article_path: str,
    human_feedback: str,
    selected_text: str,
    first_line_number: int,
    last_line_number: int,
    ctx: Context,
) -> str:
    """Edit a selected text from an article based on the human feedback.
 
    Args:
        article_path: The path to the article file.
        human_feedback: The feedback from the human.
        selected_text: The selected text to edit.
        first_line_number: The first line number of the selected text.
        last_line_number: The last line number of the selected text.
        ctx: The context to report progress.
    """
 
    async with build_in_memory_checkpointer() as checkpointer:
        workflow = build_edit_selected_text_workflow(checkpointer=checkpointer)
        thread_id = str(uuid.uuid4())
        config = {"configurable": {"thread_id": thread_id}}
 
        result = None
        async for event in workflow.astream(
            {
                "dir_path": Path(article_path).parent,
                "human_feedback": human_feedback,
                "selected_text": selected_text,
                "number_line_before_selected_text": first_line_number,
                "number_line_after_selected_text": last_line_number,
            },
            config=config,
            stream_mode=["custom", "values"],
        ):
            parsed_result = parse_message(event, ctx)
            if parsed_result:
                result = parsed_result
 
        return result if result else "Selected text edited successfully."
  1. We use a helper function parse_message to interpret the workflow’s streaming events and report progress back to the MCP client through the ctx FastMCP object. Doing so, the MCP client can further report these events on a UI, ensuring the user sees real-time updates (e.g., “20%: Reviewing article...”, “30%: Editing...") directly in their dashboard. This feedback is essential for long-running tasks, letting the user know the system is working as expected.
def parse_message(message: Any, ctx: Context):
    event_type, event_data = message
 
    if event_type == "custom":
        if isinstance(event_data, dict):
            progress = event_data.get("progress")
            log_message = event_data.get("message")
            if progress is not None:
                ctx.report_progress(progress, total=100)
            if log_message:
                ctx.info(f"[{log_message}]")
        elif isinstance(event_data, str):
            ctx.info(f"[{event_data}]")
    elif event_type == "values":
        return event_data
  1. Let’s see this in action. We can simulate an MCP client in memory to verify our tools work as expected. We create an MCP client connected to our mcp MCP Server instance, load the text we want to edit, and call the edit_selected_text tool.
from fastmcp import Client
 
# Create an in-memory client
mcp_client = Client(mcp)
 
# Load the selected text (same as previous section)
article = MarkdownArticleLoader(uri="article.md").load(working_uri=SAMPLE_DIR)
start_line = 8
end_line = 42
selected_text = "\n".join(article.content.split("\n")[start_line:end_line])
 
# Call the tool
result = await mcp_client.call_tool(
    "edit_selected_text",
    arguments={
        "article_path": str(SAMPLE_DIR / "article.md"),
        "human_feedback": "Expand on the definition of both workflows and agents.",
        "selected_text": selected_text,
        "first_line_number": start_line,
        "last_line_number": end_line,
    },
)
print(result.content[0].text)

The client receives real-time progress logs:

Sending INFO to client: 0%: Loading context
Sending INFO to client: 5%: Loaded context
Sending INFO to client: 20%: Reviewing selected text 
Sending INFO to client: 40%: Generated reviews 
Sending INFO to client: 60%: Editing selected text 
Sending INFO to client: 80%: Edited selected text
Sending INFO to client: 100%: Selected text editing completed

Followed by the final output (the same as we had when we directly called the edit_selected_text_workflow a few sections above):

Here is the edited selected text:

<selected_text>
    <content>
To make the right choice, you first need to understand what LLM workflows and AI agents are. We will look at their core properties and how they are used, rather than their technical specifics.

### LLM Workflows

An LLM workflow is a sequence of tasks orchestrated by developer-written code. It can involve LLM calls, reading from databases, or calling APIs. Each step is explicitly defined, like a detailed recipe.

This predefined path ensures deterministic, rule-based execution. You get predictable results and explicit control, making workflows easier to test and debug. You know exactly where failures occur and how to fix them.

```mermaid
graph TD
    A["Start"] --> B["Validate Input"]
    B --> C["LLM Call"]
    C --> D["Process Data"]
    D --> E["Call External API"]
    E --> F["Store Data"]
    F --> G["End"]
```
Image 1: A flowchart illustrating a deterministic LLM workflow with clear start and end points, including an LLM call and data operations.

### AI Agents

AI agents are systems where an LLM dynamically decides the sequence of steps, reasoning, and actions to achieve a goal. Their path is not predefined. The agent plans its actions based on the task and its environment.

This dynamic process, often modeled on frameworks like ReAct (Reason, Act, Observe), allows agents to be adaptive. They handle new situations through LLM-driven autonomy, selecting tools, executing actions, and correcting their course [[1]](https://www.youtube.com/watch?v=kQxr-uOxw2o&t=1s).

Now that we have a clear understanding of what LLM workflows and AI agents are, let's explore their core differences and when to choose each approach for your AI applications.

## Choosing Your Path</content>
    <first_line_number>8</first_line_number>
    <last_line_number>42</last_line_number>
</selected_text>

Here is what you have to do with edited selected text:
- print the edited selected text to the console for the user to see
- give a quick summary of the changes you made
- always apply the changes to the source file without waiting for the user to explicitly ask you to do so
- in case you haven't changed anything, just say that you haven't changed anything

Prompts

We also exposed three MCP prompts,**** decorated by @mcp.prompt ,**** such as generate_article_prompt, which enable users to trigger complex workflows using preconfigured templates. Instead of remembering how to call, trigger, or configure a particular tool, such as what arguments it requires, you just call a prompt, and it will take care of everything.

@mcp.prompt()
def generate_article_prompt(dir_path: str) -> str:
    return f"""
Using Brown hosted as an MCP server, generate an article using all the necessary resources from 
the following directory: `{dir_path}`. Don't check if any expected files are missing, just trigger 
the "generate_article" tool of the Brown MCP Server, which will take care of everything.
"""

Here is what the prompts for editing the article look like:

@mcp.prompt()
def edit_article_prompt(human_feedback: str = "") -> str:
    return f"""
Using Brown hosted as an MCP server, edit an entire article based on human feedback 
and other expected requirements. Don't check if any expected files are missing, 
just trigger the "edit_article" tool of the Brown MCP Server, which will take care 
of everything.
 
Human feedback:
<human_feedback>
{human_feedback}
</human_feedback>
 
If the <human_feedback> is empty, you will infer it from the previous messages. If there are no other messages 
to infer from, use an empty string. Don't ever fill it in with things such as "Please provide more details" or
fill it in with generic stuff.
"""
 
 
@mcp.prompt()
def edit_selected_text_prompt(human_feedback: str = "") -> str:
    return f"""
Using Brown hosted as an MCP server, edit the selected text from the article based on human feedback and 
other expected requirements. Don't check if any expected files are missing, just trigger the "edit_selected_text" 
tool of the Brown MCP Server, which will take care of everything.
 
Human feedback:
<human_feedback>
{human_feedback}
</human_feedback>
 
If the <human_feedback> is empty, you will infer it from the previous messages. If there are no other messages 
to infer from, use an empty string. Don't ever fill it in with things such as "Please provide more details" or
fill it in with generic stuff.
"""

We can verify the prompt retrieval using our client:

prompt = await mcp_client.get_prompt(
	"generate_article_prompt", 
	arguments={"dir_path": str(SAMPLE_DIR)}
	)
print(prompt)

It outputs the formatted message that the client (e.g., Cursor’s chatbot) will use to instruct the LLM:

-------------------------------- `user` Message ---------------------------------
  Role: user
---------------------------------------------------------------------------------
  Content: 
Using Brown hosted as an MCP server, generate an article using all the necessary resources from 
the following directory: `inputs/tests/01_sample_small`. Don't check if any expected files are missing, just trigger 
the "generate_article" tool of the Brown MCP Server, which will take care of everything.

---------------------------------------------------------------------------------

And similarly for edit_selected_text_prompt:

prompt = await mcp_client.get_prompt(
    "edit_selected_text_prompt",
    arguments={"human_feedback": human_feedback},
)
print(prompt)

Which returns:

-------------------------------- `user` Message ---------------------------------
  Role: user
---------------------------------------------------------------------------------
  Content: 
Using Brown hosted as an MCP server, edit the selected text from the article based on human feedback and 
other expected requirements. Don't check if any expected files are missing, just trigger the "edit_selected_text" 
tool of the Brown MCP Server, which will take care of everything.

Human feedback:
<human_feedback>
{"content":"Make the introduction more engaging and catchy. \nAlso, expand on the definition of both workflows and agents from the first section"}
</human_feedback>

If the <human_feedback> is empty, you will infer it from the previous messages. If there are no other messages 
to infer from, use an empty string. Don't ever fill it in with things such as "Please provide more details" or
fill it in with generic stuff.

---------------------------------------------------------------------------------

Remember, these prompts are like recipes that help the MCP client understand how to use the tools from the MCP server. Here, we had only one tool call per recipe, but these can get quite complex, enumerating complex business use cases that aggregate dozens of tools. That’s what we had in the Nova research agent: we compiled the entire research plan into a single plan.

As a useful analogy for memory, you can view the prompts as procedural memory because they instruct the LLM how to use its available actions (tools) to perform a complex task, which is ultimately the definition of procedural memory.

Now, let’s understand the last core component of an MCP server: resources.

Resources

In our use case, we leveraged the resources feature to expose static files used to configure Brown to the client, such as writing profiles and app configuration. In this way, the user can easily understand how Brown is configured. Why is this useful? In a real-world scenario, we will likely have multiple app configurations and writing profiles for social media posts, video transcripts, or different characters. Thus, to understand the current state of the MCP server, it’s essential to know what you are using.

Thus, we exposed resources like resource://config/app and "resource://config/character_profile" to allow the client to read the current configuration or character profile without executing any code. We did that through the @mcp.resource decorator. You could easily extend this functionality to expose all static inputs and configurations.

@mcp.resource("resource://config/app", mime_type="application/json")
def get_app_config() -> dict:
    """Get the application configuration for Brown Agent as an MCP resource.
    
    Returns:
        dict: The application configuration including:
            - Model configurations for each workflow node
            - File paths for profiles, examples, and context files
            - Number of review iterations and workflow settings
            - Temperature and other model parameters
    """
    return app_config.model_dump(mode="json")
 
 
@mcp.resource("resource://profiles/character")
def get_character_profile() -> str:
    """Get the character profile resource for Brown Agent.
    
    Returns:
        str: The character profile content in markdown format
    """
    return __get_profile("character")

We can list all the resources:

async with mcp_client:
    resources = await mcp_client.list_resources()

    for resource in resources:
        resource_dict = {
            "uri": str(resource.uri),
            "name": resource.name,
            "description": resource.description,
            "mime_type": resource.mimeType,
        }
        if hasattr(resource, "_meta") and resource._meta:
            fastmcp_meta = resource._meta.get("_fastmcp", {})
            resource_dict["tags"] = fastmcp_meta.get("tags", [])

        pretty_print.wrapped(
            resource_dict,
            title=f"`{resource.name}` Resource",
        )

It outputs:

---------------------------- `get_app_config` Resource ------------------------
  {
  "uri": "resource://config/app",
  "name": "get_app_config",
  "description": "Get the application configuration for Brown Agent ...
  "mime_type": "application/json"
}
-------------------------------------------------------------------------------
----------------------- `get_character_profile` Resource ----------------------
  {
  "uri": "resource://profiles/character",
  "name": "get_character_profile",
  "description": "Get the character profile resource for Brown Agent as an ...
  "mime_type": "text/plain"
}
-----------------------------------------------------------------------------

Or get a single resource:

config = await mcp_client.read_resource("resource://config/app")
print(config.contents[0].text)

It outputs the JSON configuration:

{
  "context": {
    "article_guideline_loader": "markdown",
    "article_guideline_uri": "article_guideline.md",
    "research_loader": "markdown",
    "research_uri": "research.md",
    "article_loader": "markdown",
    "article_renderer": "markdown",
    "article_uri": "article.md",
    "profiles_loader": "markdown",
    "profiles_uri": "inputs/profiles",
    "character_profile": "paul_iusztin.md",
    "examples_loader": "markdown",
    "examples_uri": "inputs/examples/course_lessons"
  },
  "memory": {
    "checkpointer": "in_memory"
  },
  "num_reviews": 2,
  "nodes": {
    "generate_media_items": {
      "model_id": "google_genai:gemini-2.5-flash",
      "config": {
        "temperature": 0.0,
        "top_k": null,
        "n": 1,
        "response_modalities": null,
        "include_thoughts": false,
        "thinking_budget": null,
        "max_output_tokens": null,
        "max_retries": 6,
        "mocked_response": null},
      "tools": {
        "mermaid_diagram_generator": {
          "name": "mermaid_diagram_generator",
          "model_id": "google_genai:gemini-2.5-flash",
          "config": {
            "temperature": 0.0,
            "top_k": null,
            "n": 1,
            "response_modalities": null,
            "include_thoughts": false,
            "thinking_budget": null,
            "max_output_tokens": null,
            "max_retries": 6,
            "mocked_response": null}
        }
      }
    },
    "write_article": {
      "model_id": "google_genai:gemini-2.5-flash",
      "config": {
        "temperature": 0.7,
        "top_k": null,
        "n": 1,
        "response_modalities": null,
        "include_thoughts": false,
        "thinking_budget": null,
        "max_output_tokens": null,
        "max_retries": 6,
        "mocked_response": null},
      "tools": {}
    },
    "review_article": {
      "model_id": "google_genai:gemini-2.5-flash",
		...
    }
  }
}

We wrapped up the MCP Server implementation. The last step is to understand how to use it effectively.

Hooking to the MCP Server

There are two main ways to connect to Brown’s MCP server: using the built-in CLI script or integrating it directly into Cursor.

Option 1: Brown CLI Script

Brown includes a CLI utility at lessons/writing_workflow/scripts/brown_mcp_cli.py. This script uses an in-memory MCP client to call the tools, which is great for testing or running workflows from the terminal.

It supports all the workflows as follows:

  1. Generate the article:
python scripts/brown_mcp_cli.py generate-article --dir-path /path/to/article
  1. Edit the entire article:
python scripts/brown_mcp_cli.py edit-article \
    --dir-path /path/to/article \
    --human-feedback "Make the introduction more engaging"
  1. Edit the selected text:
python scripts/brown_mcp_cli.py edit-selected-text \
    --dir-path /path/to/article \
    --human-feedback "Make this shorter. Remove all em-dashes." \
    --first-line 10 \
    --last-line 20

For more details on running these CLI commands while using Brown as a standalone project, see the documentation from lessons/writing_workflow from the GitHub repository.

Option 2: Cursor Integration

The most powerful way to use Brown is within Cursor. You configure the server in your .cursor/mcp.json file, telling Cursor how to launch the Brown Python project.

{
    "mcpServers": {
        "brown": {
            "command": "uv",
            "args": [
                "--directory",
                "lessons/writing_workflow/",
                "run",
                "python",
                "-m",
                "brown.mcp.server"
            ],
            "cwd": "${workspaceFolder}",
            "env": {
                "ENV_FILE_PATH": "${workspaceFolder}/lessons/writing_workflow/.env"
            }
        }
    }
}

Once connected, you get a fluid Human-in-the-Loop Writing Experience :

  1. Generate: You ask Cursor to “Generate an article based on the guidelines in this folder.”
  2. Review: You read the output in the editor.
  3. Feedback: You highlight a paragraph that feels off and type, “This is too formal, make it more conversational.”
  4. AI Edits: Brown’s edit_selected_text tool runs, and Cursor presents a diff view.
  5. Iterate: You accept the change and move to the next section.

This workflow puts you in the driver’s seat. The AI handles the grunt work of drafting and rewriting, while you guide the direction and quality. Note that for now, we are using local stdio transport. We will cover remote HTTP-based deployments in Part 3.

Image 9:Image 9: Screenshot of how Brown looks within Cursor when hooked as an MCP server.

We continued to use Cursor as an example because it was the one we used most to test the application. But as MCP Servers are entirely decoupled from the MCP client, you can easily use other tools to hook to Brown, such as Claude, Windsurf, or any other MCP client of your choice.

Running Brown Through the MCP Server (Video)

Check out the video below for a quick demo of how we used Brown, together with Cursor, to edit an existing article using our editing article and selected-text MCP tools.

Conclusion

Understanding how to add humans to the loop properly is essential for building practical AI applications because AI systems are imperfect. They hallucinate and make reasoning mistakes. The balance we’ve built allows the AI to handle the heavy lifting of generation while keeping humans in control of validation and direction.

In this lesson, we learned:

  1. The AI Generation / Human Validation Loop: How to design workflows that balance automation with human expertise.
  2. Edit Article Workflow: Implementing a workflow to refine entire documents based on feedback.
  3. Edit Selected Text Workflow: Building a precision tool for focused edits, reducing the risk of unwanted changes.
  4. MCP Server Integration: Serving our application logic through a standardized protocol with tools, prompts, and resources.
  5. Cursor Integration: Creating a coding-like writing experience.

This wraps up our exploration of the Brown writing workflow. We started with the foundations in Lesson 22, applied the evaluator-optimizer pattern in Lesson 23, and finalized it with human-in-the-loop integration here in Lesson 24. In Part 2D, we will take the final step: orchestrating both Nova (our researcher) and Brown (our writer) into a single, unified system. Then, in Part 3, we will shift gears to production engineering, covering AI Evals, monitoring, and deployment.

Practicing Ideas

  1. Hooking Brown to Claude Desktop: Instead of using Cursor, integrate Brown with Claude Desktop for a different AI assistant experience.
  2. Use Resource Templates: Parameterize the writing profiles within the MCP resources and easily add support for all available profiles. Learn more here.
  3. Serve Brown through FastAPI: Replace the MCP server with a FastAPI REST API for web-based integrations.
  4. Add Guideline Review Tool: Create a workflow to review and edit your article guidelines before passing them to the article generation step.

References

  1. Karpathy, A. (2024, June 18). Software Is Changing (Again). YouTube. youtube.com/watch
  2. Schluntz, E., & Zhang, B. (2024, December 19). Building effective agents. Anthropic. anthropic.com/engineering/building-effective-agents
  3. The FastMCP Server. (n.d.). FastMCP. gofastmcp.com/servers/server
  4. Functional API. (n.d.). LangChain. docs.langchain.com/oss/python/langgraph/functional-api
  5. Use the functional API. (n.d.). LangChain. docs.langchain.com/oss/python/langgraph/use-functional-api