Build a Zoom Meeting Notetaker MCP Server

Build an MCP server that lets Claude read Zoom transcripts and save notes to Notion.

Introduction

30 Second Summary

After every meeting, you promise yourself you'll organize the notes later. A week passes, and you're scrolling through a wall of transcript text trying to remember who said what.

In this project, you will build a Python MCP server that gives Claude Desktop the ability to read your Zoom meeting transcripts and save structured notes directly to Notion.

What You'll Build

A Model Context Protocol server with custom tools that Claude Desktop can call to read transcripts and create formatted Notion pages.

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

  • A Python MCP server with three custom tools that Claude Desktop can call on demand.
  • A configured Claude Desktop connection that lets you ask Claude to process your meeting transcripts in natural language.
  • A Notion integration that creates structured meeting notes pages with summaries, action items, and key decisions.
  • Secret Mission: Add a search tool that finds mentions of specific topics across all your meeting transcripts.

Are there any prerequisites?

You'll need Python installed (version 3.10 or higher), VS Code, and a Claude Desktop Pro subscription. You'll also create a free Notion account during the project if you don't have one already.

Set Up Your Project Environment

Before you can build an MCP server, you need the right tools in place. Your server will be a Python project managed by uv, a fast Python package manager that handles dependencies and virtual environments.

In this step, you will confirm your Python version, install uv, scaffold your project, and set up a Notion integration so Claude can save meeting notes later.

In this step, get ready to:
  • Install uv and verify your Python version is 3.10 or higher.
  • Create the zoom-notetaker project with the MCP SDK and httpx dependencies.
  • Create a Notion integration and get a Personal Access Token.
Install uv and verify Python version

The Model Context Protocol SDK requires Python 3.10 or higher. You will also need uv to manage your project's dependencies and virtual environment.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Python version by running:

macOS

python3 --version

Windows

python --version

You should see a version number printed in the terminal.

✔️ I see version 3.10 or higher

Your Python version is ready for this project. Move on to the next instruction below.

ⓧ I see an older version

You need Python 3.10 or higher for the MCP SDK. Download the latest version from python.org/downloads and install it.

On macOS, the system python3 may still point to the older version after installing. If so, use the full path (e.g. python3.12 --version) or install via pyenv to manage versions.

On Windows, the new installer typically updates the python command. After installing, close and reopen PowerShell, then run the version check again.

ⓧ Command not found

Python is not installed on your system. Download Python 3.10+ from python.org/downloads and run the installer.

On Windows, make sure to check Add Python to PATH during installation. After installing, close and reopen PowerShell, then run the version check again.

  • Install uv by running:
pip install uv
  • Verify uv installed correctly by running:
uv --version

You should see a version number printed, confirming uv is installed.

Command not found after installing uv?

If uv --version gives you an error, try closing and reopening your terminal. The new command may not be available until you start a fresh session.

On macOS, you may need to use pip3 install uv instead if pip is not recognized.

help me troubleshoot my uv installation

Create the project with uv

With uv ready, you can scaffold a new Python project. This creates a folder with a pyproject.toml and a virtual environment that keeps your dependencies isolated.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder by running:
uv init zoom-notetaker
  • Move into your new project folder by running:
cd zoom-notetaker
  • Add the MCP SDK with CLI tools by running:
uv add "mcp[cli]"

What does mcp[cli] mean?

The [cli] part is a Python "extra" that installs additional packages (like typer and python-dotenv) needed for MCP's developer tools. This gives you the MCP Inspector for testing your server later.

  • Add the httpx dependency for making HTTP requests by running:
uv add httpx
  • Verify everything is installed correctly by running:
uv run python -c "from mcp.server.fastmcp import FastMCP; print('MCP SDK ready')"

You should see MCP SDK ready printed in your terminal.

Seeing an import error?

If you see ModuleNotFoundError, make sure you ran uv add "mcp[cli]" from inside the zoom-notetaker folder. The uv run command only has access to packages installed in the current project.

help me fix the MCP SDK import error in my uv project

Create a Notion integration and get a Personal Access Token

Your MCP server will send structured meeting notes to Notion using the Notion API. To authorize those requests, you need a Personal Access Token from a Notion integration.

  • Open your browser and go to notion.so/profile/integrations.
  • Click New integration (or Create new integration).
  • Give it the name Zoom Notetaker and select your workspace.
  • Click Submit to create the integration.
  • Copy the Personal Access Token that appears. It starts with ntn_.
  • Save this token in a safe place (a text file or password manager). You will need it in a later step.

