Build a Marketing Research MCP Server

Build an MCP server that gives Claude tools to scrape and analyze competitor sites.

Introduction

30 Second Summary

Every marketer needs to know what their competitors are saying online. Checking competitor websites for positioning changes, SEO metadata, and messaging priorities is repetitive work that eats hours every week.

In this project, you will build a custom MCP server that gives Claude the power to scrape competitor websites, extract metadata, and produce structured marketing briefs on demand.

What You'll Build

A Python MCP server with three marketing research tools that Claude Desktop can call autonomously to perform competitive web research.

By the end of this project, you'll have:

  • A working server.py file implementing three MCP tools: scrape_page, extract_meta, and analyze_headers.
  • A prompt template that structures how Claude performs competitive analysis using your tools.
  • A tested end-to-end agentic workflow where Claude autonomously calls your tools to research a competitor and produce a marketing brief.
  • Secret Mission: Add a compare_competitors tool that fetches multiple URLs concurrently and returns a side-by-side comparison.

Are there any prerequisites?

You should be comfortable with Python and familiar with async/await syntax. A basic understanding of HTTP requests and HTML structure will help you follow the web scraping logic.

Set Up Your Environment

Before you can build MCP tools, you need a Python project with the right dependencies installed.

In this step, you will install uv (a fast Python package manager), scaffold a new project, and add the three libraries your server depends on: the MCP SDK, httpx for async HTTP, and Beautiful Soup for HTML parsing.

In this step, get ready to:
  • Install uv and create a new project directory.
  • Install project dependencies.
  • Create a minimal MCP server and verify it launches.
Install uv and create the project structure

uv is a fast Python package manager that replaces pip and venv with a single tool. The MCP official quickstart recommends it for Python MCP projects.

  • Check if uv is already installed by running this command:
uv --version

✔️ I see a version number

uv is ready to go. Move on to the next instruction below.

ⓧ Command not found

You need to install uv first. Run the install command for your operating system.

macOS

  • Install uv by running:
curl -LsSf https://astral.sh/uv/install.sh | sh
  • Close and reopen your terminal so the new PATH takes effect.
  • Confirm the install by running uv --version again. You should see a version number.

Windows

  • Install uv by running:
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
  • Close and reopen your terminal so the new PATH takes effect.
  • Confirm the install by running uv --version again. You should see a version number.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create and initialize your project by running these commands:
uv init marketing-mcp
cd marketing-mcp
uv venv
source .venv/bin/activate

What do these commands do?

  • uv init marketing-mcp creates a new project folder with a pyproject.toml file inside.
  • uv venv creates an isolated Python virtual environment in a .venv folder so your project dependencies stay separate from your system.
  • source .venv/bin/activate activates the virtual environment in your current terminal session.

Windows activation command

On Windows, replace the last command with:

.venv\Scripts\activate
  • Confirm the folder was created by running:
ls

You should see files like pyproject.toml and a .venv folder in the output.

Not seeing pyproject.toml?

  • Make sure you ran cd marketing-mcp after uv init. The ls command should be run from inside the marketing-mcp folder.
  • If uv init failed, check that uv --version prints a version number. If not, reopen your terminal after installing uv.

help me troubleshoot my uv project setup

Add project dependencies

Your MCP server needs three libraries: the MCP SDK (with its CLI extras), httpx for making async HTTP requests, and beautifulsoup4 for parsing HTML.

  • Install all three dependencies by running:
uv add "mcp[cli]" httpx beautifulsoup4

What does this command do?

uv add installs the packages into your virtual environment and records them in pyproject.toml. The [cli] part installs extra tools bundled with the MCP SDK, including the mcp command-line interface you will use to test your server.

  • Verify the install succeeded. You should see output confirming the packages were resolved and installed with no errors.

Seeing a resolution error?

  • Make sure your virtual environment is activated. You should see (marketing-mcp) or (.venv) at the start of your terminal prompt.
  • Check your internet connection. uv add needs to download packages from PyPI.

help me fix my dependency installation

Create a minimal server and verify it launches

Every MCP server starts with a FastMCP instance. You will create the simplest possible server to confirm everything is wired up correctly.

  • Create a new file called server.py in your marketing-mcp folder. Open it in your code editor and paste the following code:
from mcp.server.fastmcp import FastMCP

# Create the MCP server instance with a descriptive name
mcp = FastMCP("Marketing Research")

# Run the server using stdio transport (for Claude Desktop integration)
if __name__ == "__main__":
    mcp.run(transport="stdio")

What does this code do?

  • FastMCP("Marketing Research") creates a new MCP server with the display name "Marketing Research". This is the name clients like Claude Desktop will see.
  • mcp.run(transport="stdio") starts the server using standard input/output. This is the transport mode Claude Desktop uses to communicate with local servers.
  • Save server.py.
  • Launch the MCP Inspector to test your server by running:
uv run mcp dev server.py
  • A browser window should open showing the MCP Inspector interface. You should see your server name "Marketing Research" displayed with no errors.

What is MCP Inspector?

MCP Inspector is a browser-based tool for testing MCP servers. It connects to your server and lets you see registered tools, prompts, and resources without needing Claude Desktop. You will use it throughout this project to test each tool as you build it.

Inspector not opening?

  • Make sure you are running the command from inside the marketing-mcp directory where server.py lives.
  • Check that your virtual environment is activated. The mcp CLI comes from the mcp[cli] package you installed earlier.
  • If you see a port conflict, close any other dev servers running on the same port and try again.

help me get MCP Inspector working

✔️ Awesome, I've got everything!

Great. Double check you have saved server.py and that the Inspector shows "Marketing Research" with no errors.

ⓧ I'd like to double check the full code

Here is the complete server.py at this point:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Marketing Research")

if __name__ == "__main__":
    mcp.run(transport="stdio")

Your environment is locked and loaded. Next up, you will build your first real MCP tool that scrapes web pages and returns clean text.

Build Your First MCP Tool - Page Scraper

Your server skeleton is running and the MCP Inspector confirmed the connection. Now it's time to give your server its first real capability.

An MCP tool is a Python function decorated with @mcp.tool(). When Claude connects to your server, it reads each tool's name, docstring, and type hints to decide when and how to call it. In this step, you'll build a page scraper that fetches any URL and returns clean, readable text.

In this step, get ready to:
  • Import the HTTP and parsing libraries and configure a user-agent string.
  • Implement the scrape_page tool that fetches and cleans web pages.
  • Test the tool in MCP Inspector with a live URL.
Import dependencies and add the user-agent constant

Your server needs two libraries to scrape web pages. httpx handles async HTTP requests. Beautiful Soup parses and cleans HTML.

  • In server.py, replace everything above the if __name__ block with the following imports and configuration:
from typing import Any

import httpx
from bs4 import BeautifulSoup
from mcp.server.fastmcp import FastMCP

# Initialize the MCP server with a display name
mcp = FastMCP("Marketing Research")

# Identify your scraper in HTTP request headers
USER_AGENT = "MarketingResearchBot/1.0"

What does this code do?

The httpx library is an async-capable HTTP client. BeautifulSoup from bs4 parses raw HTML into a navigable tree you can search and modify.

The USER_AGENT constant identifies your scraper to web servers. Many sites block requests without a user-agent header, so setting one avoids silent failures.

  • Save server.py.
  • You won't see a visible change yet. The functions you add next will use these imports.
Implement the page scraper tool

The scraper needs two pieces: a reusable helper that fetches raw HTML, and an MCP tool that cleans that HTML into plain text. Splitting them keeps each function focused on one job.

  • In server.py, add the following fetch_page helper function below the USER_AGENT line and above the if __name__ block:
async def fetch_page(url: str) -> str:
    """Fetch a web page and return the raw HTML."""
    # Create a fresh HTTP client for each request
    async with httpx.AsyncClient() as client:
        response = await client.get(
            url,
            headers={"User-Agent": USER_AGENT},
            follow_redirects=True,
            timeout=15.0,
        )
        # Raise an exception for 4xx/5xx status codes
        response.raise_for_status()
        return response.text

What does this code do?

This async helper opens an httpx client, sends a GET request with your user-agent header, follows any redirects, and enforces a 15-second timeout. If the server returns an error status (like 404 or 500), raise_for_status() throws an exception so the caller knows the fetch failed.

Other tools you build later will reuse this same helper, so the HTTP logic lives in one place.

  • Save server.py.
  • You won't see a change yet. The scrape_page tool you add next calls this helper.
  • Below fetch_page and above the if __name__ block, add the scrape_page MCP tool:
@mcp.tool()
async def scrape_page(url: str) -> str:
    """Scrape a web page and return cleaned text content.

    Args:
        url: The full URL to scrape (e.g. https://www.example.com)
    """
    html = await fetch_page(url)
    soup = BeautifulSoup(html, "html.parser")

    # Remove non-content elements that add noise
    for element in soup(["script", "style", "nav", "footer", "header"]):
        element.decompose()

    # Extract visible text with line breaks between sections
    text = soup.get_text(separator="\n", strip=True)
    # Truncate to avoid overwhelming the LLM context window
    return text[:5000]

What does this code do?

The @mcp.tool() decorator registers this function as an MCP tool. Claude reads the docstring to understand what the tool does and the type hint (url: str) to know what input it needs.

Inside, decompose() permanently removes script, style, nav, footer, and header elements from the parsed HTML. These elements add noise without useful marketing content. The remaining text is extracted with newline separators and truncated to 5000 characters to stay within LLM context limits.

  • Save server.py. You'll test this tool in the next substep using MCP Inspector.

✔️ Awesome, I've got everything!

Great. Double check you've saved server.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete server.py file at this point:

from typing import Any

import httpx
from bs4 import BeautifulSoup
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Marketing Research")

USER_AGENT = "MarketingResearchBot/1.0"


async def fetch_page(url: str) -> str:
    """Fetch a web page and return the raw HTML."""
    async with httpx.AsyncClient() as client:
        response = await client.get(
            url,
            headers={"User-Agent": USER_AGENT},
            follow_redirects=True,
            timeout=15.0,
        )
        response.raise_for_status()
        return response.text


@mcp.tool()
async def scrape_page(url: str) -> str:
    """Scrape a web page and return cleaned text content.

    Args:
        url: The full URL to scrape (e.g. https://www.example.com)
    """
    html = await fetch_page(url)
    soup = BeautifulSoup(html, "html.parser")

    for element in soup(["script", "style", "nav", "footer", "header"]):
        element.decompose()

    text = soup.get_text(separator="\n", strip=True)
    return text[:5000]


if __name__ == "__main__":
    mcp.run(transport="stdio")
Test with MCP Inspector

MCP Inspector lets you call your tools directly from the browser without needing Claude. You'll use it to verify scrape_page returns clean text before wiring anything else up.

  • Launch the Inspector by running this command in your terminal:
uv run mcp dev server.py
  • The Inspector opens in your browser. Click the **Tools** tab in the left sidebar.
  • You should see scrape_page listed. Click on it.
  • In the url input field, enter https://www.opera.com.
  • Click **Run Tool** to execute the request.

You should see a block of plain text in the result panel. The text is readable content from Opera's homepage with no HTML tags, no script content, and no navigation elements.

Not seeing scrape_page in the Tools tab?

  • Make sure you saved server.py before running uv run mcp dev server.py. The Inspector reads the file at launch time.
  • Check that @mcp.tool() has parentheses. Without them, the decorator won't register the function.
  • If you see an import error, confirm your virtual environment is active and dependencies are installed with uv pip list.

Why isn't my scrape_page tool showing up in MCP Inspector?

Your server now has a working tool that can fetch and clean any public web page. Next up, you'll add two more tools that extract structured metadata and heading hierarchies from competitor sites.

Add Metadata Extraction and Header Analysis Tools

Your scrape_page tool gives Claude raw text from any web page. That's a solid foundation, but raw text alone doesn't tell you much about a competitor's strategy.

Marketing teams care about specific signals: what title tag and description a competitor chose (that's their SEO positioning), which Open Graph tags they set (that's how they want to appear when shared), and how they structure their headings (that reveals their messaging hierarchy). In this step, you'll build two tools that extract exactly these signals.

In this step, get ready to:
  • Build an extract_meta tool that pulls SEO metadata from any URL.
  • Build an analyze_headers tool that maps a page's heading hierarchy.
  • Test both tools in MCP Inspector with a real competitor URL.
Implement the metadata extraction tool

SEO metadata is the first thing marketers check when analyzing a competitor. The title tag, meta description, and Open Graph tags reveal how a company positions itself in search results and social shares.

  • In server.py, find the closing line of your scrape_page function (return text[:5000]).
  • Add the following extract_meta tool below it, leaving a blank line between the two functions:
@mcp.tool()
async def extract_meta(url: str) -> str:
    """Extract SEO metadata from a web page including title, description, and Open Graph tags.

    Args:
        url: The full URL to analyze (e.g. https://www.example.com)
    """
    html = await fetch_page(url)
    soup = BeautifulSoup(html, "html.parser")

    # Extract the page title
    title = soup.title.string.strip() if soup.title and soup.title.string else "Not found"

    # Extract meta description
    meta_desc_tag = soup.find("meta", attrs={"name": "description"})
    meta_desc = meta_desc_tag["content"] if meta_desc_tag and meta_desc_tag.get("content") else "Not found"

    # Extract canonical URL
    canonical_tag = soup.find("link", attrs={"rel": "canonical"})
    canonical = canonical_tag["href"] if canonical_tag and canonical_tag.get("href") else "Not found"

    # Extract all Open Graph tags
    og_tags: dict[str, str] = {}
    for tag in soup.find_all("meta", attrs={"property": True}):
        prop = tag.get("property", "")
        if prop.startswith("og:"):
            og_tags[prop] = tag.get("content", "")

    # Format output as a readable string
    lines = [
        f"Title: {title}",
        f"Meta Description: {meta_desc}",
        f"Canonical URL: {canonical}",
        "",
        "Open Graph Tags:",
    ]
    if og_tags:
        for key, value in og_tags.items():
            lines.append(f"  {key}: {value}")
    else:
        lines.append("  None found")

    return "\n".join(lines)

What does this code do?

