Technical SEO

How to Build an MCP Server for Your Marketing Stack: A Tested Python Walkthrough

· · 14 min read

Most guides to building an MCP server stop at the calculator. Two integers in, a sum out, the protocol demonstrated, the reader left to work out what a server for their actual job would look like. This walkthrough builds the server a marketing team would use: a Search Console export as a tool, a crawler-style URL check as a second tool, the sitemap as a resource and a weekly review as a prompt. Every line was run before it was pasted here, the test that proves it works is included, and the last section covers the part the calculator tutorials skip, which is how to know what the assistant is doing with your server once it is connected.

If you only want vendor data in an assistant, you do not need to build anything; the best MCP servers for SEO covers Ahrefs, DataForSEO, Semrush, SE Ranking and Cloro, and they are better than anything you would write in a day. You build your own server for the data those vendors do not have: your exports, your CMS, your sitemap, your internal conventions.

Key takeaways

  • An MCP server turns your own data and scripts into tools an AI assistant can call, and the same server works in Claude, ChatGPT, Cursor and VS Code because the protocol is an open standard; you write it once.
  • The Python SDK v2 builds the tool schema from type hints, so a working marketing server is around 40 lines: two tools, one resource, one prompt, no JSON Schema and no request parsing.
  • Expose tools for actions the assistant should take, resources for data it should read, and prompts for workflows you want run the same way every time; most marketing servers need all three.
  • Test the server with the SDK’s in-memory client before connecting it to anything. The listed tools and a structured result are the proof; a server that only works in a chat window has not been tested.
  • Start read-only, keep credentials in environment variables, and log every call. OpenTelemetry’s GenAI conventions already cover MCP, so the observability question has a standard answer.

What an MCP server is, and why a marketing team builds its own

The Model Context Protocol introduction describes MCP as an open-source standard for connecting AI applications to external systems: data sources, tools and workflows. The clients that speak it include Claude, ChatGPT, Visual Studio Code and Cursor, which is the practical point. A server built once is usable from every assistant your team already has, without an integration per product.

For SEO and content teams the vendor servers cover the vendor data. What they do not cover is everything that lives in your own stack: the Search Console export you pull every Monday, the CMS that holds the publish dates, the claims register, the sitemap, the script that checks whether a page still has an H1 after a deploy. Today those are things a person opens, reads and copies into a chat. An MCP server makes them things the assistant can open for itself, and the Claude SEO automation scripts on this site become tools rather than files you paste.

The design question is not “can I build one” but “what should it expose”. MCP gives you three primitives, and the split is worth getting right before writing code.

PrimitiveWhat it isMarketing stack examples
ToolA function the assistant can call with argumentsQuery the GSC export, fetch and check a URL, list posts due for an update
ResourceData the assistant can read, addressed by a URIThe sitemap, the claims register, the style guide, a content calendar
PromptA reusable instruction template the user invokesWeekly review, pre-publish check, brief from a keyword

Tools are for actions and parameterised lookups. Resources are for reference material the assistant should be able to read without you pasting it. Prompts are for the workflows you want run the same way each time rather than re-described in every session. A server with only tools works, but the resource and prompt slots are where the consistency comes from.

Build it: two tools, one resource, one prompt

The server uses the official Python SDK. The MCP Python SDK README describes v2 as the current stable line, built for the 2026-07-28 specification, requiring Python 3.10 or later and installed with pip install "mcp[cli]". The cli extra adds the mcp dev, mcp run and mcp install commands used below. The README’s own summary of what you do not write is accurate: no JSON Schema, no request parsing, no validation code, no protocol handling. The type hints on the function are the schema.

Create a virtual environment, install the SDK, and save this as marketing_mcp.py:

"""A minimal MCP server for a marketing stack: one tool per data source,
one resource for the site inventory, one prompt for a weekly review."""
import csv, os, urllib.request
from mcp.server import MCPServer

mcp = MCPServer("marketing-stack")

SITEMAP_URL = os.environ.get("SITEMAP_URL", "https://example.com/sitemap-0.xml")
GSC_EXPORT = os.environ.get("GSC_EXPORT", "gsc_queries.csv")  # a Search Console export

@mcp.tool()
def gsc_top_queries(min_impressions: int = 50, limit: int = 20) -> list[dict]:
    """Top Search Console queries from the latest export, filtered by impressions."""
    with open(GSC_EXPORT, newline="") as f:
        rows = [r for r in csv.DictReader(f) if int(r["impressions"]) >= min_impressions]
    rows.sort(key=lambda r: -int(r["impressions"]))
    return [{"query": r["query"], "clicks": int(r["clicks"]),
             "impressions": int(r["impressions"]), "position": float(r["position"])}
            for r in rows[:limit]]