What is a Personal Access Token?

A Personal Access Token lets your code make requests to the Notion API on your behalf. It acts like a password that identifies your integration to Notion's servers.

Keep it private. Anyone with this token can read and write to the pages your integration has access to.

Next, create the Notion page where your meeting notes will live.

  • Open Notion in your browser or desktop app.
  • Create a new page called Meeting Notes in your workspace.
  • Click Share in the top-right corner of the page.
  • Click Copy link to copy the page URL.
  • Paste the URL somewhere you can inspect it (like your text editor).

How do I find the page ID?

The page ID is the 32-character string at the end of the URL. For example, in https://www.notion.so/Meeting-Notes-1429989fe8ac4effbc8f57f56486db54, the page ID is 1429989fe8ac4effbc8f57f56486db54.

Copy just that 32-character hex string and save it alongside your token. You will use both values when configuring environment variables later.

  • Copy the 32-character page ID from the URL and save it next to your token.

Not sure which part of the URL is the page ID?

The page ID always comes after the last dash in the URL. It should be exactly 32 characters and contain only letters (a-f) and numbers.

If your URL has a question mark (?) after the ID, ignore everything from the question mark onward.

help me find my Notion page ID from the URL

Your environment is ready. Next up, you will build the transcript reader tools that let Claude list and read your Zoom meeting files.

Build the Transcript Reader Tools

Your project environment is ready with all the dependencies installed. Now it is time to build the actual server that Claude will connect to.

An MCP server exposes tools that a client (like Claude Desktop) can call on demand. In this step, you will create two tools: one that lists all available Zoom transcripts and another that reads a specific transcript file. These give Claude the ability to see and read your meeting recordings.

In this step, get ready to:
  • Create an MCP server file with transcript-reading tools.
  • Build a sample transcript so you can test without a real meeting.
  • Verify both tools work using the MCP Inspector.
Create the MCP server with FastMCP

The FastMCP class is the foundation of your server. It handles all the protocol details so you can focus on writing the tools Claude will call.

  • In VS Code, create a new file called server.py inside your zoom-notetaker folder.
  • Add the following imports, server instance, and environment variable constants:
import os
from pathlib import Path
from datetime import datetime

import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("zoom-notetaker")

# Directory where Zoom saves transcripts locally
ZOOM_TRANSCRIPT_DIR = os.getenv(
    "ZOOM_TRANSCRIPT_DIR",
    str(Path.home() / "Documents" / "Zoom")
)
# Notion credentials (used in a later step)
NOTION_API_KEY = os.getenv("NOTION_API_KEY", "")
NOTION_PAGE_ID = os.getenv("NOTION_PAGE_ID", "")

What does this code do?

FastMCP("zoom-notetaker") creates your MCP server instance. The string is the server's display name that clients will see.

The three os.getenv() calls read configuration from environment variables. ZOOM_TRANSCRIPT_DIR defaults to the standard Zoom transcript folder on Windows. httpx is imported now because you will use it for Notion API calls in the next step.

Now add the first tool. This tool scans the transcript directory and returns a list of available files with their dates and sizes.

  • Below the constants in server.py, add the list_transcripts tool:

@mcp.tool()
async def list_transcripts() -> str:
    """List all available Zoom meeting transcripts.

    Returns a list of transcript filenames with their dates and sizes.
    """
    transcript_dir = Path(ZOOM_TRANSCRIPT_DIR)
    if not transcript_dir.exists():
        return f"Transcript directory not found: {ZOOM_TRANSCRIPT_DIR}"

    # Collect .vtt and .txt files from all subdirectories
    files = []
    for pattern in ["**/*.vtt", "**/*.txt"]:
        files.extend(transcript_dir.glob(pattern))

    if not files:
        return "No transcript files found in the Zoom folder."

    # Sort by most recently modified first
    files.sort(key=lambda f: f.stat().st_mtime, reverse=True)

    # Format the results, capped at 20 files
    result = []
    for f in files[:20]:
        modified = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
        size_kb = f.stat().st_size / 1024
        result.append(f"- {f.name} (Modified: {modified}, Size: {size_kb:.1f} KB)")

    return f"Found {len(files)} transcript(s):\n" + "\n".join(result)

What does this code do?

The @mcp.tool() decorator registers this function as a tool that Claude can call. The docstring becomes the tool's description that Claude reads to decide when to use it.