This tool reuses your existing fetch_page helper to grab the HTML, then targets specific metadata elements that marketers care about.

  • soup.title.string grabs the text inside the <title> tag. This is what appears in browser tabs and search results.
  • The og: tags control how the page looks when shared on social media. These reveal how the competitor wants to be perceived publicly.
  • The canonical URL shows which page the competitor considers the "official" version, useful for spotting duplicate content strategies.
  • Save server.py.

You won't test this tool just yet. The next chunk adds the second tool, then you'll verify both together in MCP Inspector.

Implement the header analysis tool

A page's heading hierarchy reveals its messaging priorities. The H1 is usually the primary value proposition. H2s break down supporting themes. H3s add specifics. Together they form a map of what the competitor thinks matters most.

  • In server.py, add the following analyze_headers tool below your extract_meta function, leaving a blank line between them:
@mcp.tool()
async def analyze_headers(url: str) -> str:
    """Analyze the heading hierarchy (H1-H3) of a web page to reveal content structure and messaging.

    Args:
        url: The full URL to analyze (e.g. https://www.example.com)
    """
    html = await fetch_page(url)
    soup = BeautifulSoup(html, "html.parser")

    # Set up a dictionary to collect headings by level
    headings: dict[str, list[str]] = {"h1": [], "h2": [], "h3": []}

    # Find all headings at each level
    for level in headings:
        for tag in soup.find_all(level):
            text = tag.get_text(strip=True)
            if text:
                headings[level].append(text)

    # Format output showing the hierarchy
    lines = []
    for level, texts in headings.items():
        lines.append(f"\n{level.upper()} ({len(texts)} found):")
        if texts:
            for text in texts:
                lines.append(f"  - {text}")
        else:
            lines.append("  (none)")

    return "\n".join(lines)

What does this code do?

This tool collects every H1, H2, and H3 heading from the page and organizes them by level.

  • The dictionary headings stores lists of heading text grouped by tag level. Iterating over the dictionary keys (h1, h2, h3) lets you use each key as both a label and a search term for Beautiful Soup.
  • Empty headings are filtered out with the if text: check. Many sites have hidden or decorative headings with no content.
  • The output shows a count for each level so Claude can quickly gauge how dense the page is before reading each heading.
  • Save server.py.

Seeing a syntax error on save?

  • Make sure your new functions are placed between the scrape_page function and the if __name__ == "__main__": block at the bottom.
  • Check that the @mcp.tool() decorator sits directly above each function definition with no blank line between them.
  • Verify that the f-string quotes match. Each f"..." should have matching double quotes.

Help me debug a syntax error in my MCP server tools

Test both tools in MCP Inspector
  • Launch MCP Inspector by running:
uv run mcp dev server.py
  • In the MCP Inspector browser window, click the **Tools** tab.
  • You should now see three tools listed: scrape_page, extract_meta, and analyze_headers.
  • Select extract_meta from the tools list.
  • Enter https://www.opera.com in the url field and click **Run**.

You should see structured output showing the page title, meta description, canonical URL, and any Open Graph tags the site uses.

  • Now select analyze_headers from the tools list.
  • Enter https://www.opera.com in the url field and click **Run**.

You should see headings organized by level (H1, H2, H3) with counts showing how many of each the page contains.

Tool not showing up in Inspector?

  • Make sure you saved server.py before running uv run mcp dev server.py.
  • Check that both functions have the @mcp.tool() decorator. Without it, MCP won't register the function as a tool.
  • If you get a connection error for the URL, try a different website. Some sites block automated requests.

Help me troubleshoot my MCP tools in Inspector

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

from typing import Any

import httpx
from bs4 import BeautifulSoup
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Marketing Research")

USER_AGENT = "MarketingResearchBot/1.0"


async def fetch_page(url: str) -> str:
    """Fetch a web page and return the raw HTML."""
    async with httpx.AsyncClient() as client:
        response = await client.get(
            url,
            headers={"User-Agent": USER_AGENT},
            follow_redirects=True,
            timeout=15.0,
        )
        response.raise_for_status()
        return response.text


@mcp.tool()
async def scrape_page(url: str) -> str:
    """Scrape a web page and return cleaned text content.

    Args:
        url: The full URL to scrape (e.g. https://www.example.com)
    """
    html = await fetch_page(url)
    soup = BeautifulSoup(html, "html.parser")

    for element in soup(["script", "style", "nav", "footer", "header"]):
        element.decompose()

    text = soup.get_text(separator="\n", strip=True)
    return text[:5000]