@mcp.tool()
def check_url(url: str) -> dict:
    """Fetch a URL the way a crawler would and report status, size and whether an H1 is present."""
    req = urllib.request.Request(url, headers={"User-Agent": "marketing-mcp/1.0"})
    with urllib.request.urlopen(req, timeout=20) as resp:
        body = resp.read()
        return {"status": resp.status, "bytes": len(body), "has_h1": b"<h1" in body}

@mcp.resource("site://inventory")
def site_inventory() -> str:
    """Every URL in the sitemap, one per line."""
    with urllib.request.urlopen(SITEMAP_URL, timeout=20) as resp:
        xml = resp.read().decode()
    urls = [u.split("</loc>")[0] for u in xml.split("<loc>")[1:]]
    return "\n".join(urls)

@mcp.prompt()
def weekly_review(site: str) -> str:
    """Weekly content review: what moved, what to fix, what to write next."""
    return (f"Review {site}. Use gsc_top_queries to find queries above 100 impressions "
            "with a click-through rate under 1%, use check_url on their landing pages, "
            "and propose at most three fixes with the evidence for each.")

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

Four things are worth noticing, because they are the things you will change when you adapt it.

The docstrings are the interface. The assistant decides which tool to call by reading the tool name, the docstring and the parameter names. gsc_top_queries with a docstring that says what the export is and what the filter does gets called correctly; a tool called q1 with no docstring gets called wrongly or not at all. Write the docstring for the model, not for a colleague.

The type hints are the schema. min_impressions: int = 50 becomes a JSON Schema integer with a default; the client validates arguments against it before your function runs. The return type matters too. A tool that returns a list of dicts comes back to the assistant as structured content it can reason over field by field, which is why the GSC tool returns records rather than a formatted string.

Configuration goes through the environment. The sitemap URL and the export path are read from environment variables with defaults. This is how you run the same server against three client sites, and it is how credentials stay out of the file when the next tool talks to a real API.

The prompt encodes the workflow. weekly_review is the part teams skip, and it is the part that makes the server useful on a Monday. The instruction names the tools, the thresholds and the output shape, so the review runs the same way whoever invokes it. The threshold values are the kind of thing a content production workflow fixes once rather than re-argues weekly.

The export format assumed by gsc_top_queries is the standard Search Console performance download with query, clicks, impressions and position columns. If you would rather pull live data than read an export, the Search Console API article covers the authentication; the tool body becomes an API call and the signature stays the same.

Test it before connecting it to anything

The SDK ships a client, and the client can talk to a server object in memory with no transport, no subprocess and no assistant in the loop. This is the test to run first, because it separates “the server is wrong” from “the connection is wrong”, which are the two failures you will otherwise have to untangle at the same time in a chat window.

import asyncio, csv
from mcp import Client
import marketing_mcp

async def main():
    with open("gsc_queries.csv", "w", newline="") as f:
        w = csv.writer(f)
        w.writerow(["query", "clicks", "impressions", "position"])
        w.writerow(["ai crawler logs", "3", "120", "8.2"])
        w.writerow(["cookie banner", "1", "40", "12.0"])
    async with Client(marketing_mcp.mcp) as client:
        tools = await client.list_tools()
        print("tools:", [t.name for t in tools.tools])
        result = await client.call_tool("gsc_top_queries", {"min_impressions": 100})
        print("result:", result.structured_content)

asyncio.run(main())

Run it and you should see the two tools listed and a structured result containing only the row above the impression threshold:

tools: ['gsc_top_queries', 'check_url']
result: {'result': [{'query': 'ai crawler logs', 'clicks': 3, 'impressions': 120, 'position': 8.2}]}

That output is the proof the server works. If the tool list is empty, a decorator is missing or the module did not import. If the call raises, the error is in your function body and the traceback says where, which it will not do once a model is in between. Keep this file; it is the regression test you run after every change to the server.

For interactive poking, mcp dev marketing_mcp.py starts the server under the SDK’s inspector, where you can call each tool by hand and read the schema the type hints generated. It is worth one look, mostly to check the docstrings read the way you intended.

Connect it to an assistant

The default transport is stdio: the client launches the server as a subprocess and talks to it over standard input and output. That is the right choice for a server that runs on your own machine against your own files, and it is what mcp.run() does with no arguments.

For Claude Code, register it from the terminal and it is available in the next session:

claude mcp add marketing-stack -- python /path/to/marketing_mcp.py

For Claude Desktop, the SDK’s mcp install marketing_mcp.py writes the configuration entry for you. Cursor and VS Code each have a settings file that takes the same shape: a name, a command and its arguments. Because the protocol is the same, the server does not know or care which client launched it.

A server other people need to reach, a shared team server or one deployed next to the CMS, runs over Streamable HTTP instead:

if __name__ == "__main__":
    mcp.run(transport="streamable-http")