The function uses glob with **/*.vtt to search recursively through subdirectories. Zoom organizes transcripts in per-meeting folders, so recursive search is essential. Results are sorted by modification date and capped at 20.

  • Save server.py.

You will not see output yet. The next substep adds the second tool, and then you will test both together with a sample transcript.

Add the get_transcript tool

Listing files is useful, but Claude also needs to read a transcript's content. This second tool takes a filename and returns the full text so Claude can summarize it.

  • Below the list_transcripts function in server.py, add the get_transcript tool:

@mcp.tool()
async def get_transcript(filename: str) -> str:
    """Read the contents of a specific Zoom meeting transcript.

    Args:
        filename: The name of the transcript file to read.
    """
    transcript_dir = Path(ZOOM_TRANSCRIPT_DIR)

    # Search recursively since Zoom nests files in meeting folders
    matches = list(transcript_dir.rglob(filename))

    if not matches:
        return f"Transcript file not found: {filename}"

    file_path = matches[0]

    try:
        content = file_path.read_text(encoding="utf-8")
        return content
    except Exception as e:
        return f"Error reading transcript: {e}"

What does this code do?

This tool accepts a filename parameter (which Claude fills in based on the list_transcripts output) and uses rglob to find it anywhere in the transcript directory tree.

It returns the entire text content of the file. Claude then uses this raw transcript to generate structured meeting notes.

  • Save server.py.

You will test this tool in the next substep once the sample transcript is in place.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.py with both tools before moving on.

ⓧ I'd like to double check the full code

Here is the complete server.py so far:

import os
from pathlib import Path
from datetime import datetime

import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("zoom-notetaker")

ZOOM_TRANSCRIPT_DIR = os.getenv(
    "ZOOM_TRANSCRIPT_DIR",
    str(Path.home() / "Documents" / "Zoom")
)
NOTION_API_KEY = os.getenv("NOTION_API_KEY", "")
NOTION_PAGE_ID = os.getenv("NOTION_PAGE_ID", "")


@mcp.tool()
async def list_transcripts() -> str:
    """List all available Zoom meeting transcripts.

    Returns a list of transcript filenames with their dates and sizes.
    """
    transcript_dir = Path(ZOOM_TRANSCRIPT_DIR)
    if not transcript_dir.exists():
        return f"Transcript directory not found: {ZOOM_TRANSCRIPT_DIR}"

    files = []
    for pattern in ["**/*.vtt", "**/*.txt"]:
        files.extend(transcript_dir.glob(pattern))

    if not files:
        return "No transcript files found in the Zoom folder."

    files.sort(key=lambda f: f.stat().st_mtime, reverse=True)

    result = []
    for f in files[:20]:
        modified = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
        size_kb = f.stat().st_size / 1024
        result.append(f"- {f.name} (Modified: {modified}, Size: {size_kb:.1f} KB)")

    return f"Found {len(files)} transcript(s):\n" + "\n".join(result)


@mcp.tool()
async def get_transcript(filename: str) -> str:
    """Read the contents of a specific Zoom meeting transcript.

    Args:
        filename: The name of the transcript file to read.
    """
    transcript_dir = Path(ZOOM_TRANSCRIPT_DIR)

    matches = list(transcript_dir.rglob(filename))

    if not matches:
        return f"Transcript file not found: {filename}"

    file_path = matches[0]

    try:
        content = file_path.read_text(encoding="utf-8")
        return content
    except Exception as e:
        return f"Error reading transcript: {e}"
Create a sample transcript for testing

You need a transcript file to test against. Instead of recording a real meeting, you will create a sample VTT file with realistic dialogue including action items and decisions.

  • Create the sample_transcripts folder inside your project by running this command:
mkdir sample_transcripts
  • In VS Code, create a new file called standup_2026-07-07.vtt inside the sample_transcripts folder.
  • Paste the following content into the file:
WEBVTT

00:00:01.000 --> 00:00:05.000
Sarah: Good morning everyone. Let's get started with our weekly standup.

00:00:06.000 --> 00:00:12.000
Sarah: Quick reminder, we need to finalize the Q3 budget by end of this week.

00:00:13.000 --> 00:00:22.000
James: I finished the API integration with the payment provider yesterday. It's in code review now. Should be merged by tomorrow.

00:00:23.000 --> 00:00:35.000
James: One blocker though - I need access to the staging environment credentials. Can someone from DevOps help me with that?

00:00:36.000 --> 00:00:45.000
Maria: I can get you those credentials this afternoon, James. I'll send them through the secure channel.

00:00:46.000 --> 00:00:58.000
Maria: On my end, the user dashboard redesign is about 80% complete. I'm waiting on the final design assets from the design team.

00:00:59.000 --> 00:01:10.000
Sarah: Great progress everyone. Let's make sure we hit the Friday deadline for the budget review. James, once you have staging access, can you run the full integration test suite?

00:01:11.000 --> 00:01:18.000
James: Absolutely. I'll have the test results by Thursday at the latest.

00:01:19.000 --> 00:01:30.000
Sarah: Perfect. One more thing - we've decided to go with AWS Lambda for the notification service instead of running our own server. Cost savings are significant.

00:01:31.000 --> 00:01:38.000
Maria: Makes sense. I'll update the architecture diagram to reflect that change.

00:01:39.000 --> 00:01:45.000
Sarah: Thanks everyone. Let's sync again on Thursday to check progress. Meeting adjourned.

Why a sample transcript?

This VTT file simulates a real Zoom meeting with multiple speakers, action items, and key decisions. Using a sample means you can test the full pipeline without recording a meeting first.

Zoom saves local transcripts in WebVTT format (.vtt) with timestamps and speaker labels. Your tools will point at the sample_transcripts folder during development using the ZOOM_TRANSCRIPT_DIR environment variable.

  • Save standup_2026-07-07.vtt.
  • Confirm the file exists by running:
ls sample_transcripts

You should see standup_2026-07-07.vtt in the output.

Now test both tools using the MCP Inspector. This command launches a web-based testing UI where you can call your tools and see their output.

  • Launch the MCP Inspector by running:
uv run mcp dev server.py

What is the MCP Inspector?

The MCP Inspector is an interactive testing UI that connects to your server and lets you call tools manually. It opens in your browser and shows all registered tools with their input schemas and output.

  • When the Inspector opens in your browser, find the environment configuration in the Server connection pane.
  • Add an environment variable: set ZOOM_TRANSCRIPT_DIR to ./sample_transcripts.
  • Click the **Tools** tab to see your registered tools.
  • Click list_transcripts and run it. You should see the sample file listed with its name, date, and size.
  • Click get_transcript, enter standup_2026-07-07.vtt as the filename, and run it. You should see the full VTT content returned.

Inspector not showing your tools?

Make sure you saved server.py before running the command. The Inspector reads the file at launch.

If list_transcripts returns "Transcript directory not found", check that you set ZOOM_TRANSCRIPT_DIR to ./sample_transcripts in the Inspector's environment configuration. The relative path must match where your terminal is running from.

help me debug why the MCP Inspector is not finding my tools or transcript files

Your transcript reader tools are working. Next up, you will build the Notion integration tool that saves Claude's meeting summaries as structured pages.

Build the Notion Integration Tool

Your MCP server can now read transcripts from a local folder. The next piece is giving Claude the ability to push structured notes somewhere useful.

In this step, you will add a third tool that sends meeting summaries to Notion via the Notion API. You will also create an environment file to store your credentials, then test the full round-trip in the MCP Inspector.

In this step, get ready to:
  • Add a save_to_notion tool that creates structured pages in your Notion workspace.
  • Create a .env file to store your Notion credentials and transcript path.
  • Verify the integration end-to-end by calling the tool from the MCP Inspector and confirming the page appears in Notion.
Add the save_to_notion tool

The Notion API lets you create pages programmatically by sending a POST request with the page's parent, title, and content blocks. Your new tool will accept structured meeting data from Claude and format it into the API's expected shape.

  • In VS Code, open server.py inside your zoom-notetaker folder.
  • Scroll to the end of the get_transcript function (after the except Exception as e: block's return statement).
  • Add the following code below the get_transcript function:
@mcp.tool()
async def save_to_notion(title: str, summary: str, action_items: str, key_decisions: str) -> str:
    """Save meeting notes to a Notion page.

    Args:
        title: The title for the meeting notes page.
        summary: A brief summary of the meeting.
        action_items: A list of action items from the meeting.
        key_decisions: Key decisions made during the meeting.
    """
    # Check that credentials are configured
    if not NOTION_API_KEY:
        return "Error: NOTION_API_KEY environment variable is not set."
    if not NOTION_PAGE_ID:
        return "Error: NOTION_PAGE_ID environment variable is not set."

    # Set up headers for Notion API authentication
    headers = {
        "Authorization": f"Bearer {NOTION_API_KEY}",
        "Content-Type": "application/json",
        "Notion-Version": "2022-06-28",
    }

    # Build the page body with structured meeting notes
    body = {
        "parent": {"page_id": NOTION_PAGE_ID},
        "properties": {
            "title": {
                "title": [{"type": "text", "text": {"content": title}}]
            }
        },
        "children": [
            {
                "object": "block",
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [{"type": "text", "text": {"content": "Summary"}}]
                },
            },
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": summary}}]
                },
            },
            {
                "object": "block",
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [{"type": "text", "text": {"content": "Action Items"}}]
                },
            },
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": action_items}}]
                },
            },
            {
                "object": "block",
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [{"type": "text", "text": {"content": "Key Decisions"}}]
                },
            },
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": key_decisions}}]
                },
            },
        ],
    }

    # Send the request to Notion
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://api.notion.com/v1/pages",
            headers=headers,
            json=body,
            timeout=30.0,
        )

    # Return success or error message
    if response.status_code == 200:
        page_url = response.json().get("url", "")
        return f"Meeting notes saved successfully to Notion! URL: {page_url}"
    else:
        return f"Error saving to Notion: {response.status_code} - {response.text}"


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

What does this code do?

This tool accepts four string parameters that Claude will fill in after reading a transcript: the page title, a summary, action items, and key decisions.

It builds a JSON body matching the Notion API's block structure. Each section becomes a heading_2 block followed by a paragraph block. The Notion-Version: 2022-06-28 header tells Notion which API version to use.

The if __name__ == "__main__" block at the bottom starts the MCP server using stdio transport when the file is run directly.

  • Save server.py.

Seeing a syntax error?

Make sure there is one blank line between the end of get_transcript and the new @mcp.tool() decorator. Python requires blank lines between top-level function definitions.

Also check that the indentation of every line inside save_to_notion uses four spaces, not tabs.

help me fix a syntax error in my save_to_notion MCP tool

✔️ Awesome, I've got everything!

Great. Double check you have saved server.py before moving on.

ⓧ I'd like to double check the full code

import os
from pathlib import Path
from datetime import datetime

import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("zoom-notetaker")

ZOOM_TRANSCRIPT_DIR = os.getenv(
    "ZOOM_TRANSCRIPT_DIR",
    str(Path.home() / "Documents" / "Zoom")
)
NOTION_API_KEY = os.getenv("NOTION_API_KEY", "")
NOTION_PAGE_ID = os.getenv("NOTION_PAGE_ID", "")


@mcp.tool()
async def list_transcripts() -> str:
    """List all available Zoom meeting transcripts.

    Returns a list of transcript filenames with their dates and sizes.
    """
    transcript_dir = Path(ZOOM_TRANSCRIPT_DIR)
    if not transcript_dir.exists():
        return f"Transcript directory not found: {ZOOM_TRANSCRIPT_DIR}"

    files = []
    for pattern in ["**/*.vtt", "**/*.txt"]:
        files.extend(transcript_dir.glob(pattern))

    if not files:
        return "No transcript files found in the Zoom folder."

    files.sort(key=lambda f: f.stat().st_mtime, reverse=True)

    result = []
    for f in files[:20]:
        modified = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
        size_kb = f.stat().st_size / 1024
        result.append(f"- {f.name} (Modified: {modified}, Size: {size_kb:.1f} KB)")

    return f"Found {len(files)} transcript(s):\n" + "\n".join(result)


@mcp.tool()
async def get_transcript(filename: str) -> str:
    """Read the contents of a specific Zoom meeting transcript.

    Args:
        filename: The name of the transcript file to read.
    """
    transcript_dir = Path(ZOOM_TRANSCRIPT_DIR)

    matches = list(transcript_dir.rglob(filename))

    if not matches:
        return f"Transcript file not found: {filename}"

    file_path = matches[0]

    try:
        content = file_path.read_text(encoding="utf-8")
        return content
    except Exception as e:
        return f"Error reading transcript: {e}"


@mcp.tool()
async def save_to_notion(title: str, summary: str, action_items: str, key_decisions: str) -> str:
    """Save meeting notes to a Notion page.

    Args:
        title: The title for the meeting notes page.
        summary: A brief summary of the meeting.
        action_items: A list of action items from the meeting.
        key_decisions: Key decisions made during the meeting.
    """
    if not NOTION_API_KEY:
        return "Error: NOTION_API_KEY environment variable is not set."
    if not NOTION_PAGE_ID:
        return "Error: NOTION_PAGE_ID environment variable is not set."

    headers = {
        "Authorization": f"Bearer {NOTION_API_KEY}",
        "Content-Type": "application/json",
        "Notion-Version": "2022-06-28",
    }

    body = {
        "parent": {"page_id": NOTION_PAGE_ID},
        "properties": {
            "title": {
                "title": [{"type": "text", "text": {"content": title}}]
            }
        },
        "children": [
            {
                "object": "block",
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [{"type": "text", "text": {"content": "Summary"}}]
                },
            },
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": summary}}]
                },
            },
            {
                "object": "block",
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [{"type": "text", "text": {"content": "Action Items"}}]
                },
            },
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": action_items}}]
                },
            },
            {
                "object": "block",
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [{"type": "text", "text": {"content": "Key Decisions"}}]
                },
            },
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": key_decisions}}]
                },
            },
        ],
    }

    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://api.notion.com/v1/pages",
            headers=headers,
            json=body,
            timeout=30.0,
        )

    if response.status_code == 200:
        page_url = response.json().get("url", "")
        return f"Meeting notes saved successfully to Notion! URL: {page_url}"
    else:
        return f"Error saving to Notion: {response.status_code} - {response.text}"


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

Your server reads NOTION_API_KEY, NOTION_PAGE_ID, and ZOOM_TRANSCRIPT_DIR using os.getenv() at the top of the file. You need a .env file to supply the actual values.

  • Create a new file called .env in the root of your zoom-notetaker folder.
  • Paste the following into .env:
NOTION_API_KEY=your-notion-api-key-here
NOTION_PAGE_ID=your-notion-page-id-here
ZOOM_TRANSCRIPT_DIR=./sample_transcripts
  • Replace your-notion-api-key-here with the Personal Access Token you copied earlier (the one starting with ntn_).
  • Replace your-notion-page-id-here with the 32-character page ID from your Notion Meeting Notes page URL.

What does each variable do?

  • NOTION_API_KEY authenticates your requests to the Notion API.
  • NOTION_PAGE_ID tells the tool which page to create child pages under.
  • ZOOM_TRANSCRIPT_DIR points to ./sample_transcripts for testing so you don't need a real Zoom recording yet.
  • Save .env.
  • Confirm both files exist by running:
ls

You should see .env, server.py, and sample_transcripts in the output.

Keep .env out of version control

If you ever push this project to GitHub, add .env to your .gitignore file so your Notion token is not exposed publicly.

✔️ Awesome, I've got everything!

Great. Double check you have saved .env with your real token and page ID before moving on.

ⓧ I'd like to double check the full code

NOTION_API_KEY=your-notion-api-key-here
NOTION_PAGE_ID=your-notion-page-id-here
ZOOM_TRANSCRIPT_DIR=./sample_transcripts
Test the Notion connection with the MCP Inspector

The MCP Inspector lets you call your tools manually without needing Claude Desktop. You will pass your environment variables to the dev server so it can authenticate with Notion.

  • In your terminal (still inside the zoom-notetaker folder), set your environment variables and launch the Inspector by running:
$env:NOTION_API_KEY = "your-notion-api-key-here"
$env:NOTION_PAGE_ID = "your-notion-page-id-here"
$env:ZOOM_TRANSCRIPT_DIR = "./sample_transcripts"
uv run mcp dev server.py
  • Replace the placeholder values with your actual Notion token and page ID.
  • The MCP Inspector opens in your browser. You should see a web interface with your server's tools listed.

What is the MCP Inspector?

The MCP Inspector is a visual testing tool that acts as an MCP client. It connects to your server the same way Claude Desktop will, letting you call each tool with custom inputs and inspect the responses.

  • In the Inspector, click save_to_notion from the tools list.
  • Fill in the parameters with test data:
  • title: Test Meeting Notes
  • summary: This is a test summary from the MCP Inspector.
  • action_items: 1. Review PR 2. Update docs
  • key_decisions: Decided to use AWS Lambda for notifications.
  • Click the Run Tool button.

You should see a success response that includes a Notion URL. This confirms your server can create pages in your workspace.

  • Open Notion and navigate to your Meeting Notes page.
  • Confirm that a child page called Test Meeting Notes appeared with Summary, Action Items, and Key Decisions sections.

Getting a 401 or 404 error?

A 401 error means your token is wrong or expired. Double check the NOTION_API_KEY value you set in your terminal matches the token from your Notion integrations page (starting with ntn_).

A 404 error means the page ID is incorrect. Go back to your Meeting Notes page URL in Notion and copy the 32-character hex string that appears after the page title. Make sure there are no extra characters or spaces.

help me debug a Notion API error in my MCP server

Your MCP server now has all three tools wired up and tested. Next, you will connect it to Claude Desktop so Claude can call these tools during a conversation.

Connect to Claude Desktop

Your MCP server is built, tested, and ready to connect to Claude Desktop. The final piece is telling Claude Desktop how to find and launch your server.

Claude Desktop uses a JSON config file to know which MCP servers to start. Once configured, Claude automatically launches your server as a background process every time you open the app.

In this step, get ready to:
  • Edit the Claude Desktop config file to register your MCP server.
  • Restart Claude Desktop and verify the tools appear.
  • Test the full pipeline from transcript reading to Notion page creation.
Locate and edit the Claude Desktop config file

Claude Desktop reads a file called claude_desktop_config.json to know which MCP servers to launch. You need to add your zoom-notetaker server to this file.

  • In Claude Desktop, click the Claude menu in your system's menu bar (not the settings inside the Claude window).
  • Select Settings...
  • In the Settings window, click the Developer tab in the left sidebar.
  • Click Edit Config. This opens the config file in your default editor. If the file does not exist yet, Claude Desktop creates it for you.

Where is this file?

On Windows, the config file lives at %APPDATA%\Claude\claude_desktop_config.json. On macOS, it lives at ~/Library/Application Support/Claude/claude_desktop_config.json.

The Edit Config button opens the correct file automatically, so you do not need to navigate to it manually.

  • Replace the contents of the file with the following JSON, updating the path to match your zoom-notetaker folder location:
{
  "mcpServers": {
    "zoom-notetaker": {
      "command": "uv",
      "args": [
        "--directory",
        "[[ZOOM_NOTETAKER_PATH=\"C:/Users/YourName/Desktop/zoom-notetaker\"]]",
        "run",
        "server.py"
      ],
      "env": {
        "NOTION_API_KEY": "your-notion-api-key-here",
        "NOTION_PAGE_ID": "your-notion-page-id-here",
        "ZOOM_TRANSCRIPT_DIR": "./sample_transcripts"
      }
    }
  }
}

What does this config do?

  • "command": "uv" tells Claude Desktop to launch the server using uv.
  • "--directory" points uv to your project folder so it can find server.py and its dependencies.
  • The env field passes your Notion credentials and transcript directory to the server process. MCP servers launched over stdio inherit only a limited set of environment variables, so you must specify them here.

You must use the absolute path to your zoom-notetaker folder. On Windows, use forward slashes or double backslashes in the path.

  • Replace your-notion-api-key-here with the Notion Personal Access Token you saved earlier (starts with ntn_).
  • Replace your-notion-page-id-here with the 32-character page ID from your Notion "Meeting Notes" page URL.
  • Save the file.

Not sure what your absolute path is?

Open the terminal where you built the project, navigate into your zoom-notetaker folder, and run cd on Windows (which prints the current directory) or pwd on macOS/Linux. Copy the full path it prints.

Help me find my project's absolute path for the config

Restart Claude Desktop and verify the connection

Claude Desktop only reads the config file at startup. You need to fully quit and relaunch it for the new server to appear.

  • Right-click the Claude Desktop icon in your system tray (bottom-right on Windows, top menu bar on macOS).
  • Click Quit. Closing the window is not enough. You must fully quit the app.
  • Relaunch Claude Desktop.
  • Look for the MCP tools icon (a hammer icon) in the chat input area at the bottom of the window.
  • Click the hammer icon. You should see your three tools listed: list_transcripts, get_transcript, and save_to_notion.

Don't see the hammer icon?

If the MCP tools icon does not appear after restarting, check these common causes:

  • Make sure you fully quit Claude Desktop from the system tray, not just closed the window.
  • Verify your JSON syntax is valid. A missing comma or mismatched bracket will silently prevent the server from loading.
  • Confirm the absolute path in --directory points to the folder containing server.py and pyproject.toml.
  • On Windows with the MSIX install, Claude Desktop may read from a virtualized path. If editing via Edit Config did not work, check %LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\ for the actual config file.

My MCP server is not appearing in Claude Desktop after restart

Test the full workflow

With the connection confirmed, it is time to test the entire pipeline. You will ask Claude to read your sample transcript, summarize it, and save structured notes to Notion.

  • Start a new conversation in Claude Desktop.
  • Type: "List my available meeting transcripts"
  • Claude will ask for permission to use the list_transcripts tool. Click Allow to grant access.
  • Verify Claude responds with a list showing standup_2026-07-07.vtt.

Why does Claude ask for permission?

Claude Desktop requires explicit user approval before calling any MCP tool. This is a safety feature. You will see a permission prompt the first time each tool is used in a conversation.

  • Type: "Read the sample meeting transcript and summarize it"
  • Allow the get_transcript tool when prompted.
  • Claude reads the full VTT file and produces a summary of the meeting content.
  • Type: "Save those meeting notes to Notion with a clear summary, action items, and key decisions"
  • Allow the save_to_notion tool when prompted.
  • Claude calls the tool and confirms the Notion page was created, including a URL to the new page.
  • Open Notion and navigate to your "Meeting Notes" page.
  • Confirm a new child page was created by Claude with Summary, Action Items, and Key Decisions sections filled in by Claude's analysis of the transcript.

Getting a 401 or 404 error from Notion?

  • A 401 error means your Notion token is incorrect. Double-check that the NOTION_API_KEY value in your config matches the token from your Notion integration (starts with ntn_).
  • A 404 error means the page ID is wrong or the page is not accessible. Verify the 32-character NOTION_PAGE_ID matches the hex string in your Notion page URL.

Claude is returning a Notion API error when saving meeting notes

Your MCP server is live and connected. You now have a personal meeting notes pipeline where a single conversation with Claude can read, summarize, and save your meeting transcripts to Notion.

Secret mission

Add a Search Transcripts Tool

Your MCP server can list transcripts and read individual files. But what if you have dozens of meeting recordings and need to find every time someone mentioned 'budget' or 'deadline'? Add a search tool that lets Claude find specific topics across all your transcripts at once.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with free services, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local project folder zoom-notetaker with Python virtual environment (disk space only).
  • Notion Personal Access Token (free, no expiration unless revoked).
  • Meeting notes child pages in your Notion workspace (free).
  • MCP server entry in claude_desktop_config.json.

Keep everything running

No action needed. Choose this if you're still actively building or plan to use the MCP server regularly.

  • The MCP server only runs when Claude Desktop launches it. There are no background processes or ongoing costs.
  • Your Notion token remains active for future use.
  • The zoom-notetaker folder stays on your machine and takes minimal disk space.

Pause - I'll come back to this later

Close Claude Desktop to stop the MCP server. Since the server uses stdio transport, it stops automatically when Claude Desktop closes.

  • Right-click the Claude Desktop icon in your system tray and click Quit.
  • Your project files, Notion token, and config entry remain intact for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete the project folder by running this in your terminal:
  • macOS/Linux:
  • Windows (PowerShell):
rm -rf zoom-notetaker

On Windows, run this instead:

Remove-Item -Recurse -Force zoom-notetaker
  • Revoke your Notion Personal Access Token:
  • Go to the Notion Developer portal at notion.so/profile/integrations.
  • Find the token named "Zoom Notetaker" and revoke it.
  • Delete the meeting notes pages in Notion:
  • Open your "Meeting Notes" page in Notion.
  • Delete any child pages created by Claude during testing.
  • Remove the MCP server entry from Claude Desktop's config:
  • Open claude_desktop_config.json (press Win+R, type %APPDATA%\Claude, and open the file).
  • Delete the zoom-notetaker entry from the mcpServers object, then save the file.
  • Restart Claude Desktop for the change to take effect.

Nice Work!

Nice Work!

You just built a personal meeting notes pipeline powered by Claude Desktop. Your MCP server gives Claude the ability to read Zoom transcripts from your local file system and push structured notes directly to Notion.

You've learned how to:

  • Build a Python MCP server using FastMCP with custom tools that Claude Desktop can call on demand.
  • Integrate with the Notion API to programmatically create structured meeting notes pages from transcript data.
  • Test a full end-to-end pipeline from transcript reading to AI summarization to Notion page creation.
  • Secret Mission: Added cross-transcript search so Claude can find specific topics across all your meetings.

Ready to quiz yourself?