@mcp.tool()
async def extract_meta(url: str) -> str:
    """Extract SEO metadata from a web page including title, description, and Open Graph tags.

    Args:
        url: The full URL to analyze (e.g. https://www.example.com)
    """
    html = await fetch_page(url)
    soup = BeautifulSoup(html, "html.parser")

    title = soup.title.string.strip() if soup.title and soup.title.string else "Not found"

    meta_desc_tag = soup.find("meta", attrs={"name": "description"})
    meta_desc = meta_desc_tag["content"] if meta_desc_tag and meta_desc_tag.get("content") else "Not found"

    canonical_tag = soup.find("link", attrs={"rel": "canonical"})
    canonical = canonical_tag["href"] if canonical_tag and canonical_tag.get("href") else "Not found"

    og_tags: dict[str, str] = {}
    for tag in soup.find_all("meta", attrs={"property": True}):
        prop = tag.get("property", "")
        if prop.startswith("og:"):
            og_tags[prop] = tag.get("content", "")

    lines = [
        f"Title: {title}",
        f"Meta Description: {meta_desc}",
        f"Canonical URL: {canonical}",
        "",
        "Open Graph Tags:",
    ]
    if og_tags:
        for key, value in og_tags.items():
            lines.append(f"  {key}: {value}")
    else:
        lines.append("  None found")

    return "\n".join(lines)


@mcp.tool()
async def analyze_headers(url: str) -> str:
    """Analyze the heading hierarchy (H1-H3) of a web page to reveal content structure and messaging.

    Args:
        url: The full URL to analyze (e.g. https://www.example.com)
    """
    html = await fetch_page(url)
    soup = BeautifulSoup(html, "html.parser")

    headings: dict[str, list[str]] = {"h1": [], "h2": [], "h3": []}

    for level in headings:
        for tag in soup.find_all(level):
            text = tag.get_text(strip=True)
            if text:
                headings[level].append(text)

    lines = []
    for level, texts in headings.items():
        lines.append(f"\n{level.upper()} ({len(texts)} found):")
        if texts:
            for text in texts:
                lines.append(f"  - {text}")
        else:
            lines.append("  (none)")

    return "\n".join(lines)


if __name__ == "__main__":
    mcp.run(transport="stdio")

Your server now has three tools that each tackle a different angle of competitive research. Next up, you'll tie them together with a prompt template that tells Claude exactly how to use all three for a structured competitive analysis.

Add a Competitive Analysis Prompt Template

Your server has three independent tools that can scrape content, extract metadata, and analyze headings. Each tool works on its own, but the real value comes from orchestrating them together into a single workflow.

MCP prompt templates solve this. A prompt template is a pre-written instruction set that tells Claude exactly which tools to call and how to synthesize the results. You'll also add a resource that defines the output format, giving Claude a consistent structure to follow.

In this step, get ready to:
  • Create a prompt template that orchestrates all three tools into a competitive analysis workflow.
  • Add a resource template that defines the output format for the analysis brief.
  • Verify both appear in MCP Inspector's Prompts and Resources tabs.
Add a new import and create the competitive analysis prompt

The prompt template returns structured messages that guide Claude's behavior. To build these messages, you need the base module from the MCP prompts package.

  • In server.py, find this import line near the top of the file:
from mcp.server.fastmcp import FastMCP
  • Add this new import directly below it:
from mcp.server.fastmcp.prompts import base

What is base?

The base module provides message types like base.UserMessage that represent the conversation structure MCP sends to a client. Your prompt template returns a list of these messages to define what Claude should do.

  • Scroll to the bottom of server.py and find this line:
if __name__ == "__main__":
    mcp.run(transport="stdio")
  • Add the following prompt function directly above that if __name__ block:
@mcp.prompt(title="Competitive Analysis")
def competitive_analysis(competitor_url: str) -> list[base.Message]:
    """Run a full competitive analysis on a competitor's website.

    Args:
        competitor_url: The URL to analyze (e.g. https://www.competitor.com)
    """
    return [
        base.UserMessage(
            f"""Perform a competitive analysis on {competitor_url}.

Use these tools in sequence:
1. Call scrape_page to get the main content
2. Call extract_meta to get SEO metadata and Open Graph tags
3. Call analyze_headers to understand the content hierarchy

Then synthesize your findings into a brief with these sections:
- Positioning: What is their core value proposition?
- Key Messages: What are their top 3 marketing messages?
- Target Audience: Who are they speaking to?
- Differentiators: What do they claim makes them unique?

Be specific and cite evidence from the scraped content."""
        )
    ]

What does this prompt do?

The @mcp.prompt(title="Competitive Analysis") decorator registers this function as a discoverable prompt template. When a user selects it in Claude Desktop or MCP Inspector, the client passes in a competitor_url argument.

The function returns a base.UserMessage containing step-by-step instructions. Claude reads these instructions and autonomously decides to call scrape_page, extract_meta, and analyze_headers in sequence.

  • Save server.py.

You won't see a change yet. The next chunk adds the resource template, then you'll test both together.

Add a brief format resource

MCP resources expose read-only data that clients can pull into context. This resource provides a markdown template that Claude can reference when structuring its analysis output.

  • In server.py, add the following resource function directly below the competitive_analysis function you just added (still above the if __name__ block):