The moment a server is reachable over a network it needs authentication, and the SDK has OAuth support built into the server class for exactly that. Until you have configured it, keep the server local. Three habits are worth adopting from the first version regardless of transport.

  • Start read-only. The two tools above read a file and fetch a page. Neither can change anything. Add tools that write, publish or delete only once you have watched the assistant use the read-only ones for a while and seen how it chooses arguments.
  • Credentials in the environment, never in the file. The server will be committed to a repository, shared and pasted into a chat. API keys go in environment variables the launcher sets.
  • Validate the URL a tool is asked to fetch. check_url will fetch whatever it is given. In a real deployment restrict it to your own domains, or a prompt injected through a page the assistant reads could aim it at something internal.

Instrument it: know what the assistant is doing with your server

A connected server is a new place where work happens without a person watching, and the calculator tutorials never say what to log. Three things matter.

Which tools get called, with what arguments, how often. This tells you whether the assistant is using the server the way you designed it. A tool that is never called has a docstring problem or should not exist. A tool called with the same argument forty times in a session is a caching opportunity. Log the tool name, the arguments and the duration on every call, and the pattern shows in a week.

Which calls fail, and why. A tool raising an exception is usually reported to the assistant as an error, and the assistant will often retry, rephrase or silently move on. You want the failure in your own log with the traceback, not inferred from an odd answer.

What the assistant did with the result. This is the harder one and the one that matters for content teams: whether the review prompt produced fixes grounded in the tool output or embroidered around it. The verification gate in a content approval workflow is the place that check belongs; the server logs tell you what data the assistant had, and the gate tells you whether it used it.

You do not have to invent a schema for any of this. OpenTelemetry’s GenAI semantic conventions define spans, metrics and events for generative AI clients and include conventions for the Model Context Protocol itself, alongside the provider-specific ones. If your organisation already runs OpenTelemetry, MCP calls can land in the same traces as everything else; if it does not, the conventions are still the right list of attribute names to log, so that the day you adopt tracing the history lines up. Which tool to send those traces to is the subject of LLM observability tools for content teams.

The simplest version is a decorator around each tool function that writes one JSON line per call with the tool name, the arguments, the duration and any exception. The SDK also accepts server middleware for the same purpose when you want it in one place rather than on each function. Either way, add it before the server is connected to an assistant, not after the first odd afternoon.

Where this server goes next

The version above is a starting point and its shape is deliberate: each data source is one tool, each reference document is one resource, each recurring workflow is one prompt. Growing it means adding one of those at a time and re-running the test.

The additions that pay back first in a marketing stack are a tool over the CMS that lists posts by last-updated date, a resource holding the claims register so the assistant can check a statistic’s canonical wording before using it, and a tool over the AI crawler log analysis output so “what did GPTBot fetch this week” becomes a question rather than a script. A tool that wraps a vendor API is the last thing to add, because the vendor’s own server almost always does it better; use theirs alongside yours.

This is the layer where the content automation stack on this site stops being a set of scripts a person runs and becomes a set of tools an assistant runs under a human’s instruction. Building that layer for a client’s stack, with the tools, the tests and the logging, is the core of the AI tools and software work: the protocol is simple, and the design of what to expose and what to withhold is where the value is.

Frequently asked questions

How do I make a basic MCP server?

Install the Python SDK with pip install "mcp[cli]", create an MCPServer instance, and decorate ordinary functions with @mcp.tool(), @mcp.resource() or @mcp.prompt(). The type hints and docstrings become the schema the assistant sees. Call mcp.run() at the bottom of the file and the server speaks the protocol over standard input and output. The full working example above is about 40 lines including the docstrings.

How much does it cost to build an MCP server?

The software is free: the SDK is open source and a local stdio server needs no hosting. The cost is the time to design what to expose, which is a few hours for a first server like the one above, and the ongoing cost of whatever the tools call. A tool that reads a CSV costs nothing per call; a tool that wraps a paid API costs that API’s rate each time the assistant uses it, which is why call logging matters from day one.

How do I set up an MCP server in Claude Code or Claude Desktop?

For Claude Code, run claude mcp add with a name and the command that starts your server, and it is available in the next session. For Claude Desktop, the SDK’s mcp install command writes the configuration entry. Both launch the server as a subprocess over stdio, so there is nothing to host; the server file just needs to be on the machine running the client.

How do I create a remote MCP server?

Change the transport: mcp.run(transport="streamable-http") serves the same tools over HTTP for clients on other machines. A remote server needs authentication before anyone but you can reach it; the SDK includes OAuth support for that. Keep the first version local over stdio, get the tools and tests right, and move to Streamable HTTP only when a second person or a deployed system needs to call it.

Do I need Python to build an MCP server?

No. The protocol is language-neutral and official SDKs exist for TypeScript and other languages, so a team that lives in Node can build the same server there. Python is used here because most marketing data work already happens in Python and pandas, and the SDK’s decorator style keeps the server short. The design advice, tools for actions, resources for reference data, prompts for workflows, is the same in any language.