@mcp.resource("template://competitive-brief")
def brief_format() -> str:
    """Markdown template for a competitive analysis brief."""
    return """# Competitive Analysis: [Company Name]

## Positioning
[Core value proposition in 1-2 sentences]

## Key Messages
1. [Primary message]
2. [Secondary message]
3. [Tertiary message]

## Target Audience
[Who they are targeting and how you can tell]

## Differentiators
- [Claimed differentiator 1]
- [Claimed differentiator 2]
- [Claimed differentiator 3]

## Raw Evidence
- Title tag: [from extract_meta]
- Meta description: [from extract_meta]
- Primary H1: [from analyze_headers]
"""

What does this resource do?

The @mcp.resource("template://competitive-brief") decorator exposes this function as a readable resource at the URI template://competitive-brief. Any MCP client can fetch this template and use it as a formatting guide.

The template defines consistent sections for every competitive analysis. Claude can reference this resource to structure its output identically each time.

  • Save server.py.

Seeing a syntax error on save?

Make sure the triple-quoted string (""") in brief_format is closed properly. The closing """ must be on its own line. Also check that both new functions sit above the if __name__ == "__main__": block.

help me debug a syntax error in my MCP server after adding the prompt and resource

✔️ Awesome, I've got everything!

Great. Double check you've saved server.py with both the new import at the top and the two new functions before the if __name__ block.

ⓧ I'd like to double check the full code

from typing import Any

import httpx
from bs4 import BeautifulSoup
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts import base

mcp = FastMCP("Marketing Research")

USER_AGENT = "MarketingResearchBot/1.0"


async def fetch_page(url: str) -> str:
    """Fetch a web page and return the raw HTML."""
    async with httpx.AsyncClient() as client:
        response = await client.get(
            url,
            headers={"User-Agent": USER_AGENT},
            follow_redirects=True,
            timeout=15.0,
        )
        response.raise_for_status()
        return response.text


@mcp.tool()
async def scrape_page(url: str) -> str:
    """Scrape a web page and return cleaned text content.

    Args:
        url: The full URL to scrape (e.g. https://www.example.com)
    """
    html = await fetch_page(url)
    soup = BeautifulSoup(html, "html.parser")

    for element in soup(["script", "style", "nav", "footer", "header"]):
        element.decompose()

    text = soup.get_text(separator="\n", strip=True)
    return text[:5000]


@mcp.tool()
async def extract_meta(url: str) -> str:
    """Extract SEO metadata from a web page including title, description, and Open Graph tags.

    Args:
        url: The full URL to analyze (e.g. https://www.example.com)
    """
    html = await fetch_page(url)
    soup = BeautifulSoup(html, "html.parser")

    title = soup.title.string.strip() if soup.title and soup.title.string else "Not found"

    meta_desc_tag = soup.find("meta", attrs={"name": "description"})
    meta_desc = meta_desc_tag["content"] if meta_desc_tag and meta_desc_tag.get("content") else "Not found"

    canonical_tag = soup.find("link", attrs={"rel": "canonical"})
    canonical = canonical_tag["href"] if canonical_tag and canonical_tag.get("href") else "Not found"

    og_tags: dict[str, str] = {}
    for tag in soup.find_all("meta", attrs={"property": True}):
        prop = tag.get("property", "")
        if prop.startswith("og:"):
            og_tags[prop] = tag.get("content", "")

    lines = [
        f"Title: {title}",
        f"Meta Description: {meta_desc}",
        f"Canonical URL: {canonical}",
        "",
        "Open Graph Tags:",
    ]
    if og_tags:
        for key, value in og_tags.items():
            lines.append(f"  {key}: {value}")
    else:
        lines.append("  None found")

    return "\n".join(lines)


@mcp.tool()
async def analyze_headers(url: str) -> str:
    """Analyze the heading hierarchy (H1-H3) of a web page to reveal content structure and messaging.

    Args:
        url: The full URL to analyze (e.g. https://www.example.com)
    """
    html = await fetch_page(url)
    soup = BeautifulSoup(html, "html.parser")

    headings: dict[str, list[str]] = {"h1": [], "h2": [], "h3": []}

    for level in headings:
        for tag in soup.find_all(level):
            text = tag.get_text(strip=True)
            if text:
                headings[level].append(text)

    lines = []
    for level, texts in headings.items():
        lines.append(f"\n{level.upper()} ({len(texts)} found):")
        if texts:
            for text in texts:
                lines.append(f"  - {text}")
        else:
            lines.append("  (none)")

    return "\n".join(lines)


@mcp.prompt(title="Competitive Analysis")
def competitive_analysis(competitor_url: str) -> list[base.Message]:
    """Run a full competitive analysis on a competitor's website.

    Args:
        competitor_url: The URL to analyze (e.g. https://www.competitor.com)
    """
    return [
        base.UserMessage(
            f"""Perform a competitive analysis on {competitor_url}.

Use these tools in sequence:
1. Call scrape_page to get the main content
2. Call extract_meta to get SEO metadata and Open Graph tags
3. Call analyze_headers to understand the content hierarchy

Then synthesize your findings into a brief with these sections:
- Positioning: What is their core value proposition?
- Key Messages: What are their top 3 marketing messages?
- Target Audience: Who are they speaking to?
- Differentiators: What do they claim makes them unique?

Be specific and cite evidence from the scraped content."""
        )
    ]


@mcp.resource("template://competitive-brief")
def brief_format() -> str:
    """Markdown template for a competitive analysis brief."""
    return """# Competitive Analysis: [Company Name]

## Positioning
[Core value proposition in 1-2 sentences]

## Key Messages
1. [Primary message]
2. [Secondary message]
3. [Tertiary message]

## Target Audience
[Who they are targeting and how you can tell]

## Differentiators
- [Claimed differentiator 1]
- [Claimed differentiator 2]
- [Claimed differentiator 3]

## Raw Evidence
- Title tag: [from extract_meta]
- Meta description: [from extract_meta]
- Primary H1: [from analyze_headers]
"""


if __name__ == "__main__":
    mcp.run(transport="stdio")
Test prompts and resources in MCP Inspector

MCP Inspector has dedicated tabs for prompts and resources. You'll verify both appear and return the expected content.

  • Launch MCP Inspector by running:
  • If the Inspector is already running from a previous step, stop it with Ctrl+C and re-run the command to pick up your new code.
uv run mcp dev server.py
  • Open the Inspector in your browser.
  • Click the Prompts tab.
  • You should see Competitive Analysis listed as a prompt template.
  • Click on it to expand it. You should see a competitor_url argument field.
  • Enter https://www.opera.com and click to get the prompt. The generated message should show the full instruction text telling Claude to call all three tools.
  • Click the Resources tab.
  • You should see template://competitive-brief listed. Click it to inspect the markdown template content.

What are the Prompts and Resources tabs?

The Prompts tab displays available prompt templates with their arguments and descriptions. You can test prompts with custom arguments and preview the generated messages.

The Resources tab lists all available resources with their metadata. You can inspect each resource's content directly. Together, these tabs let you verify your server exposes everything correctly before connecting a real client.

Don't see the prompt or resource?

Make sure you restarted the Inspector after saving your changes. The Inspector connects to your server at launch time, so any code changes require a restart.

Also check that both functions sit above the if __name__ == "__main__": block. If they're below it, the server never registers them.

help me figure out why my prompt or resource isn't showing in MCP Inspector

Your server now has tools, a prompt template, and a resource. Next up, you'll connect everything to Claude Desktop and run a full end-to-end competitive analysis.

Connect to Claude Desktop and Run End-to-End

Your MCP server is fully built and tested in isolation. All three tools, the prompt template, and the resource are working in MCP Inspector.

Now it's time to connect your server to a real AI client. When Claude Desktop is connected to your server, Claude can autonomously decide which tools to call based on your request. This is the agentic workflow in action.

In this step, get ready to:
  • Register your MCP server with Claude Desktop.
  • Verify the connection by checking for the hammer icon showing 3 tools.
  • Run an end-to-end competitive analysis where Claude calls your tools autonomously.
Register your server with Claude Desktop

The MCP CLI can register your server directly with Claude Desktop's configuration file. This tells Claude Desktop where to find your server and how to launch it.

First, make sure you have Claude Desktop installed on your machine.

✔️ I already have Claude Desktop

Great. Move on to the next instruction below.

ⓧ I need to install it

  • Download Claude Desktop from claude.ai/download.
  • Click Download for macOS or Download for Windows depending on your platform.
  • Open the installer and follow the prompts to complete installation.
  • Launch Claude Desktop and sign in with your Anthropic account.

Once Claude Desktop is open and you're signed in, continue below.

  • Make sure you're inside your marketing-mcp project folder in your terminal.
  • Register your server with Claude Desktop by running this command:
uv run mcp install server.py --name "Marketing Research"

What does this command do?

This command writes an entry into Claude Desktop's configuration file at ~/Library/Application Support/Claude/claude_desktop_config.json. The entry tells Claude Desktop how to start your server using uv whenever it needs your tools.

Your server runs on stdio transport, meaning Claude Desktop launches it as a subprocess and communicates through standard input/output. There is no background process running when you're not using it.

You should see a confirmation message indicating the server was registered successfully.

Command failed or permission error?

  • Make sure Claude Desktop is installed and has been launched at least once. The configuration directory is created on first launch.
  • Check that you're running the command from inside the marketing-mcp folder where server.py lives.
  • If the command is not recognized, confirm your virtual environment is activated with source .venv/bin/activate.

help me troubleshoot the mcp install command failing

Manual configuration alternative

If the CLI command doesn't work, you can manually edit the config file. Open ~/Library/Application Support/Claude/claude_desktop_config.json and add your server entry inside the mcpServers object:

{
  "mcpServers": {
    "Marketing Research": {
      "command": "uv",
      "args": ["--directory", "/FULL/PATH/TO/marketing-mcp", "run", "server.py"]
    }
  }
}

Replace the path with the full absolute path to your marketing-mcp folder.

Restart Claude Desktop and verify the connection

Claude Desktop reads its configuration on startup. After registering a new server, you need to fully restart the app for it to pick up the change.

  • Quit Claude Desktop completely. Press Cmd+Q (macOS) or right-click the taskbar icon and select Quit (Windows).
  • Reopen Claude Desktop.
  • Look for the hammer icon in the bottom-left area of the chat input. It should show a number indicating available tools.
  • Click the hammer icon to expand the tools list.

You should see your 3 tools listed: scrape_page, extract_meta, and analyze_headers.

Don't see the hammer icon?

  • Make sure you fully quit and relaunched Claude Desktop. Closing the window is not the same as quitting the app.
  • Open ~/Library/Application Support/Claude/claude_desktop_config.json in a text editor and verify the Marketing Research entry exists.
  • Check that the path in your config points to the correct location of server.py.

why isn't my MCP server showing up in Claude Desktop

Run a full competitive analysis

This is where the agentic magic happens. You'll give Claude a natural language request, and it will autonomously decide which of your tools to call and in what order.

  • In a new Claude Desktop chat, type the following prompt and press Enter:
Use the competitive_analysis prompt to research opera.com
  • Claude will ask for permission to use your tools. Click Allow when prompted for each tool call.

What's happening behind the scenes?

Claude reads the competitive_analysis prompt template you defined, which instructs it to call scrape_page, extract_meta, and analyze_headers in sequence. Claude interprets these instructions and autonomously makes each tool call, passing the URL and reading the results.

After gathering data from all three tools, Claude synthesizes the raw information into a structured competitive brief with sections for Positioning, Key Messages, Target Audience, and Differentiators. This is the same agentic pattern used in production AI workflows.

Watch Claude's output. You should see it call each tool one by one, then produce a structured marketing brief about Opera's positioning and messaging.

Claude not calling your tools?

  • Make sure you clicked Allow on the permission prompts. Claude will not call tools without explicit approval.
  • Try rephrasing your request to be more direct: "Call the scrape_page tool on https://www.opera.com" to test a single tool first.
  • If you see an error about the server not responding, check that uv is in your system PATH and can be found by Claude Desktop.

help me figure out why Claude isn't using my MCP tools

Secret mission

Concurrent Competitor Comparison

Your current tools analyze one competitor at a time. What if you could compare three competitors side-by-side in a single request? In this secret mission, you'll add a tool that fetches multiple URLs concurrently and returns a structured comparison table.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using stdio transport. There are no cloud resources, no background processes, and no ongoing costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The marketing-mcp project directory on your Desktop.
  • A Claude Desktop configuration entry for "Marketing Research" in claude_desktop_config.json.

Keep everything running

No action needed. Choose this if you plan to keep using your MCP server with Claude Desktop.

  • Your server runs on-demand when Claude needs it. There is no background process and no cost.
  • The Claude Desktop config entry stays in place so your tools remain available in every new conversation.

Pause - I'll come back to this later

Remove the Claude Desktop integration but keep your project files so you can re-register the server later.

  • Open ~/Library/Application Support/Claude/claude_desktop_config.json in a text editor.
  • Delete the "Marketing Research" entry from the mcpServers object.
  • Save the file and restart Claude Desktop.
  • Your project files remain on your Desktop. To reconnect later, run:
uv run mcp install server.py --name "Marketing Research"

Delete - I don't want to use this again

Remove all project resources and the Claude Desktop integration entirely.

  • Open ~/Library/Application Support/Claude/claude_desktop_config.json in a text editor.
  • Delete the "Marketing Research" entry from the mcpServers object.
  • Save the file and restart Claude Desktop. The hammer icon should no longer show your marketing research tools.
  • Remove the project directory by running this command in your terminal:
rm -rf ~/Desktop/marketing-mcp
  • Confirm the folder is gone by running:
ls ~/Desktop | grep marketing-mcp

You should see no output, confirming the directory has been removed.

Nice Work!

Nice Work!

You just built a custom MCP server that turns Claude into an autonomous marketing research agent. Your server exposes real tools that scrape, extract, and analyze competitor websites on demand.

You've learned how to:

  • Built a custom MCP server with 3 marketing research tools (scrape_page, extract_meta, and analyze_headers) using the MCP Python SDK and FastMCP.
  • Connected your server to Claude Desktop via stdio transport, enabling agentic tool use where Claude autonomously decides which tools to call.
  • Created a prompt template and resource template that structure competitive analysis workflows into a consistent marketing brief format.
  • Secret Mission: Added a compare_competitors tool using asyncio.gather for concurrent multi-URL comparison with graceful error handling.

Ready to quiz yourself?