<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ mcp - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ mcp - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 03 Jun 2026 17:23:37 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/mcp/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Connect Your AI Coding Agent to a Browser on macOS  ]]>
                </title>
                <description>
                    <![CDATA[ AI coding agents like Claude Code, Cursor, and the rest have gotten remarkably good at reading and writing code. But the moment they need to look at something on the web, they hit a wall. They can't s ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-connect-your-ai-coding-agent-to-a-browser-on-macos/</link>
                <guid isPermaLink="false">6a1594c1da253d50d4ae1277</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ macOS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer Tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ אחיה כהן ]]>
                </dc:creator>
                <pubDate>Tue, 26 May 2026 12:40:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/7e77f1c5-6942-4dbe-a3c6-ca74cc4354e5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>AI coding agents like Claude Code, Cursor, and the rest have gotten remarkably good at reading and writing code. But the moment they need to <em>look at something on the web</em>, they hit a wall. They can't see your staging site. They can't read the error in your analytics dashboard. They can't check whether the form they just built actually submits.</p>
<p>The usual fix is to hand the agent a headless browser — Puppeteer or Playwright driving a fresh Chromium instance. That works, sort of. But a headless Chromium starts every session as a stranger: no logins, no cookies, no sessions. It spins up a second browser engine that pushes your CPU and spins up your fan. And a growing number of sites simply block it on sight.</p>
<p>There's another option, and on a Mac it's a good one: let the agent drive the <strong>Safari you already use</strong> — the one that's already logged into GitHub, your analytics, your staging environment. That's what Safari MCP does. It's an open-source MCP server that exposes Safari to any MCP-capable agent through around 80 tools, with no Chromium, no WebDriver, and no separate browser to babysit.</p>
<p>In this tutorial you'll connect Safari MCP to an AI agent, run your first automation, and then build something a headless browser fundamentally cannot do: an automation that works inside a page you're logged into. By the end you'll understand not just <em>how</em> to wire this up, but <em>when</em> native browser automation is the right call — and when it isn't.</p>
<p>Here's what you'll need:</p>
<ul>
<li><p>A Mac (Safari MCP is macOS-only — more on that trade-off later)</p>
</li>
<li><p>Node.js 18 or newer</p>
</li>
<li><p>An MCP-capable AI agent — this tutorial uses Claude Code and Cursor, but any MCP client works</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-mcp-and-why-does-browser-automation-need-it">What is MCP, and Why Does Browser Automation Need It?</a></p>
</li>
<li><p><a href="#heading-why-safari-instead-of-chrome-or-playwright">Why Safari Instead of Chrome or Playwright?</a></p>
</li>
<li><p><a href="#heading-installing-safari-mcp">Installing Safari MCP</a></p>
</li>
<li><p><a href="#heading-your-first-automation-reading-a-page">Your First Automation: Reading a Page</a></p>
</li>
<li><p><a href="#heading-the-payoff-automating-a-logged-in-workflow">The Payoff: Automating a Logged-in Workflow</a></p>
</li>
<li><p><a href="#heading-handling-the-tricky-parts">Handling the Tricky Parts</a></p>
</li>
<li><p><a href="#heading-limitations-when-not-to-use-this">Limitations: When Not to Use This</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-what-is-mcp-and-why-does-browser-automation-need-it">What is MCP, and Why Does Browser Automation Need It?</h2>
<p>Before wiring anything up, it helps to know what the "MCP" in Safari MCP stands for.</p>
<p><strong>MCP</strong> is the Model Context Protocol — an open standard for connecting AI agents to external tools and data. Think of it the way you'd think of a USB port. Before USB, every device needed its own connector. MCP is the equivalent of agreeing on one connector: an agent that speaks MCP can use <em>any</em> tool that speaks MCP, with no custom integration code on either side.</p>
<p>An MCP <strong>server</strong> exposes a set of tools. An MCP <strong>client</strong> — your AI agent — discovers those tools and calls them. The server describes each tool (its name, what it does, what arguments it takes) and the agent decides when to call it. When Claude Code decides it needs to read a web page, it doesn't run browser code itself. It calls a tool that some MCP server provides.</p>
<p>Browser automation is a natural fit for this model. The agent's job is reasoning — "I need to see what's on the staging site, then check the console for errors." The actual mechanics — open a tab, wait for load, read the DOM, capture console output — are well-defined operations that belong behind a stable interface. That interface is exactly what an MCP server provides.</p>
<p>Safari MCP is one such server. It runs as a local process, exposes around 80 browser tools (navigate, click, fill, read, screenshot, extract, and more), and any MCP client can drive it. The agent never touches AppleScript or WebKit internals. It just calls <code>safari_navigate</code> and gets a result.</p>
<p>The "USB port" framing matters for a practical reason: nothing in this tutorial is Claude-specific. Wire Safari MCP into Cursor, Cline, Windsurf, or your own MCP client and the tools are identical.</p>
<h2 id="heading-why-safari-instead-of-chrome-or-playwright">Why Safari Instead of Chrome or Playwright?</h2>
<p>If you've automated a browser before, you've almost certainly used Chrome through Puppeteer, Playwright, or Selenium. So why reach for Safari?</p>
<p>It comes down to three differences that matter once an <em>AI agent</em>, not a test script, is the thing driving the browser.</p>
<p><strong>1. It's your real browser, with your real sessions.</strong> A headless Chromium launched by Playwright is a clean room. It has never logged into anything. If you want your agent to read your analytics dashboard, you first have to solve authentication — store credentials somewhere, script the login, handle two-factor prompts, refresh tokens. Safari MCP skips all of that. It drives the Safari instance you use every day, which is <em>already</em> logged into your dashboards, your GitHub, your email. The agent inherits those sessions for free.</p>
<p><strong>2. It doesn't melt your laptop.</strong> A headless Chromium is a second, full browser engine running alongside the browser you already have open. On a laptop that's real CPU, real memory, and a fan you can hear. Safari MCP uses the WebKit engine that's already running on every Mac — there's no second engine to start. The project measures this at roughly 60% less CPU for the browsing work, and the automation runs with Safari in the background, so it doesn't steal your screen.</p>
<p><strong>3. Sites don't treat it as a bot.</strong> Headless browsers leak. They expose <code>navigator.webdriver</code>, they ship with telltale automation fingerprints, and bot-detection services — Cloudflare's challenge pages, reCAPTCHA, the WAFs in front of a lot of B2B sites — have gotten very good at spotting them. Your real Safari, driven through the operating system, looks like exactly what it is: a person's browser. (To be clear: this is for automating <em>your own</em> accounts and sites — not for evading access controls you don't own.)</p>
<p>The cost of all this is the obvious one: <strong>Safari MCP is macOS-only.</strong> It's built on WebKit and AppleScript, so there's no Windows or Linux story. If your agent runs on a Linux CI box, this isn't your tool. If it runs on your Mac — which, for a coding agent, it very often does — the trade is a good one. We'll come back to limitations honestly at the end.</p>
<h2 id="heading-installing-safari-mcp">Installing Safari MCP</h2>
<p>Installation is genuinely one command, but there are two Safari settings to flip first. Let's do it in order.</p>
<h3 id="heading-step-1-enable-safaris-developer-features">Step 1 — Enable Safari's developer features</h3>
<p>Safari MCP reads and controls pages by running JavaScript inside Safari. Two settings have to be on:</p>
<ol>
<li><p>Open <strong>Safari → Settings → Advanced</strong> and check <strong>"Show features for web developers."</strong> This reveals the Develop menu.</p>
</li>
<li><p>Open the new <strong>Develop</strong> menu and check <strong>"Allow JavaScript from Apple Events."</strong></p>
</li>
</ol>
<p>That second one is the important one. It's what lets an outside process — the MCP server — ask Safari to run JavaScript on a page. Without it, every tool call fails.</p>
<h3 id="heading-step-2-run-the-server">Step 2 — Run the server</h3>
<pre><code class="language-bash">npx safari-mcp
</code></pre>
<p>That's the whole install. <code>npx</code> fetches the package and runs it; there's nothing to build. The first time an agent calls a tool, macOS will pop up a permission prompt — something like <em>"Terminal wants to control Safari."</em> Click <strong>OK</strong>. That's the standard Automation permission, and you can review it later under <strong>System Settings → Privacy &amp; Security → Automation</strong>.</p>
<p>If you'd rather have it installed permanently:</p>
<pre><code class="language-bash">npm install -g safari-mcp
</code></pre>
<h3 id="heading-step-3-tell-your-agent-about-it">Step 3 — Tell your agent about it</h3>
<p>Your AI agent needs to know the server exists. For <strong>Claude Code</strong>, one command does it:</p>
<pre><code class="language-bash">claude mcp add safari -- npx safari-mcp
</code></pre>
<p>For <strong>Cursor</strong>, create <code>.cursor/mcp.json</code> in your project:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "safari": {
      "command": "npx",
      "args": ["safari-mcp"]
    }
  }
}
</code></pre>
<p>The process is the same for every client — Claude Desktop, Cline, Windsurf, Continue, VS Code. You're telling the agent: "there's an MCP server named <code>safari</code>; start it by running <code>npx safari-mcp</code>."</p>
<p>Restart your agent (or reload its MCP servers) and it will connect. In Claude Code you can confirm with the <code>/mcp</code> command, which lists connected servers and their tools. You should see <code>safari</code> with around 80 tools available.</p>
<p>That's it. Your agent now has a browser.</p>
<h2 id="heading-your-first-automation-reading-a-page">Your First Automation: Reading a Page</h2>
<p>Let's prove the wiring works with the simplest possible task: have the agent read a web page.</p>
<p>In your agent, just ask in plain language:</p>
<blockquote>
<p>"Use the safari tools to open example.com and tell me what the page says."</p>
</blockquote>
<p>Behind that request, the agent makes two tool calls. First it navigates:</p>
<pre><code class="language-json">{ "tool": "safari_navigate", "arguments": { "url": "https://example.com" } }
</code></pre>
<p>Then it reads the content:</p>
<pre><code class="language-json">{ "tool": "safari_read_page", "arguments": {} }
</code></pre>
<p><code>safari_read_page</code> returns the page's title, URL, and text content with the HTML stripped out — exactly the form an LLM wants. The agent gets back something like this:</p>
<pre><code class="language-plaintext">Example Domain
https://example.com/
This domain is for use in illustrative examples in documents. You may
use this domain in literature without prior coordination or asking for
permission.
</code></pre>
<p>And it relays that to you. You just watched your agent browse.</p>
<p>A quick note on <em>how</em> the agent should look at a page, because it changes everything downstream. <code>safari_read_page</code> is great for "what does this say." But when the agent needs to <em>act</em> — click a button, fill a field — text isn't enough. It needs to know what's actually there and how to target it. For that, the better first move is <code>safari_snapshot</code>:</p>
<pre><code class="language-json">{ "tool": "safari_snapshot", "arguments": {} }
</code></pre>
<p>This returns an accessibility-tree view of the page, where every interactive element has a stable <code>ref</code> ID:</p>
<pre><code class="language-plaintext">[textbox ref=0_8] "Full Name" value=""
[combobox ref=0_10] "Subject"
[button ref=0_15] "Submit"
</code></pre>
<p>Those <code>ref</code> IDs are the agent's reliable handles. CSS selectors break when a page re-renders. A snapshot ref stays valid for the life of the page. Keep that in mind — it's the difference between an automation that works once and one that works every time.</p>
<h2 id="heading-the-payoff-automating-a-logged-in-workflow">The Payoff: Automating a Logged-in Workflow</h2>
<p>Reading example.com is a wiring test. Here's the thing a headless browser genuinely cannot do.</p>
<p>Pick a site you're logged into in Safari right now — your analytics, your project board, your CI dashboard. We'll use GitHub, because every developer has an account and the notifications page is a real, mildly annoying chore. The task: <strong>have the agent open your GitHub notifications and summarize what actually needs your attention.</strong></p>
<p>Ask the agent:</p>
<blockquote>
<p>"Open my GitHub notifications, read them, and group them into 'needs a reply' versus 'just FYI'."</p>
</blockquote>
<p>The agent navigates:</p>
<pre><code class="language-json">{ "tool": "safari_navigate", "arguments": { "url": "https://github.com/notifications" } }
</code></pre>
<p>Stop and notice what <em>didn't</em> happen. No login screen. No OAuth dance. No personal access token in an environment variable. Safari is already authenticated as you, so the agent lands directly on your real notifications. A headless Chromium would have hit a login wall here and stopped.</p>
<p>Notification lists load incrementally, so the agent should wait for content before reading. <code>safari_wait_for</code> polls the page until a selector or piece of text appears, or a timeout elapses:</p>
<pre><code class="language-json">{ "tool": "safari_wait_for", "arguments": { "text": "Inbox", "timeout": 10000 } }
</code></pre>
<p>Then it reads. <code>safari_read_page</code> scoped to the notifications region returns the list as clean text:</p>
<pre><code class="language-json">{ "tool": "safari_read_page", "arguments": { "selector": "main" } }
</code></pre>
<p>The agent reasons over that text and hands you the grouped summary. The whole loop — navigate, wait, read, summarize — is a handful of tool calls.</p>
<p>When you need data in a precise shape rather than prose — to feed another step, or to write to a file — the agent can reach for <code>safari_evaluate</code>, which runs custom JavaScript on the page and returns whatever you build:</p>
<pre><code class="language-json">{
  "tool": "safari_evaluate",
  "arguments": {
    "expression": "JSON.stringify([...document.querySelectorAll('li')].map(li =&gt; li.innerText.trim()))"
  }
}
</code></pre>
<p>The agent writes that expression itself, against the structure it just saw in the snapshot — you don't hand-author selectors.</p>
<p>You might be thinking: <em>GitHub has an API, why scrape the page?</em> Fair. For GitHub specifically, the API is excellent. But the point generalizes. Most of the dashboards you stare at every day — your billing portal, your error tracker's specific filtered view, a client's analytics, the admin panel of some tool your company pays for — either have no usable API or would cost you an afternoon of OAuth setup to reach. With Safari MCP, "the page I'm already looking at" <em>is</em> the API. The agent reads what you can see, because it's using the browser you're seeing it in.</p>
<p>That's the capability headless automation can't match. Not speed, not features — <strong>access.</strong></p>
<h2 id="heading-handling-the-tricky-parts">Handling the Tricky Parts</h2>
<p>A first automation always looks easy. Three things tend to bite on the second one.</p>
<h3 id="heading-tab-safety-the-agent-must-not-hijack-your-tabs">Tab Safety — The Agent Must not Hijack Your Tabs</h3>
<p>This is the scariest failure mode: you're typing in a tab, the agent navigates <em>that</em> tab, and your work is gone. Safari MCP guards against it by stamping each automation tab with an identity marker — it uses <code>window.name</code>, which survives page navigations — and resolving "the agent's tab" through that marker on every call. If it can't positively identify its own tab, it refuses to act and raises a re-anchor error rather than guessing.</p>
<p>The practical rule for you: let the agent open its own tab with <code>safari_new_tab</code>, and it will stay in its lane. Don't point it at "the current tab" and assume.</p>
<h3 id="heading-waiting-for-dynamic-content">Waiting for Dynamic Content</h3>
<p>Modern pages render after load. If the agent reads too early, it reads an empty shell. Don't have it guess with fixed sleeps — use <code>safari_wait_for</code>, which polls for a selector or text until it appears or the timeout elapses:</p>
<pre><code class="language-json">{ "tool": "safari_wait_for", "arguments": { "selector": ".results-list", "timeout": 8000 } }
</code></pre>
<p>This is the single most common fix for "the automation works when I step through it slowly but fails when it runs."</p>
<h3 id="heading-framework-forms">Framework Forms</h3>
<p>Set a React or Vue input's <code>.value</code> directly and the framework never notices — its internal state stays empty, and your "filled" form submits blank. Safari MCP's <code>safari_fill</code> and <code>safari_fill_form</code> use the native value setters and dispatch the <code>input</code> and <code>change</code> events the framework listens for, so React, Vue, Angular, and Svelte state all stay in sync:</p>
<pre><code class="language-json">{
  "tool": "safari_fill_form",
  "arguments": {
    "fields": [
      { "selector": "#email", "value": "jane@example.com" },
      { "selector": "#message", "value": "Looks great." }
    ]
  }
}
</code></pre>
<p>For framework-heavy pages where CSS selectors are fragile, go back to the snapshot refs from the previous section — pass <code>{ "ref": "0_9" }</code> instead of <code>{ "selector": "#email" }</code>. Refs survive re-renders; selectors don't.</p>
<p>None of these are exotic. They're just the difference between a demo and an automation you'd actually leave running.</p>
<h2 id="heading-limitations-when-not-to-use-this">Limitations: When Not to Use This</h2>
<p>A tool tutorial that only lists strengths isn't worth much. Here's where Safari MCP is the wrong choice.</p>
<p><strong>It's macOS-only, and that's structural.</strong> Safari MCP is built on WebKit and AppleScript. There's no Windows or Linux port coming, because the foundation doesn't exist on those platforms. If your agent runs in Linux CI, use Playwright.</p>
<p><strong>It drives one Safari, on one Mac.</strong> This is browser automation for <em>your</em> machine — a coding agent working alongside you. It is not a fleet. If you need 50 parallel browsers scraping in a data center, that's a headless-Chromium-in-containers job, and Safari MCP is the wrong shape for it.</p>
<p><strong>Cross-browser test suites should stay on Playwright.</strong> If you're writing end-to-end tests that must pass on Chrome, Firefox, and Safari, use the tool built for that. Safari MCP drives exactly one engine: WebKit.</p>
<p><strong>It shares a browser with you.</strong> Because it uses your real Safari, the agent and you are in the same browser. That's the entire point — but it means you should let the agent work in its own tabs and not fight it for the same window.</p>
<p>The honest summary: Safari MCP is built for one specific situation — an AI agent doing real browser work on the Mac you're sitting at, against sites you're already logged into. In that situation it's hard to beat. Outside it, reach for the headless tools. Knowing which situation you're in is the actual skill.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>You've gone from an AI agent that could only see code to one that can see the web — the real web, behind your real logins.</p>
<p>To recap what you did: you learned what MCP is and why browser automation belongs behind that interface. You saw why a native Safari engine beats a headless Chromium for an agent working on your Mac and you installed Safari MCP with one command and two settings. You ran a first read, and then you did the thing that actually matters — an automation inside a logged-in page, with no auth code at all. Finally, you saw the edges: tab safety, waiting for dynamic content, framework forms, and the cases where you should pick a different tool.</p>
<p>The bigger idea is worth holding onto. An AI agent is only as capable as the tools you connect to it. Giving it a browser — a <em>real</em> one — turns "write me code" into "go look at the staging site, find the bug, and tell me what's wrong." That's a different kind of collaborator.</p>
<p>Safari MCP is open source under the MIT license, and it exposes around 80 tools beyond the handful you used here — screenshots, network inspection, storage, accessibility audits, multi-tab workflows. The repository and full tool reference are at <a href="https://github.com/achiya-automation/safari-mcp">github.com/achiya-automation/safari-mcp</a>. Point your agent at it and see what it does when it can finally look around.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Autonomous OSINT Agent in Python Using Claude's Tool Use API ]]>
                </title>
                <description>
                    <![CDATA[ When I started studying OSINT, I always felt I was just putting random values into software without deeply understanding what I was doing. After months in the field, I realized I wasn't really investi ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-autonomous-agent-in-python-using-claude/</link>
                <guid isPermaLink="false">6a06669ebaf09db7a64df6cf</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ claude ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tommaso Bertocchi ]]>
                </dc:creator>
                <pubDate>Fri, 15 May 2026 00:19:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/5890d77b-0678-4c68-a9c3-2304fb2a02ad.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When I started studying OSINT, I always felt I was just putting random values into software without deeply understanding what I was doing. After months in the field, I realized I wasn't really investigating — I was just executing steps that follow a predictable pattern. That's exactly what an AI agent is good at. So I built one.</p>
<p>In this tutorial you'll learn how to set up OpenOSINT, an open-source Python OSINT framework with an AI agent at its core. You'll learn how Claude's native tool use API works, how to run autonomous investigations from the terminal using the interactive AI REPL, how to use the direct CLI for scripting, and how to expose all the tools to Claude Code or Claude Desktop via an MCP server.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-osint-and-why-manual-workflows-break-down">What Is OSINT and Why Manual Workflows Break Down</a></p>
</li>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-claudes-tool-use-api-works">How Claude's Tool Use API Works</a></p>
</li>
<li><p><a href="#heading-how-to-install-openosint">How to Install OpenOSINT</a></p>
</li>
<li><p><a href="#heading-how-to-use-the-interactive-ai-repl">How to Use the Interactive AI REPL</a></p>
</li>
<li><p><a href="#heading-how-to-run-individual-tools-from-the-cli">How to Run Individual Tools from the CLI</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-mcp-server">How to Set Up the MCP Server</a></p>
</li>
<li><p><a href="#heading-how-the-agent-loop-works-under-the-hood">How the Agent Loop Works Under the Hood</a></p>
</li>
<li><p><a href="#heading-project-architecture">Project Architecture</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-osint-and-why-manual-workflows-break-down">What Is OSINT and Why Manual Workflows Break Down</h2>
<p>Open Source Intelligence (OSINT) is the practice of collecting and analyzing information from publicly available sources. Security researchers use it during penetration tests. Journalists use it to verify identities and trace connections. Threat analysts use it to profile infrastructure.</p>
<p>A typical OSINT workflow looks like this:</p>
<ol>
<li><p>You have a target email address</p>
</li>
<li><p>You run <code>holehe</code> to find which platforms that email is registered on</p>
</li>
<li><p>You notice a username in the output</p>
</li>
<li><p>You manually copy that username and run <code>sherlock</code> to search 300+ platforms</p>
</li>
<li><p>You switch to a browser to check HaveIBeenPwned</p>
</li>
<li><p>You open another tab for a WHOIS lookup</p>
</li>
<li><p>You take notes and repeat</p>
</li>
</ol>
<p>Every tool is a silo. Every pivot is manual. The investigation logic — what to run next, what to chain, what the findings mean — lives entirely in your head.</p>
<p>When you close the terminal, it's gone.</p>
<p>This tutorial walks you through <a href="https://github.com/OpenOSINT/OpenOSINT">OpenOSINT</a>, an open-source Python framework that replaces that fragmented workflow with an AI agent that chains tools autonomously, executes them against real binaries, and saves a structured Markdown report.</p>
<p>More importantly, you'll learn the core design principle that makes it trustworthy for security research: <strong>hallucination in tool results is structurally impossible</strong>.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end of this tutorial, you'll have a working OSINT agent that you can use in three ways:</p>
<ul>
<li><p><strong>Interactive AI REPL</strong> — type a target in natural language and the agent decides what to run</p>
</li>
<li><p><strong>Direct CLI</strong> — run individual tools without AI, useful for scripting</p>
</li>
<li><p><strong>MCP Server</strong> — expose all tools to Claude Code or Claude Desktop</p>
</li>
</ul>
<p>Here's what a real session looks like:</p>
<pre><code class="language-plaintext">$ openosint
openosint ❯ investigate target@example.com

  → generate_dorks('target@example.com')
  → search_email('target@example.com')
  ✓ Found: Spotify, WordPress, Gravatar, Office365

  → search_breach('target@example.com')
  ✓ Found in 2 breaches: LinkedIn (2016), Adobe (2013)

  → search_username('target_handle')
  ✓ Found on: GitHub, Reddit, HackerNews, Twitter

  ╭──────────────── Report ────────────────╮
  │ ## Online Presence                     │
  │ Spotify · WordPress · Gravatar         │
  │                                        │
  │ ## Data Breaches                       │
  │ LinkedIn (2016) · Adobe (2013)         │
  ╰────────────────────────────────────────╯

  ✓ Report saved → reports/2026-05-11_report.md
</code></pre>
<p>The agent went from email → linked accounts → username pivot → cross-platform search with no human orchestration at any step.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow this tutorial, you'll need:</p>
<ul>
<li><p>Python 3.10 or later installed on your machine</p>
</li>
<li><p>Basic familiarity with the command line</p>
</li>
<li><p>An <a href="https://console.anthropic.com/">Anthropic API key</a> — only required for the AI REPL, not for the CLI or MCP server</p>
</li>
<li><p>Git installed</p>
</li>
</ul>
<p>You don't need prior experience with OSINT tools or the Anthropic SDK.</p>
<h2 id="heading-how-claudes-tool-use-api-works">How Claude's Tool Use API Works</h2>
<p>Before you dive into installation, it's worth understanding the mechanism that makes this framework trustworthy for security research.</p>
<p>Most AI applications that wrap external tools work by generating text that describes what a tool <em>would</em> return. That's a problem when accuracy matters — the model can hallucinate plausible-looking usernames, fake subdomains, or data breaches that never happened.</p>
<p>Claude's tool use API works differently. When the model decides it needs to call a tool, it does <strong>not</strong> generate the output. It stops and emits a structured <code>tool_use</code> block containing the tool name and the arguments it wants to pass.</p>
<p>Your code then runs the actual binary — <code>holehe</code>, <code>sherlock</code>, or whatever else — and sends the real output back as a <code>tool_result</code>. The model reads that real output and decides its next step.</p>
<p>Here's the flow:</p>
<pre><code class="language-plaintext">User prompt
    ↓
Model decides to call search_email()
    ↓
Hard stop — model emits tool_use block
    ↓
Your code runs holehe against the real target
    ↓
Real output sent back as tool_result
    ↓
Model reads actual results, decides next step
    ↓
Repeat until investigation is complete
</code></pre>
<p>The model never generates tool output. It only ever reads it. If <code>sherlock</code> finds 12 profiles, those 12 URLs go back into the context verbatim. The model cannot add a 13th that doesn't exist.</p>
<p>This is not a prompting trick or a system prompt instruction. It is how the API is architected. Keep this in mind as you read through the agent loop code later in this tutorial.</p>
<h2 id="heading-how-to-install-openosint">How to Install OpenOSINT</h2>
<p>Start by cloning the repository and installing the package:</p>
<pre><code class="language-bash">git clone https://github.com/OpenOSINT/OpenOSINT.git
cd OpenOSINT
pip install -e .
</code></pre>
<p>Alternatively, if you just want to use the tool without modifying the source, install it directly from PyPI:</p>
<pre><code class="language-bash">pip install openosint
</code></pre>
<p>Next, set your Anthropic API key. This is only required for the interactive AI REPL — the direct CLI and MCP server work without it:</p>
<pre><code class="language-bash">export ANTHROPIC_API_KEY=sk-ant-...
</code></pre>
<h3 id="heading-how-to-install-the-external-tool-dependencies">How to Install the External Tool Dependencies</h3>
<p>OpenOSINT wraps several standalone OSINT tools. Install the ones you plan to use:</p>
<pre><code class="language-bash">pip install holehe            # email account enumeration
pip install sherlock-project  # username search across 300+ platforms
pip install sublist3r         # subdomain enumeration
</code></pre>
<p>For phone intelligence, <code>phoneinfoga</code> is a standalone binary. Download the release for your platform from its <a href="https://github.com/sundowndev/phoneinfoga/releases">GitHub releases page</a> and place it somewhere in your <code>PATH</code>.</p>
<h3 id="heading-how-to-configure-optional-api-keys">How to Configure Optional API Keys</h3>
<p>Two tools work at higher rate limits with optional API keys:</p>
<pre><code class="language-bash">export HIBP_API_KEY=your_key    # required for breach checks via HaveIBeenPwned v3
export IPINFO_TOKEN=your_token  # optional — raises ipinfo.io rate limits
</code></pre>
<p>If a binary is missing or an API key is not configured, that specific tool returns a descriptive error string. All other tools continue to work normally.</p>
<h2 id="heading-how-to-use-the-interactive-ai-repl">How to Use the Interactive AI REPL</h2>
<p>Run <code>openosint</code> with no arguments to start the AI-powered REPL. You can also use <code>openosint shell</code> — it's equivalent:</p>
<pre><code class="language-bash">$ openosint
# or
$ openosint shell
</code></pre>
<p>If you prefer to pass the API key inline rather than via environment variable, use the <code>--api-key</code> flag:</p>
<pre><code class="language-bash">$ openosint --api-key sk-ant-...
</code></pre>
<p>You'll get a prompt where you can type targets or questions in natural language:</p>
<pre><code class="language-plaintext">openosint ❯ investigate target@example.com
openosint ❯ find all accounts for johndoe99
openosint ❯ what subdomains does example.com have?
openosint ❯ check if +14155552671 is a mobile number
</code></pre>
<p>The agent decides which tools to run based on your input. You don't need to specify which tools to use or in what order. If you type an email address, the agent will run email enumeration. If it finds a linked username, it may pivot and search that username across platforms.</p>
<p>Reports are saved automatically to the <code>reports/</code> directory after every investigation that produces structured findings.</p>
<p>Here are the commands available inside the REPL:</p>
<table>
<thead>
<tr>
<th>Command</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>clear</code></td>
<td>Reset the conversation memory</td>
</tr>
<tr>
<td><code>save</code></td>
<td>Manually save the last report</td>
</tr>
<tr>
<td><code>tools</code></td>
<td>Show available tools and their status</td>
</tr>
<tr>
<td><code>config</code></td>
<td>Show current configuration</td>
</tr>
<tr>
<td><code>help</code></td>
<td>List all commands</td>
</tr>
<tr>
<td><code>exit</code> or Ctrl-D</td>
<td>Quit</td>
</tr>
</tbody></table>
<h2 id="heading-how-to-run-individual-tools-from-the-cli">How to Run Individual Tools from the CLI</h2>
<p>If you want to run a single tool without the AI layer — for scripting, automation, or quick lookups — use the direct CLI:</p>
<pre><code class="language-bash"># Email account enumeration (default timeout: 120s)
openosint email target@example.com

# With a custom timeout in seconds
openosint email target@example.com -t 60

# Username search across 300+ platforms (default timeout: 180s)
openosint username johndoe99

# Enable verbose output for debugging
openosint -v email target@example.com
</code></pre>
<p>The direct CLI doesn't require an Anthropic API key. It runs the underlying binary and prints the output to the terminal.</p>
<p>This mode is useful when you need predictable, scriptable behavior — for example, piping output into another tool or running automated checks.</p>
<h2 id="heading-how-to-set-up-the-mcp-server">How to Set Up the MCP Server</h2>
<p>OpenOSINT also ships as a Model Context Protocol (MCP) server. This exposes all 9 tools to any MCP-compatible AI client.</p>
<h3 id="heading-how-to-register-with-claude-code">How to Register with Claude Code</h3>
<pre><code class="language-bash">claude mcp add openosint python /absolute/path/to/OpenOSINT/openosint/mcp_server.py
</code></pre>
<p>Verify the registration worked:</p>
<pre><code class="language-bash">claude mcp list
</code></pre>
<p>Once registered, you can drive investigations from the Claude Code prompt:</p>
<pre><code class="language-plaintext">&gt; Investigate target@example.com. If you find a linked username,
  trace it across other platforms and compile a full report.
</code></pre>
<h3 id="heading-how-to-configure-claude-desktop">How to Configure Claude Desktop</h3>
<p>Add the following to your Claude Desktop config at <code>~/Library/Application Support/Claude/claude_desktop_config.json</code>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "openosint": {
      "command": "python",
      "args": ["/absolute/path/to/OpenOSINT/openosint/mcp_server.py"]
    }
  }
}
</code></pre>
<p>Restart Claude Desktop after saving the file. The tools will appear in Claude's tool list.</p>
<p>The MCP server uses stdio transport and does not need a persistent background process. Claude Code or Claude Desktop starts it on demand.</p>
<h2 id="heading-how-the-agent-loop-works-under-the-hood">How the Agent Loop Works Under the Hood</h2>
<p>Here is a simplified version of the agent loop from <code>openosint/agent.py</code>:</p>
<pre><code class="language-python">import anthropic
import asyncio

client = anthropic.Anthropic()

async def run_investigation(user_prompt: str) -&gt; str:
    messages = [{"role": "user", "content": user_prompt}]

    while True:
        response = client.messages.create(
            model="claude-...",   # model configured via --api-key / env var
            max_tokens=4096,
            tools=TOOL_SCHEMAS,   # JSON schemas for all 9 tools
            messages=messages
        )

        # Agent is done — extract and return the final report
        if response.stop_reason == "end_turn":
            return extract_text(response)

        # Agent needs a tool — run the real binary
        if response.stop_reason == "tool_use":
            tool_results = []

            for block in response.content:
                if block.type == "tool_use":
                    # Runs holehe, sherlock, etc. as real subprocesses
                    real_output = await execute_tool(block.name, block.input)

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": real_output  # real output, never generated
                    })

            # Append assistant turn and real tool results to conversation
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})
</code></pre>
<p>There are a few important things to understand in this code.</p>
<ol>
<li><p><strong>The loop runs until</strong> <code>stop_reason == "end_turn"</code>: The agent decides when it has gathered enough information to write the final report. It may call one tool or ten, depending on what it finds.</p>
</li>
<li><p><code>execute_tool()</code> <strong>runs real subprocesses</strong>: It's a thin async wrapper around Python's <code>asyncio.create_subprocess_exec()</code> with a configurable timeout. There's no simulation and no mocked data at any point.</p>
</li>
<li><p><strong>Conversation history is maintained across the entire loop</strong>: Each tool result goes back into <code>messages</code>, so the model always has full context of what it found when deciding what to run next.</p>
</li>
<li><p><strong>Tool schemas are defined as JSON</strong>: Each tool has a name, description, and parameter schema. The model uses these to know what tools exist and what arguments they accept. Here's a simplified example for <code>search_email</code>:</p>
</li>
</ol>
<pre><code class="language-python">{
    "name": "search_email",
    "description": (
        "Enumerates online services and social accounts "
        "associated with an email address using holehe."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "email": {
                "type": "string",
                "description": "Target email address"
            }
        },
        "required": ["email"]
    }
}
</code></pre>
<p>The same pattern applies to all 9 tools. The model reads these schemas at the start of every request and uses them to decide what's available and how to call it.</p>
<h2 id="heading-project-architecture">Project Architecture</h2>
<p>The codebase is organized in five layers. The hard rule across the codebase is that no layer imports from a layer above it:</p>
<pre><code class="language-plaintext">openosint/tools/        Core tools
                        Async wrappers around external binaries and APIs.
                        Stateless. No AI. No CLI. Pure functions.

openosint/agent.py      AI agent
                        Anthropic tool use loop.
                        Per-session conversation history.
                        Imports from tools/. Nothing imports from agent.py.

openosint/repl.py       Interactive REPL (prompt_toolkit + Rich)
openosint/mcp_server.py MCP server (stdio transport)
openosint/cli.py        CLI entry point
</code></pre>
<p>This separation makes each layer independently testable. The core tools are pure async functions that take a string and return a string — you can unit test them without touching the agent or the CLI.</p>
<p>It also means the AI layer is entirely optional. If you don't have an Anthropic API key, you use the CLI and bypass the agent. The MCP server also operates independently of the agent.</p>
<h3 id="heading-the-9-available-tools">The 9 Available Tools</h3>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Backend</th>
<th>What it returns</th>
</tr>
</thead>
<tbody><tr>
<td><code>search_email</code></td>
<td>holehe</td>
<td>Social accounts linked to an email</td>
</tr>
<tr>
<td><code>search_username</code></td>
<td>sherlock</td>
<td>Accounts across 300+ platforms</td>
</tr>
<tr>
<td><code>search_breach</code></td>
<td>HaveIBeenPwned v3</td>
<td>Breach names, dates, leaked data types</td>
</tr>
<tr>
<td><code>search_whois</code></td>
<td>python-whois</td>
<td>Registrant, registrar, creation/expiry</td>
</tr>
<tr>
<td><code>search_ip</code></td>
<td>ipinfo.io</td>
<td>Geolocation, ASN, hostname, org</td>
</tr>
<tr>
<td><code>search_domain</code></td>
<td>sublist3r</td>
<td>Subdomain enumeration</td>
</tr>
<tr>
<td><code>generate_dorks</code></td>
<td>built-in</td>
<td>12 targeted Google dork URLs, no network calls</td>
</tr>
<tr>
<td><code>search_paste</code></td>
<td>psbdmp.ws</td>
<td>Pastebin dump mentions</td>
</tr>
<tr>
<td><code>search_phone</code></td>
<td>phoneinfoga</td>
<td>Carrier, country, line type</td>
</tr>
</tbody></table>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you learned how to set up and use OpenOSINT — a Python OSINT framework built on Claude's tool use API.</p>
<p>The key takeaway is the design principle: by using native tool use, the agent never generates tool output. It only reads real output from real binaries. This makes it suitable for security research where accuracy matters and hallucination isn't an acceptable failure mode.</p>
<p>To recap the three interfaces:</p>
<ul>
<li><p>Run <code>openosint</code> for the interactive AI REPL — best for full investigations with automatic chaining</p>
</li>
<li><p>Run <code>openosint email</code> or <code>openosint username</code> for direct CLI access — best for scripting and automation</p>
</li>
<li><p>Register the MCP server in Claude Code or Claude Desktop to run investigations inside your existing AI environment</p>
</li>
</ul>
<p>The full source code is available on <a href="https://github.com/OpenOSINT/OpenOSINT">GitHub</a> under the MIT license. Contributions and issues are welcome.</p>
<p><strong>Legal note</strong>: OpenOSINT is for authorized security research, penetration testing, and investigative journalism only. Users are solely responsible for compliance with applicable law, including GDPR, CCPA, and the CFAA. See the <a href="https://github.com/OpenOSINT/OpenOSINT/blob/main/DISCLAIMER.md">DISCLAIMER.md</a> for the full notice.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Market Research Copilot with MCP and Python [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ Most financial AI tools are good at one thing: summarizing a stock. You ask about Apple, NVIDIA, or Tesla, and they give you a clean overview of price action, a few ratios, and maybe some company cont ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-market-research-copilot-with-mcp-and-python-handbook/</link>
                <guid isPermaLink="false">69fb845950ecad45335e0fe2</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stockmarket ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Wed, 06 May 2026 18:11:37 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/97192f8e-e5c5-4339-8974-90d823d93a86.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most financial AI tools are good at one thing: summarizing a stock. You ask about Apple, NVIDIA, or Tesla, and they give you a clean overview of price action, a few ratios, and maybe some company context. That can be useful, but it falls short the moment the task becomes more like real research.</p>
<p>Real research usually starts with a view. Not a ticker. A trader, analyst, or product team is more likely to ask something like, “Apple looks attractive because downside has been controlled and business quality remains high. Does the data actually support that?” That's a different problem. A summary can't answer it properly because the system needs to test the claim itself, not just describe the company around it.</p>
<p>In this tutorial, we're going to build a financial research copilot that does exactly that. It takes a natural-language thesis, pulls historical prices and fundamentals through EODHD’s MCP server, turns those inputs into structured evidence, and returns a short research memo with a verdict.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-this-copilot-actually-produces">What This Copilot Actually Produces</a></p>
</li>
<li><p><a href="#heading-what-makes-this-different-from-a-normal-stock-assistant">What Makes This Different from a Normal Stock Assistant</a></p>
</li>
<li><p><a href="#heading-the-workflow">The Workflow</a></p>
</li>
<li><p><a href="#heading-building-the-mcp-client">Building the MCP Client</a></p>
</li>
<li><p><a href="#heading-setting-up-corepyhttpcorepy">Setting Up core.py</a></p>
</li>
<li><p><a href="#heading-parsing-a-research-prompt-into-a-structured-request">Parsing a Research Prompt into a Structured Request</a></p>
</li>
<li><p><a href="#heading-fetching-the-two-data-sources-historical-amp-fundamental-data">Fetching the Two Data Sources: Historical &amp; Fundamental Data</a></p>
</li>
<li><p><a href="#heading-building-the-first-evidence-layer-from-price-data">Building the First Evidence Layer from Price Data</a></p>
</li>
<li><p><a href="#heading-building-the-second-evidence-layer-from-fundamentals">Building the Second Evidence Layer from Fundamentals</a></p>
</li>
<li><p><a href="#heading-what-do-we-have-so-far">What do we have so far?</a></p>
</li>
<li><p><a href="#heading-classifying-the-thesis">Classifying the Thesis</a></p>
</li>
<li><p><a href="#heading-turning-signals-into-support-contradiction-and-missing-evidence">Turning Signals into Support, Contradiction, and Missing Evidence</a></p>
<ul>
<li><a href="#heading-sanity-check-jupyter-notebook">Sanity Check (Jupyter Notebook)</a></li>
</ul>
</li>
<li><p><a href="#heading-assigning-a-verdict">Assigning a Verdict</a></p>
</li>
<li><p><a href="#heading-building-the-facts-object">Building the Facts Object</a></p>
<ul>
<li><p><a href="#heading-1-company-context">1. Company Context</a></p>
</li>
<li><p><a href="#heading-2-single-stock-facts-builder">2. Single-Stock Facts Builder</a></p>
</li>
<li><p><a href="#heading-3-watchlist-facts-builder">3. Watchlist Facts Builder</a></p>
</li>
<li><p><a href="#heading-sanity-check-jupyter-notebook-1">Sanity Check (Jupyter Notebook)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-writing-the-final-memo">Writing the Final Memo</a></p>
<ul>
<li><a href="#heading-sanity-check-jupyter-notebook-2">Sanity Check (Jupyter Notebook)</a></li>
</ul>
</li>
<li><p><a href="#heading-stitching-everything-together">Stitching Everything Together</a></p>
</li>
<li><p><a href="#heading-demo-time-jupyter-notebook">Demo Time! (Jupyter Notebook)</a></p>
<ul>
<li><p><a href="#heading-demo-1-testing-whether-a-premium-is-actually-justified">Demo 1. Testing Whether a Premium Is Actually Justified</a></p>
</li>
<li><p><a href="#heading-demo-2-testing-whether-volatility-is-too-high-for-the-underlying-business">Demo 2. Testing Whether Volatility Is Too High for the Underlying Business</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before starting, make sure you have the following in place.</p>
<p>You will need Python 3.9 or later, along with these libraries: <code>mcp</code>, <code>openai</code>, <code>numpy</code>, and <code>pandas</code>. Install them with pip before running any code.</p>
<p>You will also need two API keys. One from EODHD for historical prices and fundamentals data, and one from OpenAI for parsing and memo generation. If you don't have an EODHD key, you can get one by registering for a developer account at <a href="http://eodhd.com">eodhd.com</a>.</p>
<p>The tutorial assumes basic familiarity with Python and async programming. You don't need a background in finance, but it helps to understand what a P/E ratio and drawdown mean before reading the evidence-building sections.</p>
<p>A Jupyter notebook environment is recommended for running the sanity checks, though any Python environment that supports <code>await</code> will work.</p>
<h2 id="heading-what-this-copilot-actually-produces">What This Copilot Actually&nbsp;Produces</h2>
<p>Before getting into the pipeline, it helps to see the kind of output we're building toward. The easiest way to understand this project is to look at one real example.</p>
<p>Suppose the user gives the system this prompt:</p>
<blockquote>
<p>I think Apple looks attractive because downside has been controlled and business quality remains high. Can you test that for AAPL over the last 180&nbsp;days?</p>
</blockquote>
<p>The copilot doesn't respond with a loose summary of Apple. It turns that into a structured research memo:</p>
<pre><code class="language-plaintext">1. Thesis under review  

Apple appears attractive due to controlled downside and sustained high business 
quality.

2. Supporting evidence  

Over the past 180 days, maximum drawdown was limited to -13.82%, suggesting relatively contained downside.Profitability metrics are strong, with a 35.37% operating margin and 27.04% profit margin. Returns on capital are high, with ROA at 24.38% and ROE at 152.02%, indicating efficient asset use and strong  capital efficiency. Growth metrics support ongoing business strength, with quarterly revenue growth of 15.70% and earnings growth of 18.30% year-over-year. Forward estimates also remain positive, with expected earnings growth of 9.68% and 
revenue growth of 6.87%.

3. Evidence that weakens the thesis  

Net EPS revisions over the past 30 days are negative (-3), indicating some deterioration in analyst sentiment.

4. Missing evidence  

No material gaps in the provided dataset.

5. Verdict  

partially_supported - There is more supporting evidence than contradicting evidence, but the thesis is not fully confirmed.

6. Bottom-line assessment  

Apple demonstrates strong and consistent business quality supported by high margins, returns, and continued growth. Downside has been relatively contained over the observed period, though not negligible. However, negative earnings 
revisions introduce some caution, leaving the thesis supported but not conclusively established.
</code></pre>
<p>This example makes the goal of the project much clearer. We're not building a system that simply tells us what happened to Apple. We're building one that takes a claim, checks it against market and fundamentals data, and returns a structured judgment.</p>
<p>That distinction matters because the memo is only the final surface. Underneath it, the system first parses the thesis, pulls prices and fundamentals through <a href="https://eodhd.com/financial-apis/mcp-server-for-financial-data-by-eodhd"><strong>EODHD’s MCP server</strong></a>, computes the relevant signals, builds support and contradiction, assigns a verdict, and only then writes the final note. That's what gives the output its structure.</p>
<p>In this first part, we’ll build everything up to the evidence layers that power this kind of output.</p>
<h2 id="heading-what-makes-this-different-from-a-normal-stock-assistant">What Makes This Different from a Normal Stock Assistant</h2>
<img src="https://cdn-images-1.medium.com/max/1000/1*rJirKoA1xWiuZjyENZypGg.png" alt="Stock assistant vs Thesis copilot workflow comparison" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>A normal stock assistant starts with a ticker and tries to explain what happened. It may summarize price action, mention a few ratios, and add some company context. That is useful when the question is broad, but it's not enough when the input is a specific investment view.</p>
<p>This project starts from the opposite direction. The input is not “tell me about Apple.” The input is a claim, like Apple looks attractive because downside has been controlled and business quality remains high. That changes the job of the system. It now has to test each part of that claim, decide what supports it, decide what weakens it, and be clear about what's still missing.</p>
<p>That one shift is what shapes the whole workflow. Instead of ending at retrieval and summarization, the pipeline has to parse the thesis, map the data to the right kind of evidence, and return a verdict. That's what makes this feel like a research copilot rather than a better stock summary tool.</p>
<h2 id="heading-the-workflow">The Workflow</h2>
<p>At a high level, the copilot follows a simple sequence:</p>
<ul>
<li><p>parse the user’s thesis into a structured request</p>
</li>
<li><p>fetch historical prices and fundamentals through MCP</p>
</li>
<li><p>turn those inputs into market and business signals</p>
</li>
<li><p>map those signals into support, contradiction, and missing evidence</p>
</li>
<li><p>assign a verdict</p>
</li>
<li><p>write the final memo</p>
</li>
</ul>
<p>That's the full loop. The output may look like a short research note, but it sits on top of a more controlled pipeline in <code>core.py</code>.</p>
<h4 id="heading-project-structure">Project structure:</h4>
<pre><code class="language-plaintext">project/
├── client.py
├── core.py
└── test.ipynb
</code></pre>
<p><code>client.py</code> is the MCP access layer. It connects to EODHD, lists tools, calls them with retries and timeouts, and returns metadata for each request. <code>core.py</code> contains the actual thesis-testing logic, including parsing, data fetching, signal computation, evidence building, verdict assignment, and memo generation. <code>test.ipynb</code> is where the quality checks and end-to-end demos are run.</p>
<p>This split is useful because it keeps the tutorial easy to follow. When we move into code, each block has a clear place. MCP access stays in <code>client.py</code>, while the research workflow stays in <code>core.py</code>.</p>
<h2 id="heading-building-the-mcp-client">Building the MCP&nbsp;Client</h2>
<p>We’ll start with the thinnest part of the project, which is the MCP access layer.</p>
<p>This file only does one job. It connects to EODHD’s MCP server, lists available tools, calls a tool with retries and a timeout, and returns a small metadata object alongside the response. The actual thesis logic doesn't belong here. Keeping this layer small makes the rest of the project much easier to reason about later.</p>
<p>Create a file called <code>client.py</code> and add this:</p>
<pre><code class="language-python">import time
import asyncio

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

class EODHDMCP:
    def __init__(self, apikey, base_url=None):
        self.apikey = apikey
        self.base_url = base_url or "https://mcp.eodhd.dev/mcp"
        self._tools = None

    def _url(self):
        return f"{self.base_url}?apikey={self.apikey}"

    def _open(self):
        return streamablehttp_client(self._url())

    async def list_tools(self):
        if self._tools is not None:
            return self._tools

        async with self._open() as (read, write, _):
            async with ClientSession(read, write) as s:
                await s.initialize()
                resp = await s.list_tools()
                self._tools = [t.name for t in resp.tools]
                return self._tools

    async def call_tool(self, name, args, trace_id, timeout_s=25, retries=2):
        last = None

        for attempt in range(retries + 1):
            t0 = time.time()
            try:
                async with self._open() as (read, write, _):
                    async with ClientSession(read, write) as s:
                        await s.initialize()
                        out = await asyncio.wait_for(s.call_tool(name, args), timeout=timeout_s)
                        dt = time.time() - t0
                        meta = {
                            "trace_id": trace_id,
                            "tool": name,
                            "args": args,
                            "latency_s": round(dt, 3),
                        }
                        return out, meta
            except Exception as e:
                last = e
                if attempt &lt; retries:
                    await asyncio.sleep(0.5 * (attempt + 1))

        raise last
</code></pre>
<p>There are only two methods that really matter here. <code>list_tools()</code> is just a quick way to inspect and cache the tools exposed by the MCP server. <code>call_tool()</code> is the method the rest of the project will actually use. It makes the request, applies timeout and retry handling, and returns both the raw output and a small metadata object.</p>
<p>That metadata becomes useful later because the workflow stays traceable. When the copilot returns a memo, we still know which tool was called, with what arguments, and how long it took. So even though this file is small, it gives the rest of the system a clean and inspectable access layer.</p>
<h2 id="heading-setting-up-corepy">Setting Up&nbsp;<code>core.py</code></h2>
<p>Now that the MCP client is ready, we can start building the main workflow in <code>core.py</code>.</p>
<p>This file will hold the actual thesis-testing logic, so the first step is to set up the imports, API clients, a few limits, and some small helper functions that the rest of the pipeline will reuse.</p>
<p>Create a file called <code>core.py</code> and start with this:</p>
<pre><code class="language-python">import json
import re
import time
import uuid
import asyncio
from datetime import date, timedelta

import numpy as np
import pandas as pd
from openai import OpenAI

from client import EODHDMCP

eodhd_api_key = "your eodhd api key"
mcp_base_url = "https://mcp.eodhd.dev/mcp"

openai_api_key = "your openai api key"
model_name = "gpt-5.3-chat-latest"

max_lookback_days = 365
max_tool_calls = 10
max_tickers = 5

mcp = EODHDMCP(eodhd_api_key, base_url=mcp_base_url)
oa = OpenAI(api_key=openai_api_key)

def log_event(event, trace_id, **extra):
    payload = {
        "event": event,
        "trace_id": trace_id,
        "ts": round(time.time(), 3),
    }
    payload.update(extra)
    print(json.dumps(payload, default=str))

def get_dates_from_lookback(days):
    end = date.today()
    start = end - timedelta(days=int(days))
    return start.isoformat(), end.isoformat()

def make_state():
    return {
        "tool_calls": 0,
        "tool_trace": [],
    }

def bump_tool_call(state, meta):
    state["tool_calls"] += 1
    state["tool_trace"].append(meta)

    if state["tool_calls"] &gt; max_tool_calls:
        raise RuntimeError("tool call budget exceeded")

def to_text(out):
    if isinstance(out, str):
        return out.strip()

    if hasattr(out, "content"):
        try:
            parts = []
            for item in out.content:
                if hasattr(item, "text") and item.text is not None:
                    parts.append(item.text)
                else:
                    parts.append(str(item))
            return "\n".join(parts).strip()
        except Exception:
            pass

    return str(out).strip()
</code></pre>
<p>Note: Replace <code>“your eodhd api key”</code> with your actual EODHD API key. If you don’t have one, you can obtain it by opening an EODHD developer account.</p>
<p>This block does three things:</p>
<ul>
<li><p>First, it sets up the two clients we need. <code>mcp</code> is the EODHD MCP client from <code>client.py</code>, and <code>oa</code> is the OpenAI client that will be used for parsing and memo generation later.</p>
</li>
<li><p>Second, it defines a few small limits for the workflow. These help keep the system controlled by capping the lookback window, the number of tickers, and the number of tool calls in a single run.</p>
</li>
<li><p>Third, it adds helper functions that the rest of the file depends on. <code>log_event()</code> gives us lightweight tracing, <code>get_dates_from_lookback()</code> converts a lookback window into start and end dates, <code>make_state()</code> and <code>bump_tool_call()</code> help track MCP usage, and <code>to_text()</code> safely converts tool output into plain text before we parse it.</p>
</li>
</ul>
<h2 id="heading-parsing-a-research-prompt-into-a-structured-request">Parsing a Research Prompt into a Structured Request</h2>
<p>The first thing this copilot needs to do is clean up the input. A user isn't going to send a perfectly formatted request every time. They're more likely to write a research thought in plain English and mix the thesis, ticker, and timeframe into one prompt.</p>
<p>That is why the system starts by turning the raw prompt into four fields:</p>
<ul>
<li><p>ticker</p>
</li>
<li><p>lookback window</p>
</li>
<li><p>thesis</p>
</li>
<li><p>mode</p>
</li>
</ul>
<p>This logic goes into <code>core.py</code>.</p>
<pre><code class="language-python">def parse_request(text):
    prompt = f"""
You are extracting fields for a financial thesis-testing copilot.

Return only valid JSON with this exact shape:
{{
  "tickers": ["AAPL"],
  "lookback_days": 180,
  "thesis": "the actual thesis statement",
  "mode": "single"
}}

Rules:
- Extract only tickers explicitly mentioned or strongly implied.
- Do not invent tickers.
- If there are multiple tickers, mode must be "watchlist".
- If there is one ticker, mode must be "single".
- If no timeframe is mentioned, use 180.
- Convert months to days using 30 days per month.
- Convert years to days using 365 days per year.
- Keep the thesis concise but faithful to the user's intent.
- Return JSON only. No markdown. No explanation.

User request:
{text}
""".strip()

    r = oa.responses.create(
        model=model_name,
        input=[{"role": "user", "content": prompt}],
    )

    raw = r.output_text.strip()

    try:
        parsed = json.loads(raw)
    except Exception:
        raise RuntimeError(f"parser returned non-json text: {raw[:500]}")

    return parsed
</code></pre>
<p>This function gives the model one very narrow job. It's not asking for an opinion or analysis. It's only asking for structured extraction. That matters because we want flexibility at the input layer, but we don't want the whole workflow to become fuzzy.</p>
<p>Once the model returns that JSON, Python takes over and tightens it up.</p>
<pre><code class="language-python">def enforce_limits(parsed):
    tickers = parsed.get("tickers", [])
    if not isinstance(tickers, list):
        tickers = []

    tickers = [str(x).upper().strip() for x in tickers if str(x).strip()]
    tickers = tickers[:max_tickers]

    lookback_days = parsed.get("lookback_days", 180)
    try:
        lookback_days = int(lookback_days)
    except Exception:
        lookback_days = 180

    if lookback_days &lt; 1:
        lookback_days = 1
    if lookback_days &gt; max_lookback_days:
        lookback_days = max_lookback_days

    thesis = str(parsed.get("thesis", "")).strip()
    if not thesis:
        thesis = "No thesis provided."

    mode = parsed.get("mode", "single")
    if len(tickers) &gt; 1:
        mode = "watchlist"
    else:
        mode = "single"

    return {
        "tickers": tickers,
        "lookback_days": lookback_days,
        "thesis": thesis,
        "mode": mode,
    }
</code></pre>
<p>This second function is what keeps the workflow controlled. It cleans the tickers, caps how many we allow in one request, clamps the time window, and makes sure the mode matches the number of tickers. So the model gives us flexibility, while the code gives us boundaries. That combination is important for a build like this.</p>
<h2 id="heading-fetching-the-two-data-sources-historical-amp-fundamental-data">Fetching the Two Data Sources: Historical &amp; Fundamental Data</h2>
<p>Once the request is parsed, the next step is to pull the data that will feed the rest of the workflow. For this version, we only use two sources from EODHD: historical prices and fundamentals. That's enough to test a surprising number of thesis types without making the build unnecessarily wide.</p>
<p>Add these two functions to <code>core.py</code>:</p>
<pre><code class="language-python">async def fetch_prices(ticker, start_date, end_date, trace_id, state):
    args = {
        "ticker": ticker,
        "start_date": start_date,
        "end_date": end_date,
        "period": "d",
        "order": "a",
        "fmt": "json",
    }

    out, meta = await mcp.call_tool("get_historical_stock_prices", args, trace_id)
    text = to_text(out)

    bump_tool_call(state, meta)

    if not text:
        raise RuntimeError("empty response from get_historical_stock_prices")

    try:
        data = json.loads(text)
    except Exception:
        raise RuntimeError(f"price tool returned non-json text: {text[:300]}")

    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])

    df = pd.DataFrame(data)
    if df.empty:
        return df

    keep = [c for c in ["date", "close"] if c in df.columns]
    df = df[keep].copy()
    df["ticker"] = ticker

    return df

async def fetch_fundamentals(ticker, trace_id, state):
    args = {
        "ticker": ticker,
        "include_financials": False,
        "fmt": "json",
    }

    out, meta = await mcp.call_tool("get_fundamentals_data", args, trace_id)
    text = to_text(out)

    bump_tool_call(state, meta)

    if not text:
        raise RuntimeError("empty response from get_fundamentals_data")

    try:
        data = json.loads(text)
    except Exception:
        raise RuntimeError(f"fundamentals tool returned non-json text: {text[:300]}")

    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])

    return data
</code></pre>
<ul>
<li><p><code>fetch_prices()</code> pulls daily historical data for the requested window and reduces it to the fields we actually need right now: <code>date</code>, <code>close</code>, and the ticker itself. That trimmed DataFrame is what we'll later use for return, drawdown, volatility, trend, and other market signals.</p>
</li>
<li><p><code>fetch_fundamentals()</code> keeps the fundamentals payload as JSON because we'll extract different categories from it in the next sections, including margins, growth, valuation, revisions, and beta.</p>
</li>
</ul>
<p>A couple of details matter here. Both functions run through the same MCP wrapper, so they automatically inherit the timeout, retry, and metadata handling we already built in <code>client.py</code>. Both also call <code>bump_tool_call()</code>, which lets us track how many external calls were made during a single run. That becomes useful later when we want the workflow to stay inspectable rather than feel like a black box.</p>
<h2 id="heading-building-the-first-evidence-layer-from-price-data">Building the First Evidence Layer from Price&nbsp;Data</h2>
<p>Once the price data is in, the next step is to turn that raw series into something we can actually reason with. For this copilot, price history isn't the final answer, but it is still the first evidence layer. It helps us test claims around downside control, risk, momentum, and the quality of returns.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def compute_price_signals(prices_df):
    if prices_df is None or prices_df.empty:
        return {}

    df = prices_df.copy()
    df["date"] = pd.to_datetime(df["date"], errors="coerce")
    df["close"] = pd.to_numeric(df["close"], errors="coerce")

    df = df.dropna(subset=["date", "close"]).sort_values("date")
    if df.empty:
        return {}

    close = df["close"]
    rets = close.pct_change().dropna()

    out = {
        "n_points": int(len(close)),
        "start_price": float(close.iloc[0]),
        "end_price": float(close.iloc[-1]),
    }

    if len(close) &gt;= 2:
        out["ret_total"] = float(close.iloc[-1] / close.iloc[0] - 1)

    if not rets.empty:
        vol_daily = float(rets.std())
        vol_annualized = float(vol_daily * np.sqrt(252))

        out["vol_daily"] = vol_daily
        out["vol_annualized"] = vol_annualized

        if vol_annualized &gt; 0 and "ret_total" in out:
            out["ret_to_vol"] = float(out["ret_total"] / vol_annualized)

    peak = close.cummax()
    drawdown = close / peak - 1
    out["max_drawdown"] = float(drawdown.min())

    logp = np.log(close.values)
    x = np.arange(len(logp))
    if len(logp) &gt;= 3:
        out["trend_slope"] = float(np.polyfit(x, logp, 1)[0])
    else:
        out["trend_slope"] = 0.0

    return out
</code></pre>
<p>This function gives us a compact set of market signals from a plain close-price series. <code>ret_total</code> tells us how the stock moved over the full window. <code>vol_annualized</code> tells us how noisy that move was. <code>max_drawdown</code> is useful when the thesis talks about downside control. <code>trend_slope</code> gives us a simple directional measure, and <code>ret_to_vol</code> helps us judge return quality instead of looking at raw return alone.</p>
<p>The important point here is that we aren't asking the model to infer all of this from raw prices. We compute it first in Python, so the later reasoning step starts from explicit signals rather than vague interpretation. That makes the whole workflow much more stable.</p>
<h2 id="heading-building-the-second-evidence-layer-from-fundamentals">Building the Second Evidence Layer from Fundamentals</h2>
<p>Price data gives us one side of the thesis. The second side comes from fundamentals. This is the part that makes the project stop sounding generic. Once the copilot starts treating fundamentals as actual evidence, instead of just company profile data, the outputs become much more useful.</p>
<p>Add this helper first in <code>core.py</code>:</p>
<pre><code class="language-python">def _to_float(x):
    if x in (None, "", "NA"):
        return None
    try:
        return float(x)
    except Exception:
        return None
</code></pre>
<p>This small function just cleans values before we use them. Fundamentals payloads often contain strings, nulls, or <code>"NA"</code>, so it helps to normalize everything early.</p>
<p>Now add the main function:</p>
<pre><code class="language-python">def compute_fundamental_signals(fundamentals):
    if not isinstance(fundamentals, dict):
        return {}

    general = fundamentals.get("General", {}) or {}
    highlights = fundamentals.get("Highlights", {}) or {}
    valuation = fundamentals.get("Valuation", {}) or {}
    technicals = fundamentals.get("Technicals", {}) or {}

    earnings = fundamentals.get("Earnings", {}) or {}
    trend = earnings.get("Trend", {}) or {}

    latest_trend = None
    if isinstance(trend, dict) and trend:
        latest_key = sorted(trend.keys())[-1]
        latest_trend = trend.get(latest_key, {}) or {}
    else:
        latest_trend = {}

    out = {
        "sector": general.get("Sector"),
        "industry": general.get("Industry"),
        "employees": _to_float(general.get("FullTimeEmployees")),

        "market_cap": _to_float(highlights.get("MarketCapitalization")),
        "pe_ratio": _to_float(highlights.get("PERatio")),
        "peg_ratio": _to_float(highlights.get("PEGRatio")),
        "profit_margin": _to_float(highlights.get("ProfitMargin")),
        "operating_margin": _to_float(highlights.get("OperatingMarginTTM")),
        "roa": _to_float(highlights.get("ReturnOnAssetsTTM")),
        "roe": _to_float(highlights.get("ReturnOnEquityTTM")),
        "revenue_ttm": _to_float(highlights.get("RevenueTTM")),
        "revenue_growth_yoy": _to_float(highlights.get("QuarterlyRevenueGrowthYOY")),
        "earnings_growth_yoy": _to_float(highlights.get("QuarterlyEarningsGrowthYOY")),
        "dividend_yield": _to_float(highlights.get("DividendYield")),

        "trailing_pe": _to_float(valuation.get("TrailingPE")),
        "forward_pe": _to_float(valuation.get("ForwardPE")),
        "price_sales": _to_float(valuation.get("PriceSalesTTM")),
        "price_book": _to_float(valuation.get("PriceBookMRQ")),
        "ev_revenue": _to_float(valuation.get("EnterpriseValueRevenue")),
        "ev_ebitda": _to_float(valuation.get("EnterpriseValueEbitda")),

        "beta": _to_float(technicals.get("Beta")),

        "earnings_estimate_growth": _to_float(latest_trend.get("earningsEstimateGrowth")),
        "revenue_estimate_growth": _to_float(latest_trend.get("revenueEstimateGrowth")),
        "eps_revisions_up_30d": _to_float(latest_trend.get("epsRevisionsUpLast30days")),
        "eps_revisions_down_30d": _to_float(latest_trend.get("epsRevisionsDownLast30days")),
    }

    if out["trailing_pe"] is not None and out["forward_pe"] is not None:
        out["forward_vs_trailing_pe_change"] = out["forward_pe"] - out["trailing_pe"]

    if out["eps_revisions_up_30d"] is not None and out["eps_revisions_down_30d"] is not None:
        out["net_eps_revisions_30d"] = out["eps_revisions_up_30d"] - out["eps_revisions_down_30d"]

    return out
</code></pre>
<p>This function pulls together the parts of the fundamentals payload that matter most for thesis testing.</p>
<ul>
<li><p>From <code>Highlights</code>, we get profitability, returns on capital, growth, and market cap. From <code>Valuation</code>, we get multiples like trailing P/E, forward P/E, price-to-sales, and EV-based ratios.</p>
</li>
<li><p>From <code>Technicals</code>, we take beta.</p>
</li>
<li><p>From <code>Earnings.Trend</code>, we pick up forward estimate growth and revision data.</p>
</li>
</ul>
<p>These are the fields that let us test claims around business quality, premium justification, valuation, and forward expectations in a much more concrete way.</p>
<p>The last two derived fields are also useful. The gap between forward P/E and trailing P/E gives us a quick way to see whether valuation is easing or staying stretched. Net EPS revisions over the last 30 days tell us whether analyst expectations are improving or deteriorating.</p>
<h2 id="heading-what-do-we-have-so-far">What Do We Have So Far?</h2>
<p>At this point, the copilot can parse a thesis, fetch prices and fundamentals, and convert both into two reusable signal layers:</p>
<ul>
<li><p>Price signals cover return, volatility, drawdown, trend, and return quality</p>
</li>
<li><p>Fundamentals signals cover margins, returns on capital, growth, valuation, revisions, and beta.</p>
</li>
</ul>
<p>Next, we’ll turn those signals into what a real research workflow needs: supporting evidence, weakening evidence, what’s missing, a verdict, and the final memo.</p>
<h2 id="heading-classifying-the-thesis">Classifying the&nbsp;Thesis</h2>
<p>Before the copilot can judge a thesis, it first needs to understand what kind of claim is being made.</p>
<p>This matters because not every thesis should be tested the same way. A claim about controlled downside should care more about drawdown and volatility. A claim about business quality should lean more on margins, returns on capital, and growth. A claim about premium justification may need both business quality and valuation context.</p>
<p>So instead of jumping straight from signals to a verdict, we'll add a small classification step. This gives the system a short list of claim types to work with and a cleaner summary of the thesis.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def classify_thesis(thesis):
    prompt = f"""
You are classifying a stock thesis into a few broad claim types.

Return only valid JSON like this:
{{
  "claim_types": ["controlled_downside", "business_quality"],
  "summary": "short restatement of the thesis"
}}

Allowed claim types:
- controlled_downside
- momentum_strength
- low_risk
- high_risk
- valuation_attractive
- valuation_expensive
- business_quality
- weak_business_quality
- premium_justified
- premium_not_justified

Rules:
- pick only the claim types that are clearly relevant
- do not invent extra labels
- if nothing fits strongly, return an empty list
- summary should be short and faithful

Thesis:
{thesis}
""".strip()

    r = oa.responses.create(
        model=model_name,
        input=[{"role": "user", "content": prompt}],
    )

    raw = r.output_text.strip()

    try:
        out = json.loads(raw)
    except Exception:
        raise RuntimeError(f"thesis classifier returned non-json text: {raw[:500]}")

    claim_types = out.get("claim_types", [])
    if not isinstance(claim_types, list):
        claim_types = []

    clean = []
    allowed = {
        "controlled_downside",
        "momentum_strength",
        "low_risk",
        "high_risk",
        "valuation_attractive",
        "valuation_expensive",
        "business_quality",
        "weak_business_quality",
        "premium_justified",
        "premium_not_justified",
    }

    for x in claim_types:
        x = str(x).strip()
        if x in allowed and x not in clean:
            clean.append(x)

    return {
        "claim_types": clean,
        "summary": str(out.get("summary", "")).strip(),
    }
</code></pre>
<p>This function keeps the model’s job narrow. It's not being asked to decide whether the thesis is right or wrong. It's only being asked to identify the kind of thesis it's dealing with. That makes the next step much cleaner, because the evidence engine no longer has to treat every prompt the same way.</p>
<p>The validation at the bottom is important too. Even though the model returns the labels, Python still filters them through an allowed set and removes anything unexpected. That keeps this step flexible, but still controlled.</p>
<h2 id="heading-turning-signals-into-support-contradiction-and-missing-evidence">Turning Signals into Support, Contradiction, and Missing&nbsp;Evidence</h2>
<p>This is the step where the copilot actually starts reasoning.</p>
<p>Up to this point, we have three things in hand. We have the thesis, we have the claim types, and we have the signal layers built from price data and fundamentals. But none of that is useful on its own unless the system can turn it into a clear argument.</p>
<p>That means it needs to answer three questions for every thesis:</p>
<ul>
<li><p>What in the data supports this claim?</p>
</li>
<li><p>What in the data weakens it?</p>
</li>
<li><p>What is still missing before we can judge it properly?</p>
</li>
</ul>
<p>That's exactly what <code>build_evidence_blocks()</code> does. It takes the classified thesis, checks the relevant price and fundamentals signals, and sorts them into three buckets: support, contradiction, and missing evidence.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def build_evidence_blocks(thesis, thesis_tags, price_signals, fundamental_signals):
    evidence_for = []
    evidence_against = []
    missing_evidence = []

    ret_total = price_signals.get("ret_total")
    vol = price_signals.get("vol_annualized")
    dd = price_signals.get("max_drawdown")
    trend = price_signals.get("trend_slope")
    ret_to_vol = price_signals.get("ret_to_vol")

    pe = fundamental_signals.get("pe_ratio") or fundamental_signals.get("trailing_pe")
    forward_pe = fundamental_signals.get("forward_pe")
    beta = fundamental_signals.get("beta")

    profit_margin = fundamental_signals.get("profit_margin")
    operating_margin = fundamental_signals.get("operating_margin")
    roa = fundamental_signals.get("roa")
    roe = fundamental_signals.get("roe")
    revenue_growth = fundamental_signals.get("revenue_growth_yoy")
    earnings_growth = fundamental_signals.get("earnings_growth_yoy")
    earnings_estimate_growth = fundamental_signals.get("earnings_estimate_growth")
    revenue_estimate_growth = fundamental_signals.get("revenue_estimate_growth")
    net_eps_revisions = fundamental_signals.get("net_eps_revisions_30d")

    claim_types = thesis_tags.get("claim_types", [])

    if "controlled_downside" in claim_types:
        if dd is not None:
            if dd &gt; -0.15:
                evidence_for.append(f"Maximum drawdown was relatively contained at {dd:.2%}.")
            else:
                evidence_against.append(f"Maximum drawdown reached {dd:.2%}, which weakens the controlled-downside claim.")
        else:
            missing_evidence.append("No drawdown signal available to test downside control.")

    if "momentum_strength" in claim_types:
        if trend is not None and ret_total is not None:
            if trend &gt; 0 and ret_total &gt; 0:
                evidence_for.append(f"Trend was positive and total return over the window was {ret_total:.2%}.")
            else:
                evidence_against.append("Trend and total return do not strongly support a momentum-strength view.")
        else:
            missing_evidence.append("No usable trend or return signal available to test momentum.")

    if "low_risk" in claim_types:
        if vol is not None:
            if vol &lt; 0.30:
                evidence_for.append(f"Annualized volatility was {vol:.2%}, which supports a lower-risk view.")
            else:
                evidence_against.append(f"Annualized volatility was {vol:.2%}, which weakens a low-risk thesis.")
        else:
            missing_evidence.append("No volatility signal available to test risk.")

    if "high_risk" in claim_types:
        if vol is not None:
            if vol &gt;= 0.30:
                evidence_for.append(f"Annualized volatility was {vol:.2%}, which supports a higher-risk view.")
            else:
                evidence_against.append(f"Annualized volatility was only {vol:.2%}, which does not strongly support a high-risk thesis.")
        else:
            missing_evidence.append("No volatility signal available to test risk.")

    if "valuation_attractive" in claim_types:
        if pe is not None:
            if pe &lt; 20:
                evidence_for.append(f"P/E is {pe:.2f}, which supports a more attractive valuation view.")
            elif pe &gt; 30:
                evidence_against.append(f"P/E is {pe:.2f}, which weakens the attractive-valuation claim.")
        else:
            missing_evidence.append("No P/E metric available to test valuation attractiveness.")

        if forward_pe is not None and pe is not None:
            if forward_pe &lt; pe:
                evidence_for.append(f"Forward P/E ({forward_pe:.2f}) is below trailing P/E ({pe:.2f}), which can support an improving earnings setup.")

    if "valuation_expensive" in claim_types or "premium_not_justified" in claim_types:
        if pe is not None:
            if pe &gt; 30:
                evidence_for.append(f"P/E is {pe:.2f}, which supports an expensive-valuation view.")
            else:
                evidence_against.append(f"P/E is {pe:.2f}, which does not strongly support an expensive-valuation claim.")
        else:
            missing_evidence.append("No P/E metric available to test whether valuation looks expensive.")

    if "business_quality" in claim_types or "premium_justified" in claim_types:
        quality_hits = 0

        if operating_margin is not None:
            if operating_margin &gt;= 0.25:
                evidence_for.append(f"Operating margin is {operating_margin:.2%}, which supports strong business quality.")
                quality_hits += 1
            else:
                evidence_against.append(f"Operating margin is {operating_margin:.2%}, which is not especially strong for a quality claim.")

        if profit_margin is not None:
            if profit_margin &gt;= 0.20:
                evidence_for.append(f"Profit margin is {profit_margin:.2%}, which supports business quality.")
                quality_hits += 1
            else:
                evidence_against.append(f"Profit margin is {profit_margin:.2%}, which weakens a strong-quality thesis.")

        if roa is not None:
            if roa &gt;= 0.10:
                evidence_for.append(f"ROA is {roa:.2%}, which supports efficient asset use.")
                quality_hits += 1
            else:
                evidence_against.append(f"ROA is {roa:.2%}, which does not strongly support a quality claim.")

        if roe is not None:
            if roe &gt;= 0.20:
                evidence_for.append(f"ROE is {roe:.2%}, which supports strong capital efficiency.")
                quality_hits += 1
            else:
                evidence_against.append(f"ROE is {roe:.2%}, which is weaker than expected for a strong-quality thesis.")

        if revenue_growth is not None:
            if revenue_growth &gt; 0:
                evidence_for.append(f"Quarterly revenue growth was {revenue_growth:.2%} YoY, which supports business momentum.")
                quality_hits += 1
            else:
                evidence_against.append(f"Quarterly revenue growth was {revenue_growth:.2%} YoY, which weakens the quality claim.")

        if earnings_growth is not None:
            if earnings_growth &gt; 0:
                evidence_for.append(f"Quarterly earnings growth was {earnings_growth:.2%} YoY, which supports operating strength.")
                quality_hits += 1
            else:
                evidence_against.append(f"Quarterly earnings growth was {earnings_growth:.2%} YoY, which weakens the quality claim.")

        if earnings_estimate_growth is not None:
            if earnings_estimate_growth &gt; 0:
                evidence_for.append(f"Forward earnings estimate growth is {earnings_estimate_growth:.2%}, which supports a healthier forward outlook.")
            else:
                evidence_against.append(f"Forward earnings estimate growth is {earnings_estimate_growth:.2%}, which weakens the quality argument.")

        if revenue_estimate_growth is not None:
            if revenue_estimate_growth &gt; 0:
                evidence_for.append(f"Forward revenue estimate growth is {revenue_estimate_growth:.2%}, which supports ongoing business strength.")
            else:
                evidence_against.append(f"Forward revenue estimate growth is {revenue_estimate_growth:.2%}, which weakens the quality argument.")

        if net_eps_revisions is not None:
            if net_eps_revisions &gt; 0:
                evidence_for.append(f"Net EPS revisions over the last 30 days are positive ({net_eps_revisions:.0f}), which supports improving expectations.")
            elif net_eps_revisions &lt; 0:
                evidence_against.append(f"Net EPS revisions over the last 30 days are negative ({net_eps_revisions:.0f}), which weakens the thesis.")

        if quality_hits == 0:
            missing_evidence.append("This version could not extract enough direct business-quality metrics to test the quality claim.")

    if "weak_business_quality" in claim_types:
        if operating_margin is not None and operating_margin &lt; 0.15:
            evidence_for.append(f"Operating margin is only {operating_margin:.2%}, which supports a weaker-quality view.")
        if profit_margin is not None and profit_margin &lt; 0.10:
            evidence_for.append(f"Profit margin is only {profit_margin:.2%}, which supports a weaker-quality view.")
        if revenue_growth is not None and revenue_growth &lt;= 0:
            evidence_for.append(f"Revenue growth is {revenue_growth:.2%} YoY, which supports a weaker-quality view.")
        if earnings_growth is not None and earnings_growth &lt;= 0:
            evidence_for.append(f"Earnings growth is {earnings_growth:.2%} YoY, which supports a weaker-quality view.")

    if beta is not None:
        if beta &gt; 1.2:
            evidence_against.append(f"Beta is {beta:.2f}, which suggests above-market sensitivity.")
        elif beta &lt; 0.9:
            evidence_for.append(f"Beta is {beta:.2f}, which suggests below-market sensitivity.")
    else:
        missing_evidence.append("No beta value available.")

    if ret_to_vol is None:
        missing_evidence.append("No return-to-volatility signal available.")

    if not evidence_for and not evidence_against:
        missing_evidence.append("The current data is not enough to strongly support or reject the thesis.")

    return {
        "thesis": thesis,
        "thesis_summary": thesis_tags.get("summary", ""),
        "claim_types": claim_types,
        "evidence_for": evidence_for,
        "evidence_against": evidence_against,
        "missing_evidence": list(dict.fromkeys(missing_evidence)),
    }
</code></pre>
<p>The function looks long, but the logic is simple once you break it down.</p>
<p>It starts by pulling the signals it needs from the two evidence layers that we built earlier. Then it checks the thesis tags one by one. If the thesis is about controlled downside, it looks at drawdown. If it's about risk, it looks at volatility and beta. If't is about business quality, it leans on margins, returns on capital, growth, and revisions. If it's about valuation, it checks multiples like P/E and the relationship between forward and trailing valuation.</p>
<p>That's the key shift in this project. The copilot is no longer just collecting data. It's deciding which parts of the EODHD-backed signal set actually matter for the thesis in front of it.</p>
<p>The three output buckets are what make this useful.</p>
<ul>
<li><p><code>evidence_for</code> holds the points that support the claim.</p>
</li>
<li><p><code>evidence_against</code> holds the points that weaken it.</p>
</li>
<li><p><code>missing_evidence</code> makes the gaps explicit instead of letting the system sound more confident than it should.</p>
</li>
</ul>
<p>That's what makes this feel like a thesis-testing workflow rather than a polished stock summary.</p>
<h3 id="heading-sanity-check-jupyter-notebook">Sanity Check (Jupyter Notebook)</h3>
<p>Run this code inside <code>test.ipynb</code> for a quick sanity check:</p>
<pre><code class="language-python">import uuid
from core import (
    fetch_prices,
    fetch_fundamentals,
    compute_price_signals,
    classify_thesis,
    build_evidence_blocks,
    make_state
)
import json

trace_id = uuid.uuid4().hex[:10]
state = make_state()

thesis = "Apple looks attractive because downside has been controlled and business quality remains high."

prices = await fetch_prices("AAPL.US", "2026-01-01", "2026-04-01", trace_id, state)
funds = await fetch_fundamentals("AAPL.US", trace_id, state)

signals = compute_price_signals(prices)
tags = classify_thesis(thesis)
evidence = build_evidence_blocks(thesis, tags, signals, funds)

print(tags)
print(json.dumps(evidence, indent=2))
</code></pre>
<p><strong>Expected Output:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/38ec0e04-b237-4ebb-8b26-61e2f82f36b0.png" alt="Sanity check expected output" style="display:block;margin:0 auto" width="1500" height="508" loading="lazy">

<h2 id="heading-assigning-a-verdict">Assigning a&nbsp;Verdict</h2>
<p>Once the evidence is structured, the copilot still needs one more layer before it can write a memo. It needs a controlled way to label the thesis.</p>
<p>That's the job of <code>decide_verdict()</code>. It looks at how much evidence supports the thesis, how much weakens it, and whether the claim still depends on missing business-quality or valuation evidence. The goal here isn't to create a perfect scoring model. It's to make sure the system doesn't jump from a few evidence strings straight into a confident conclusion.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def decide_verdict(evidence, claim_types=None):
    claim_types = claim_types or []

    evidence_for = evidence.get("evidence_for", [])
    evidence_against = evidence.get("evidence_against", [])
    missing = evidence.get("missing_evidence", [])

    n_for = len(evidence_for)
    n_against = len(evidence_against)
    n_missing = len(missing)

    quality_claim = any(x in claim_types for x in ["business_quality", "weak_business_quality", "premium_justified", "premium_not_justified"])
    valuation_claim = any(x in claim_types for x in ["valuation_attractive", "valuation_expensive", "premium_justified", "premium_not_justified"])

    if n_for == 0 and n_against == 0:
        return {
            "verdict": "unresolved_due_to_missing_evidence",
            "reason": "There is not enough usable evidence to test the thesis.",
        }

    if quality_claim and n_missing &gt;= 1:
        if n_against &gt; 0:
            return {
                "verdict": "weakly_supported",
                "reason": "Some evidence supports the thesis, but direct business-quality evidence is missing and contradictory signals remain.",
            }
        return {
            "verdict": "partially_supported",
            "reason": "Part of the thesis is supported, but direct business-quality evidence is missing.",
        }

    if valuation_claim and n_missing &gt;= 1:
        return {
            "verdict": "unresolved_due_to_missing_evidence",
            "reason": "The thesis depends on valuation evidence that is not available in this version.",
        }

    if n_for &gt; 0 and n_against == 0:
        if n_missing &gt;= 2:
            return {
                "verdict": "partially_supported",
                "reason": "The available evidence supports the thesis, but important evidence is still missing.",
            }
        return {
            "verdict": "supported",
            "reason": "The available evidence mainly supports the thesis.",
        }

    if n_against &gt; 0 and n_for == 0:
        return {
            "verdict": "not_supported",
            "reason": "The available evidence mainly weakens the thesis.",
        }

    if n_for &gt; n_against:
        return {
            "verdict": "partially_supported",
            "reason": "There is more supporting evidence than contradicting evidence, but the thesis is not fully confirmed.",
        }

    if n_against &gt;= n_for:
        return {
            "verdict": "weakly_supported",
            "reason": "Contradicting evidence is meaningful enough that the thesis is only weakly supported.",
        }

    return {
        "verdict": "unresolved_due_to_missing_evidence",
        "reason": "The evidence is mixed and does not clearly resolve the thesis.",
    }
</code></pre>
<p>The logic here is intentionally simple. It doesn't try to do fine-grained scoring. Instead, it uses the shape of the evidence to decide whether the thesis is supported, partially supported, weakly supported, not supported, or still unresolved.</p>
<p>A couple of checks matter more than the rest. If the thesis depends on business-quality or valuation evidence and that evidence is still missing, the verdict gets capped early instead of sounding stronger than it should. That is important because a thesis can look convincing on price behavior alone, but still be incomplete if the claim depends on fundamentals that aren't actually present.</p>
<p>The other useful thing about this function is that it returns both a short label and a reason. That makes the final output easier to understand later, and it also gives the memo-writing step something cleaner to work from than a bare category.</p>
<h2 id="heading-building-the-facts-object">Building the Facts&nbsp;Object</h2>
<p>Before the memo gets written, the system first puts everything into one structured object. That object becomes the single source of truth for the final output. Instead of handing the model a mix of scattered variables, we'll give it one clean package containing the thesis, signals, company context, evidence, and verdict.</p>
<h3 id="heading-1-company-context">1. Company&nbsp;Context</h3>
<p>We’ll start with a small helper that pulls the basic company context from the fundamentals payload.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def extract_company_context(fundamentals):
    if not isinstance(fundamentals, dict):
        return {}

    gen = fundamentals.get("General", {}) or {}

    out = {
        "name": gen.get("Name"),
        "code": gen.get("Code"),
        "exchange": gen.get("Exchange"),
        "sector": gen.get("Sector"),
        "industry": gen.get("Industry"),
        "country": gen.get("CountryName"),
        "market_cap": gen.get("MarketCapitalization"),
        "pe_ratio": gen.get("PERatio"),
        "beta": gen.get("Beta"),
        "dividend_yield": gen.get("DividendYield"),
        "description": gen.get("Description"),
    }

    clean = {}
    for k, v in out.items():
        if v not in (None, "", "NA"):
            clean[k] = v

    return clean
</code></pre>
<p>This function is just a cleanup step. It gives us a compact company context block that can later sit alongside the price and fundamentals signals without dragging the full fundamentals payload into the memo layer.</p>
<h3 id="heading-2-single-stock-facts-builder">2. Single-Stock Facts&nbsp;Builder</h3>
<p>Now add the single-stock facts builder:</p>
<pre><code class="language-python">def build_thesis_facts(parsed, ticker, signals, fundamentals, thesis_tags, evidence):
    company = extract_company_context(fundamentals)

    facts = {
        "type": "single_name_thesis_test",
        "ticker": ticker,
        "lookback_days": parsed["lookback_days"],
        "thesis": parsed["thesis"],
        "thesis_summary": thesis_tags.get("summary", ""),
        "claim_types": thesis_tags.get("claim_types", []),
        "market_signals": {
            "ret_total": signals.get("ret_total"),
            "vol_annualized": signals.get("vol_annualized"),
            "max_drawdown": signals.get("max_drawdown"),
            "trend_slope": signals.get("trend_slope"),
            "ret_to_vol": signals.get("ret_to_vol"),
            "start_price": signals.get("start_price"),
            "end_price": signals.get("end_price"),
            "n_points": signals.get("n_points"),
        },
        "company_context": {
            "name": company.get("name"),
            "exchange": company.get("exchange"),
            "sector": company.get("sector"),
            "industry": company.get("industry"),
            "country": company.get("country"),
            "market_cap": company.get("market_cap"),
            "pe_ratio": company.get("pe_ratio"),
            "beta": company.get("beta"),
            "dividend_yield": company.get("dividend_yield"),
        },
        "description": company.get("description"),
        "evidence_for": evidence.get("evidence_for", []),
        "evidence_against": evidence.get("evidence_against", []),
        "missing_evidence": evidence.get("missing_evidence", []),
    }

    facts["verdict"] = decide_verdict(evidence, thesis_tags.get("claim_types", []))
    return facts
</code></pre>
<p>This is the main facts object for a single-stock thesis. It pulls together the parsed thesis, the market signals, the basic company context, the evidence buckets, and the verdict. At this point, the copilot has already done the reasoning work. The memo isn't deciding anything new. It's just writing from this object.</p>
<h3 id="heading-3-watchlist-facts-builder">3. Watchlist Facts&nbsp;Builder</h3>
<p>Now add the watchlist version:</p>
<pre><code class="language-python">def build_watchlist_facts(parsed, tickers, signals_by_ticker, fundamentals_by_ticker, thesis_tags, evidence_by_ticker):
    per_ticker = {}

    for t in tickers:
        company = extract_company_context(fundamentals_by_ticker.get(t, {}))
        signals = signals_by_ticker.get(t, {})
        evidence = evidence_by_ticker.get(t, {})

        per_ticker[t] = {
            "company_context": {
                "name": company.get("name"),
                "sector": company.get("sector"),
                "industry": company.get("industry"),
                "market_cap": company.get("market_cap"),
                "pe_ratio": company.get("pe_ratio"),
                "beta": company.get("beta"),
            },
            "market_signals": {
                "ret_total": signals.get("ret_total"),
                "vol_annualized": signals.get("vol_annualized"),
                "max_drawdown": signals.get("max_drawdown"),
                "trend_slope": signals.get("trend_slope"),
                "ret_to_vol": signals.get("ret_to_vol"),
            },
            "evidence_for": evidence.get("evidence_for", []),
            "evidence_against": evidence.get("evidence_against", []),
            "missing_evidence": evidence.get("missing_evidence", []),
            "verdict": decide_verdict(evidence, thesis_tags.get("claim_types", []))
        }

    facts = {
        "type": "watchlist_thesis_test",
        "tickers": tickers,
        "lookback_days": parsed["lookback_days"],
        "thesis": parsed["thesis"],
        "thesis_summary": thesis_tags.get("summary", ""),
        "claim_types": thesis_tags.get("claim_types", []),
        "per_ticker": per_ticker,
    }

    return facts
</code></pre>
<p>This version does the same thing, but across multiple tickers. Instead of one top-level evidence block, it stores a per-ticker structure so the memo layer can later compare names without needing to reconstruct anything.</p>
<p>That is the main reason this section matters. By the time we reach the memo step, we no longer want to pass loose values around. We want one structured object that already contains:</p>
<ul>
<li><p>the thesis</p>
</li>
<li><p>the relevant signals</p>
</li>
<li><p>the company context</p>
</li>
<li><p>the evidence buckets</p>
</li>
<li><p>the verdict</p>
</li>
</ul>
<p>That keeps the final writing step much cleaner and makes the whole workflow easier to debug.</p>
<h3 id="heading-sanity-check-jupyter-notebook">Sanity Check (Jupyter Notebook)</h3>
<p>Run this code inside <code>test.ipynb</code> for a quick sanity check:</p>
<pre><code class="language-python">from core import build_thesis_facts, extract_company_context

facts = build_thesis_facts(
    parsed={
        "tickers": ["AAPL"],
        "lookback_days": 180,
        "thesis": "Apple looks attractive because downside has been controlled and business quality remains high.",
        "mode": "single"
    },
    ticker="AAPL.US",
    signals=signals,
    fundamentals=funds,
    thesis_tags=tags,
    evidence=evidence
)

print(json.dumps(facts, indent=2))
</code></pre>
<p><strong>Expected Output:</strong></p>
<pre><code class="language-json">{
  "type": "single_name_thesis_test",
  "ticker": "AAPL.US",
  "lookback_days": 180,
  "thesis": "Apple looks attractive because downside has been controlled and business quality remains high.",
  "thesis_summary": "Apple is attractive due to controlled downside and strong business quality",
  "claim_types": [
    "controlled_downside",
    "business_quality"
  ],
  "market_signals": {
    "ret_total": -0.05675067340688533,
    "vol_annualized": 0.2504818805125429,
    "max_drawdown": -0.11322450740687473,
    "trend_slope": -0.0005437843809243782,
    "ret_to_vol": -0.22656598270006817,
    "start_price": 271.01,
    "end_price": 255.63,
    "n_points": 62
  },
  "company_context": {
    "name": "Apple Inc",
    "exchange": "NASDAQ",
    "sector": "Technology",
    "industry": "Consumer Electronics",
    "country": "USA",
    "market_cap": null,
    "pe_ratio": null,
    "beta": null,
    "dividend_yield": null
  },
  "description": "Apple Inc. designs, manufactures, and markets smartphones, personal computers, tablets, wearables, and accessories worldwide. The company offers iPhone, a line of smartphones; Mac, a line of personal computers; iPad, a line of multi-purpose tablets; and wearables, home, and accessories comprising AirPods, Apple Vision Pro, Apple TV, Apple Watch, Beats products, and HomePod, as well as Apple branded and third-party accessories. It also provides AppleCare support and cloud services; and operates various platforms, including the App Store that allow customers to discover and download applications and digital content, such as books, music, video, games, and podcasts, as well as advertising services include third-party licensing arrangements and its own advertising platforms. In addition, the company offers various subscription-based services, such as Apple Arcade, a game subscription service; Apple Fitness+, a personalized fitness service; Apple Music, which offers users a curated listening experience with on-demand radio stations; Apple News+, a subscription news and magazine service; Apple TV, which offers exclusive original content and live sports; Apple Card, a co-branded credit card; and Apple Pay, a cashless payment service, as well as licenses its intellectual property. The company serves consumers, and small and mid-sized businesses; and the education, enterprise, and government markets. It distributes third-party applications for its products through the App Store. The company also sells its products through its retail and online stores, and direct sales force; and third-party cellular network carriers and resellers. The company was formerly known as Apple Computer, Inc. and changed its name to Apple Inc. in January 2007. Apple Inc. was founded in 1976 and is headquartered in Cupertino, California.",
  "evidence_for": [
    "Maximum drawdown was relatively contained at -11.32%."
  ],
  "evidence_against": [],
  "missing_evidence": [
    "This version does not include direct business-quality metrics such as margins, growth, cash flow, or return on capital.",
    "Only basic company context is available, which is not enough on its own to confirm business quality.",
    "No beta value available."
  ],
  "verdict": {
    "verdict": "partially_supported",
    "reason": "Part of the thesis is supported, but direct business-quality evidence is missing."
  }
}
</code></pre>
<h2 id="heading-writing-the-final-memo">Writing the Final&nbsp;Memo</h2>
<p>At this point, the hard part is already done.</p>
<p>By the time we reach the memo step, the copilot already has a structured facts object with the thesis, claim types, market signals, company context, evidence buckets, and verdict. So this final function isn't where the reasoning happens. It's just the presentation layer that turns that structured judgment into something readable.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">def write_thesis_memo(facts):
    prompt = f"""
You are writing a short financial research memo.

Write using only the facts provided below.
Do not invent numbers, events, comparisons, or opinions beyond the supplied evidence.
If evidence is missing, say so clearly.

Use this exact structure:

1. Thesis under review
2. Supporting evidence
3. Evidence that weakens the thesis
4. Missing evidence
5. Verdict
6. Bottom-line assessment

Style rules:
- Keep it concise
- Keep it analytical and professional
- No bullet points unless necessary
- No hype
- No generic investment disclaimer language
- The bottom-line assessment should be balanced and evidence-based
- The verdict section must explicitly use the supplied verdict

Facts:
{json.dumps(facts, indent=2, default=str)}
""".strip()

    r = oa.responses.create(
        model=model_name,
        input=[{"role": "user", "content": prompt}],
    )

    return r.output_text.strip()
</code></pre>
<p>This function keeps the model boxed into one narrow task. It's not being asked to look at raw price history, raw fundamentals, or scattered variables. It's being asked to write from one clean facts object that already contains the judgment.</p>
<p>That separation matters because it keeps the final memo grounded. The model isn't deciding what it thinks about the stock at the last second. It's simply turning the structured output of the earlier steps into a short research note.</p>
<p>The prompt is also deliberately strict. It fixes the memo structure, tells the model not to invent anything, and makes the verdict explicit instead of leaving it implied. That helps the final output stay consistent even when the underlying thesis changes.</p>
<h3 id="heading-sanity-check-jupyter-notebook">Sanity Check (Jupyter Notebook)</h3>
<p>You can test it with a facts object from the previous section:</p>
<pre><code class="language-python">from core import write_thesis_memo

memo = write_thesis_memo(facts)
print(memo)
</code></pre>
<p><strong>Expected Output:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/b5f44144-8da4-4c9a-8a59-c5ac6915a6b0.png" alt="Sanity check expected output" style="display:block;margin:0 auto" width="1500" height="606" loading="lazy">

<h2 id="heading-stitching-everything-together">Stitching Everything Together</h2>
<p>At this point, all the individual pieces are ready. We have the parser, the data fetchers, the signal builders, the thesis classifier, the evidence engine, the verdict layer, and the memo writer. The only thing left is to connect them into one end-to-end function.</p>
<p>Add this to <code>core.py</code>:</p>
<pre><code class="language-python">async def run_thesis_copilot(user_text):
    trace_id = uuid.uuid4().hex[:10]
    log_event("request_started", trace_id, text=user_text)

    parsed = enforce_limits(parse_request(user_text))
    tickers = parsed["tickers"]

    if not tickers:
        return {
            "memo": "No valid ticker was found in the request.",
            "facts": {},
            "data_used": {},
            "tool_trace_id": trace_id,
        }

    log_event(
        "parsed",
        trace_id,
        tickers=tickers,
        lookback_days=parsed["lookback_days"],
        mode=parsed["mode"],
        thesis=parsed["thesis"],
    )

    start_date, end_date = get_dates_from_lookback(parsed["lookback_days"])
    state = make_state()

    try:
        thesis_tags = classify_thesis(parsed["thesis"])

        if parsed["mode"] == "single":
            ticker = tickers[0]
            ticker_full = ticker if "." in ticker else f"{ticker}.US"

            log_event(
                "tool_phase",
                trace_id,
                mode="single",
                ticker=ticker_full,
                start_date=start_date,
                end_date=end_date,
            )

            prices = await fetch_prices(ticker_full, start_date, end_date, trace_id, state)
            funds = await fetch_fundamentals(ticker_full, trace_id, state)

            price_signals = compute_price_signals(prices)
            fundamental_signals = compute_fundamental_signals(funds)

            evidence = build_evidence_blocks(
                parsed["thesis"],
                thesis_tags,
                price_signals,
                fundamental_signals
            )

            facts = build_thesis_facts(
                parsed,
                ticker_full,
                price_signals,
                funds,
                thesis_tags,
                evidence
            )

            facts["fundamental_signals"] = fundamental_signals

            memo = write_thesis_memo(facts)

            out = {
                "memo": memo,
                "facts": facts,
                "data_used": {
                    "tickers": [ticker_full],
                    "date_range": [start_date, end_date],
                    "tools_called": [x.get("tool") for x in state["tool_trace"]],
                    "tool_calls": state["tool_calls"],
                },
                "tool_trace_id": trace_id,
            }

            log_event("request_finished", trace_id, tool_calls=state["tool_calls"])
            return out

        ticker_full = [x if "." in x else f"{x}.US" for x in tickers]

        log_event(
            "tool_phase",
            trace_id,
            mode="watchlist",
            tickers=ticker_full,
            start_date=start_date,
            end_date=end_date,
        )

        signals_by_ticker = {}
        funds_by_ticker = {}
        evidence_by_ticker = {}

        for t in ticker_full:
            prices = await fetch_prices(t, start_date, end_date, trace_id, state)
            funds = await fetch_fundamentals(t, trace_id, state)

            price_signals = compute_price_signals(prices)
            fundamental_signals = compute_fundamental_signals(funds)

            evidence = build_evidence_blocks(
                parsed["thesis"],
                thesis_tags,
                price_signals,
                fundamental_signals
            )

            signals_by_ticker[t] = {
                **price_signals,
                "fundamental_signals": fundamental_signals
            }
            funds_by_ticker[t] = funds
            evidence_by_ticker[t] = evidence

        facts = build_watchlist_facts(
            parsed,
            ticker_full,
            signals_by_ticker,
            funds_by_ticker,
            thesis_tags,
            evidence_by_ticker,
        )

        memo = write_thesis_memo(facts)

        out = {
            "memo": memo,
            "facts": facts,
            "data_used": {
                "tickers": ticker_full,
                "date_range": [start_date, end_date],
                "tools_called": [x.get("tool") for x in state["tool_trace"]],
                "tool_calls": state["tool_calls"],
            },
            "tool_trace_id": trace_id,
        }

        log_event("request_finished", trace_id, tool_calls=state["tool_calls"])
        return out

    except Exception as e:
        detail = repr(e)
        if hasattr(e, "exceptions"):
            detail = detail + " | " + " ; ".join([repr(x) for x in e.exceptions])

        log_event("request_failed", trace_id, err=detail)

        return {
            "memo": f"failed: {e}",
            "facts": {},
            "data_used": {
                "tickers": tickers,
                "date_range": [start_date, end_date],
                "tools_called": [x.get("tool") for x in state["tool_trace"]],
                "tool_calls": state["tool_calls"],
            },
            "tool_trace_id": trace_id,
        }
</code></pre>
<p>This function is just the full workflow in one place. It parses the request, fetches the data, computes the two signal layers, builds the evidence, assembles the facts object, writes the memo, and returns everything in a clean output.</p>
<p>The useful part is that it returns more than just the memo. It also returns the structured facts object, the tools that were used, the date range, and the trace ID. That keeps the final result inspectable instead of turning the copilot into a black box.</p>
<h2 id="heading-demo-time-jupyter-notebook">Demo Time! (Jupyter Notebook)</h2>
<h3 id="heading-demo-1-testing-whether-a-premium-is-actually-justified">Demo 1: Testing Whether a Premium Is Actually Justified</h3>
<p>This is a good first demo because it pushes the copilot beyond a basic single-stock check. The prompt isn't asking whether NVIDIA is a good company in general. It's asking whether NVIDIA’s premium over AMD can actually be defended using market behavior and business quality.</p>
<p>Here's the prompt:</p>
<pre><code class="language-python">from core import run_thesis_copilot

q = """
Between NVDA and AMD, I think NVDA's premium is still justified by stronger market behavior and business quality.
Check that over the last 6 months.
""".strip()

result = await run_thesis_copilot(q)

print(result["memo"])
print(result["data_used"])
</code></pre>
<p>And here's the output:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/e4a9e881-243a-47bb-b36b-1e273deb8e04.png" alt="Demo 1 output" style="display:block;margin:0 auto" width="1398" height="793" loading="lazy">

<p>What makes this output useful is that it doesn't flatten the result into a simple yes or no. NVIDIA clearly looks stronger on business quality, but market behavior isn't as convincing, and the lack of direct valuation data stops the copilot from overclaiming.</p>
<p>This is the kind of behavior we want. The system isn't just comparing two companies. It's testing whether the specific claim about a premium actually holds up.</p>
<h3 id="heading-demo-2-testing-whether-volatility-is-too-high-for-the-underlying-business">Demo 2: Testing Whether Volatility Is Too High for the Underlying Business</h3>
<p>The second demo shifts back to a single-stock thesis, but the claim is different. This time, the question isn't whether the company looks attractive. It's whether the stock is more volatile than the underlying business quality would justify.</p>
<p>Here's the prompt:</p>
<pre><code class="language-python">q = """
TSLA feels too volatile for the underlying business quality.
Test that thesis over the last year.
""".strip()

result = await run_thesis_copilot(q)

print(result["memo"])
print(result["data_used"])
</code></pre>
<p>And here's the output:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f362fe21017f7317167b14c/a9767ee9-d227-4478-a2aa-9ee62c46488c.png" alt="Demo 2 output" style="display:block;margin:0 auto" width="1500" height="679" loading="lazy">

<p>This result is useful because it shows a more conflicted thesis. Tesla’s recent returns and forward growth expectations offer some support, but the current profitability, recent operating trends, revisions, and volatility profile all push back against the idea that the business quality is strong enough to fully justify that risk.</p>
<p>So the final verdict lands where it should: not as a clean confirmation, but as a weakly supported thesis.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>At this point, the copilot already does the most important part well. It can take a natural-language thesis, pull the right market and fundamentals data through EODHD’s MCP layer, turn those inputs into structured evidence, and return a research memo that's much more disciplined than a normal stock summary.</p>
<p>At the same time, this version still has clear limits. It doesn't yet go deeper into statement-level accounting logic, it doesn't use news or catalyst context, and its handling of relative valuation can still be stronger for more demanding comparison cases.</p>
<p>But even with those limits, the shift here is already meaningful. The real change wasn't just connecting a model to financial data. It was moving from summarizing stocks to testing claims.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Agentic Terminal Workflow with GitHub Copilot CLI and MCP Servers ]]>
                </title>
                <description>
                    <![CDATA[ Most developers live in their terminal. You run commands, debug pipelines, manage infrastructure, and navigate codebases, all from a shell prompt. But despite how central the terminal is to developer  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-agentic-terminal-workflow-with-github-copilot-cli-and-mcp-servers/</link>
                <guid isPermaLink="false">69f212526e0124c05e1857b5</guid>
                
                    <category>
                        <![CDATA[ Developer Tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ terminal ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp server ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Caleb Mintoumba ]]>
                </dc:creator>
                <pubDate>Wed, 29 Apr 2026 14:14:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3e4e3d7e-6cbf-4742-a63b-f9a2579f2318.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most developers live in their terminal. You run commands, debug pipelines, manage infrastructure, and navigate codebases, all from a shell prompt.</p>
<p>But despite how central the terminal is to developer workflows, AI assistance there has remained shallow: autocomplete a command here, explain an error there.</p>
<p>That changes when you combine GitHub Copilot CLI with MCP (Model Context Protocol) servers. Instead of an AI that reacts to isolated prompts, you get a terminal that understands your project context, queries live data sources, and chains tool calls autonomously – what the industry is starting to call an agentic workflow.</p>
<p>In this tutorial, you'll learn exactly how to wire these two systems together, step by step. By the end, your terminal will be able to do things like understand your Git history before suggesting a fix, query your running Docker containers before writing a compose patch, or pull live API schemas before generating a request.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-github-copilot-cli">What is GitHub Copilot CLI?</a></p>
</li>
<li><p><a href="#heading-what-is-the-model-context-protocol">What is the Model Context Protocol?</a></p>
</li>
<li><p><a href="#heading-how-mcp-servers-work-in-a-terminal-context">How MCP Servers Work in a Terminal Context</a></p>
</li>
<li><p><a href="#heading-step-1-install-and-configure-github-copilot-cli">Step 1 – Install and Configure GitHub Copilot CLI</a></p>
</li>
<li><p><a href="#heading-step-2-set-up-your-first-mcp-server">Step 2 – Set Up Your First MCP Server</a></p>
</li>
<li><p><a href="#heading-step-3-wire-copilot-cli-to-your-mcp-server">Step 3 – Wire Copilot CLI to Your MCP Server</a></p>
</li>
<li><p><a href="#heading-step-4-build-a-real-agentic-workflow">Step 4 – Build a Real Agentic Workflow</a></p>
</li>
<li><p><a href="#heading-step-5-extend-with-multiple-mcp-servers">Step 5 – Extend with Multiple MCP Servers</a></p>
</li>
<li><p><a href="#heading-debugging-common-issues">Debugging Common Issues</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before you start, make sure you have the following:</p>
<ul>
<li><p><strong>Node.js</strong> v18 or later (<code>node --version</code>)</p>
</li>
<li><p><strong>npm</strong> v9 or later</p>
</li>
<li><p>A GitHub account with Copilot enabled. The free tier (available to all GitHub users) is sufficient to follow this tutorial. Pro, Business, and Enterprise plans unlock higher usage limits but aren't required.</p>
</li>
<li><p><strong>GitHub CLI</strong> (<code>gh</code>) installed. We'll use it to authenticate.</p>
</li>
<li><p>Basic familiarity with the terminal and JSON configuration files</p>
</li>
<li><p>(Optional) <strong>Docker</strong> installed if you want to follow the Docker MCP example in Step 5</p>
</li>
</ul>
<p>You don't need prior experience with MCP or agentic AI systems, as this guide builds that understanding from the ground up.</p>
<h2 id="heading-what-is-github-copilot-cli">What is GitHub Copilot CLI?</h2>
<p>GitHub Copilot CLI is the terminal-native interface to GitHub's Copilot AI. Unlike the IDE plugin (which assists with code completion), Copilot CLI is designed specifically for shell workflows. It exposes three main commands:</p>
<ul>
<li><p><code>gh copilot suggest</code> proposes a shell command based on a natural language description</p>
</li>
<li><p><code>gh copilot explain</code> explains what a given command does</p>
</li>
<li><p><code>gh copilot alias</code> generates shell aliases for Copilot subcommands</p>
</li>
</ul>
<p>Here's a quick example of <code>suggest</code> in action:</p>
<pre><code class="language-shell">gh copilot suggest "find all files modified in the last 24 hours and larger than 1MB"
</code></pre>
<p>Copilot will return something like:</p>
<pre><code class="language-shell">find . -mtime -1 -size +1M
</code></pre>
<p>It will also ask if you want to copy it, run it directly, or revise the request. This interactive loop is already useful – but by itself, Copilot CLI has no awareness of your project context. It doesn't know your repo structure, your running services, or your deployment environment. That's where MCP comes in.</p>
<h2 id="heading-what-is-the-model-context-protocol">What is the Model Context Protocol?</h2>
<p>The <strong>Model Context Protocol (MCP)</strong> is an open standard introduced by Anthropic in late 2024. Its goal is straightforward: give AI models a standardized way to connect to external tools, data sources, and services.</p>
<p>Think of MCP as a universal adapter layer between an AI model and the real world. Without MCP, each AI integration is custom-built: one plugin for GitHub, another for Postgres, another for Slack, all with incompatible interfaces. MCP defines a single protocol that any tool can implement, and any compatible AI client can consume.</p>
<p>An MCP server exposes <strong>tools</strong> (functions the AI can call), <strong>resources</strong> (data the AI can read), and <strong>prompts</strong> (reusable instruction templates). The AI client in our case, a Copilot-powered terminal discovers these capabilities at runtime and uses them autonomously to complete a task.</p>
<p>A few notable MCP servers that are already production-ready:</p>
<table>
<thead>
<tr>
<th>MCP Server</th>
<th>What it exposes</th>
</tr>
</thead>
<tbody><tr>
<td>@modelcontextprotocol/server-filesystem</td>
<td>Read/write access to local files</td>
</tr>
<tr>
<td>@modelcontextprotocol/server-git</td>
<td>Git log, diff, blame, branch operations</td>
</tr>
<tr>
<td>@modelcontextprotocol/server-github</td>
<td>GitHub Issues, PRs, repos via API</td>
</tr>
<tr>
<td>@modelcontextprotocol/server-postgres</td>
<td>Live query execution on a Postgres DB</td>
</tr>
<tr>
<td>@modelcontextprotocol/server-docker</td>
<td>Container inspection, logs, stats</td>
</tr>
</tbody></table>
<p>The full registry lives at <code>github.com/modelcontextprotocol/servers</code>.</p>
<h2 id="heading-how-mcp-servers-work-in-a-terminal-context">How MCP Servers Work in a Terminal Context</h2>
<p>Before we get hands-on, it's worth understanding the communication model.</p>
<p>MCP servers run as local processes. They communicate with the AI client over <strong>stdio</strong> (standard input/output) or over an <strong>HTTP/SSE transport</strong>. The client sends JSON-RPC messages to the server, and the server responds with structured data.</p>
<p>Here's the simplified flow:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66f71ee288cc311f84e563bc/e1844dc5-a869-4201-ad8a-fd1cb305f646.png" alt="An architectural flowchart illustrating the Model Context Protocol (MCP) workflow. The process starts with a user typing a natural language prompt, passes through the Copilot CLI (the MCP client), communicates via JSON-RPC over stdio with an MCP Server (e.g., server-git), executes real tools like git log, returns a structured result to Copilot, which finally synthesizes a context-aware response for the user." style="display:block;margin:0 auto" width="1408" height="768" loading="lazy">

<p>The key word here is <strong>grounded</strong>. Without MCP, Copilot responds based purely on its training data and your prompt. With MCP, it can call <code>git log --oneline -20</code> before answering your question about recent regressions and its answer is based on <em>your actual code history</em>, not a generalized assumption.</p>
<h3 id="heading-step-1-install-and-configure-github-copilot-cli">Step 1 – Install and Configure GitHub Copilot CLI</h3>
<p>If you haven't already, install the GitHub CLI:</p>
<pre><code class="language-shell"># macOS
brew install gh

# Ubuntu/Debian
sudo apt install gh

# Windows (via winget)
winget install --id GitHub.cli
</code></pre>
<p>Then authenticate:</p>
<pre><code class="language-shell">gh auth login
</code></pre>
<p>Follow the interactive prompts. Select <strong>GitHub.com</strong>, then <strong>HTTPS</strong>, and authenticate via browser when prompted.</p>
<p>Now install the Copilot CLI extension:</p>
<pre><code class="language-shell">gh extension install github/gh-copilot
</code></pre>
<p>Verify the installation:</p>
<pre><code class="language-shell">gh copilot --version
</code></pre>
<p>You should see output like <code>gh-copilot version 1.x.x</code>.</p>
<p><strong>Optional but recommended: set up shell aliases.</strong> This makes the workflow much faster. For <code>bash</code> or <code>zsh</code>:</p>
<pre><code class="language-shell"># Add to your ~/.bashrc or ~/.zshrc
eval "$(gh copilot alias -- bash)"   # for bash
eval "$(gh copilot alias -- zsh)"    # for zsh
</code></pre>
<p>After reloading your shell (<code>source ~/.bashrc</code>), you can use <code>ghcs</code> as shorthand for <code>gh copilot suggest</code> and <code>ghce</code> for <code>gh copilot explain</code>.</p>
<h3 id="heading-step-2-set-up-your-first-mcp-server">Step 2 – Set Up Your First MCP Server</h3>
<p>We'll start with <code>server-git</code>. It's the most immediately useful for a development workflow and has zero external dependencies.</p>
<p>Install it globally via npm:</p>
<pre><code class="language-shell">npm install -g @modelcontextprotocol/server-git
</code></pre>
<p>Test that it runs:</p>
<pre><code class="language-shell">mcp-server-git --version
</code></pre>
<p>This server exposes the following tools to any compatible MCP client:</p>
<ul>
<li><p><code>git_log</code> retrieve commit history with filters</p>
</li>
<li><p><code>git_diff</code> diff between branches or commits</p>
</li>
<li><p><code>git_status</code> current working tree status</p>
</li>
<li><p><code>git_show</code> inspect a specific commit</p>
</li>
<li><p><code>git_blame</code> annotate file lines with commit info</p>
</li>
<li><p><code>git_branch</code> list or switch branches</p>
</li>
</ul>
<p>Now create a configuration file. MCP clients look for a file called <code>mcp.json</code> to discover available servers. Create it in your project root or in a global config directory:</p>
<pre><code class="language-shell">mkdir -p ~/.config/mcp
touch ~/.config/mcp/mcp.json
</code></pre>
<p>Add the following content:</p>
<pre><code class="language-markdown">{
  "mcpServers": {
    "git": {
      "command": "mcp-server-git",
      "args": ["--repository", "."],
      "transport": "stdio"
    }
  }
}
</code></pre>
<p>A few notes on this config:</p>
<ul>
<li><p><code>command</code> is the binary to run. Make sure it's on your <code>$PATH</code>.</p>
</li>
<li><p><code>args</code> passes <code>--repository .</code> so the server scopes itself to the current working directory.</p>
</li>
<li><p><code>transport: "stdio"</code> means communication happens over standard input/output the simplest and most stable option for local servers.</p>
</li>
</ul>
<h3 id="heading-step-3-wire-copilot-cli-to-your-mcp-server">Step 3 – Wire Copilot CLI to Your MCP Server</h3>
<p>This is where the two systems connect. GitHub Copilot CLI supports MCP via its <code>--mcp-config</code> flag (available from version 1.3+). You point it at your <code>mcp.json</code>, and Copilot will automatically initialize the declared servers before processing your prompt.</p>
<p>Here's the basic invocation:</p>
<pre><code class="language-shell">gh copilot suggest --mcp-config ~/.config/mcp/mcp.json "why did the build break in the last commit?"
</code></pre>
<p>When you run this inside a Git repository, Copilot CLI will:</p>
<ol>
<li><p>Start the <code>mcp-server-git</code> process</p>
</li>
<li><p>Call <code>git_log</code> to retrieve recent commits</p>
</li>
<li><p>Call <code>git_diff</code> on the most recent commit</p>
</li>
<li><p>Synthesize an answer based on the actual diff output</p>
</li>
</ol>
<p>Try it yourself on a repo with a recent failing commit. The difference in response quality compared to a plain <code>gh copilot suggest</code> is immediately obvious.</p>
<p><strong>Tip: avoid retyping the flag every time.</strong> Add a shell function to your <code>.bashrc</code>/<code>.zshrc</code>:</p>
<pre><code class="language-shell">function aterm() {
  gh copilot suggest --mcp-config ~/.config/mcp/mcp.json "$@"
}
</code></pre>
<p>Now you just type:</p>
<pre><code class="language-shell">aterm "what changed between main and feature/auth?"
</code></pre>
<p>And you're running a fully context-aware, MCP-powered query from a single short command. This function name <code>aterm</code> for <em>agentic terminal</em> is what we'll use throughout the rest of this tutorial.</p>
<h3 id="heading-step-4-build-a-real-agentic-workflow">Step 4 – Build a Real Agentic Workflow</h3>
<p>Let's move beyond individual queries and build a workflow that chains multiple tool calls to complete a real developer task: <strong>diagnosing a regression</strong>.</p>
<p>Imagine you pushed a feature branch and your CI pipeline failed. You don't know exactly which change caused it. Here's how your agentic terminal handles it:</p>
<h4 id="heading-query-1-understand-what-changed">Query 1: understand what changed</h4>
<pre><code class="language-shell">aterm "summarize all commits on feature/auth that aren't on main yet"
</code></pre>
<p>Copilot calls <code>git_log</code> with branch filters, then returns a structured summary of commits unique to your branch. No copy-pasting SHAs manually.</p>
<h4 id="heading-query-2-isolate-the-diff">Query 2: isolate the diff</h4>
<pre><code class="language-shell">aterm "show me everything that changed in the auth middleware between main and feature/auth"
</code></pre>
<p>This triggers <code>git_diff</code> scoped to the path containing your middleware. Copilot returns the diff with an explanation of what each change does.</p>
<h4 id="heading-query-3-find-the-likely-culprit">Query 3: find the likely culprit</h4>
<pre><code class="language-shell">aterm "which of those changes could cause a JWT validation failure?"
</code></pre>
<p>At this point, Copilot has the diff in its context window from the previous tool calls. It reasons over the actual code changes not generic knowledge about JWT and pinpoints the likely issue.</p>
<h4 id="heading-query-4-generate-the-fix">Query 4: generate the fix</h4>
<pre><code class="language-shell">aterm "write the corrected version of that validation function"
</code></pre>
<p>Copilot generates a targeted fix based on the specific code it retrieved via MCP. You get a patch you can directly apply, not a generic code template.</p>
<p>This four-step sequence – understand, isolate, reason, fix – is a complete agentic loop. Each step is grounded in live repository data retrieved through MCP tools. The AI is not hallucinating context. Instead, it's reading your actual codebase.</p>
<h3 id="heading-step-5-extend-with-multiple-mcp-servers">Step 5 – Extend with Multiple MCP Servers</h3>
<p>One MCP server is useful. Multiple MCP servers working together is where the workflow becomes genuinely powerful. Let's add two more: <code>server-filesystem</code> and <code>server-docker</code>.</p>
<p>Install the additional servers:</p>
<pre><code class="language-shell">npm install -g @modelcontextprotocol/server-filesystem
npm install -g @modelcontextprotocol/server-docker
</code></pre>
<p>Update your <code>mcp.json</code>:</p>
<pre><code class="language-markdown">{
  "mcpServers": {
    "git": {
      "command": "mcp-server-git",
      "args": ["--repository", "."],
      "transport": "stdio"
    },
    "filesystem": {
      "command": "mcp-server-filesystem",
      "args": ["--root", "."],
      "transport": "stdio"
    },
    "docker": {
      "command": "mcp-server-docker",
      "transport": "stdio"
    }
  }
}
</code></pre>
<p>With all three servers active, your terminal can now answer cross-domain questions:</p>
<pre><code class="language-shell">aterm "my Express app container keeps restarting, check the logs and compare with what the healthcheck in my Dockerfile expects"
</code></pre>
<p>To answer this, Copilot will:</p>
<ol>
<li><p>Call <code>docker_logs</code> (server-docker) to pull the container's recent stderr output</p>
</li>
<li><p>Call <code>read_file</code> (server-filesystem) to read your <code>Dockerfile</code></p>
</li>
<li><p>Parse the <code>HEALTHCHECK</code> instruction</p>
</li>
<li><p>Cross-reference the log errors with the health endpoint path</p>
</li>
<li><p>Return a diagnosis explaining the mismatch and suggest the fix</p>
</li>
</ol>
<p>This is an <strong>agentic workflow</strong>: the model autonomously decides which tools to call, in what order, and synthesizes the results into a coherent answer. You didn't tell it to read the Dockerfile. It inferred that was necessary based on your question.</p>
<p><strong>A note on security:</strong> When running <code>server-filesystem</code>, always scope it to a specific directory using <code>--root</code>. Never point it at <code>/</code> or your home directory. Similarly, <code>server-docker</code> has access to your Docker socket run it only in trusted environments.</p>
<h2 id="heading-debugging-common-issues">Debugging Common Issues</h2>
<p><code>mcp-server-git: command not found</code></p>
<p>The npm global bin directory isn't on your <code>$PATH</code>. Fix:</p>
<pre><code class="language-shell">export PATH="\(PATH:\)(npm bin -g)"
# or for newer npm versions:
export PATH="\(PATH:\)(npm prefix -g)/bin"
</code></pre>
<p>Add this line to your <code>.bashrc</code>/<code>.zshrc</code> to persist it.</p>
<h4 id="heading-copilot-cli-doesnt-seem-to-be-using-mcp-tools">Copilot CLI doesn't seem to be using MCP tools</h4>
<p>Check your Copilot CLI version:</p>
<pre><code class="language-shell">gh copilot --version
</code></pre>
<p>MCP support requires version 1.3 or later. Update with:</p>
<pre><code class="language-shell">gh extension upgrade copilot
</code></pre>
<p>Also verify your <code>mcp.json</code> is valid JSON a trailing comma or missing bracket will silently prevent server initialization.</p>
<h4 id="heading-mcp-server-starts-but-returns-no-data">MCP server starts but returns no data</h4>
<p>Run the server manually to check for errors:</p>
<pre><code class="language-shell">mcp-server-git --repository .
</code></pre>
<p>If it exits immediately, check that you're running the command inside a valid Git repository. For <code>server-docker</code>, make sure the Docker daemon is running and your user has access to the Docker socket:</p>
<pre><code class="language-shell">sudo usermod -aG docker $USER
# Then log out and back in
</code></pre>
<h4 id="heading-responses-are-slow-with-multiple-servers">Responses are slow with multiple servers</h4>
<p>Each MCP server is a separate subprocess. Spawning several at once adds startup latency, especially on slower machines. Two optimizations:</p>
<ol>
<li><p>Only declare the servers you actually need for a given project in your <code>mcp.json</code></p>
</li>
<li><p>Use project-specific config files instead of one global config:</p>
</li>
</ol>
<pre><code class="language-shell"># project A (backend)
aterm --mcp-config ./mcp-backend.json "..."

# project B (infra)
aterm --mcp-config ./mcp-infra.json "..."
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've just built an agentic terminal workflow from scratch. Here's a quick recap of what you did:</p>
<ul>
<li><p>Installed and configured GitHub Copilot CLI with shell aliases for fast access</p>
</li>
<li><p>Set up MCP servers (<code>server-git</code>, <code>server-filesystem</code>, <code>server-docker</code>) and wired them through a <code>mcp.json</code> config</p>
</li>
<li><p>Created a shell function (<code>aterm</code>) that transparently passes your MCP config to every Copilot query</p>
</li>
<li><p>Built a multi-step agentic loop for diagnosing regressions using live Git data</p>
</li>
<li><p>Extended the setup with cross-domain tool orchestration across Git, filesystem, and Docker</p>
</li>
</ul>
<p>The architecture you've built here is not a demo – it's a production-ready pattern. You can extend it with any MCP-compatible server: <code>server-postgres</code> for database-aware queries, <code>server-github</code> for issue and PR context, or custom MCP servers you write yourself for your internal APIs.</p>
<p>The terminal has always been the most powerful surface in a developer's environment. With Copilot CLI and MCP, it's finally becoming an intelligent one.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use the Model Context Protocol to Build a Personal Financial Assistant ]]>
                </title>
                <description>
                    <![CDATA[ LLMs are great at writing market commentary. The problem is they can sound confident even when they haven't looked at any data. That’s fine for casual chat, but it’s not fine if you’re building a feat ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-mcp-to-build-a-personal-financial-assistant/</link>
                <guid isPermaLink="false">69c4104010e664c5dac37aed</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Wed, 25 Mar 2026 16:41:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d0911eef-bfd9-49f7-92ce-8890d8222efd.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>LLMs are great at writing market commentary. The problem is they can sound confident even when they haven't looked at any data. That’s fine for casual chat, but it’s not fine if you’re building a feature for a product, an internal tool, or anything a user might rely on.</p>
<p>In this guide, we’ll build a small financial assistant that fetches real data by calling tools exposed via the MCP protocol (Model Context Protocol), then computes the numbers in Python. The LLM’s job is only to narrate the computed facts. It doesn't invent metrics, and it doesn't do the math.</p>
<p>By the end, you’ll have two outputs you can actually plug into a product flow: a single-ticker market brief, and a watchlist snapshot that compares multiple tickers on volatility and drawdown, with the tool calls traced so you can see exactly what data was used.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-is-mcp-and-how-does-it-change-the-integration-story">What is MCP, and How Does it Change the Integration Story?</a></p>
</li>
<li><p><a href="#heading-architecture-the-narrator-pattern">Architecture: The “Narrator” Pattern</a></p>
</li>
<li><p><a href="#heading-step-1-mcp-client-wrapper-clientpy">Step 1: MCP Client Wrapper (<code>client.py</code>)</a></p>
</li>
<li><p><a href="#heading-step-2-the-assistant-core-corepy">Step 2: The Assistant Core (<code>core.py</code>)</a></p>
<ul>
<li><p><a href="#heading-1-budgets-and-trace-logging">1. Budgets and Trace Logging</a></p>
</li>
<li><p><a href="#heading-2-parsing-the-request">2. Parsing the Request</a></p>
</li>
<li><p><a href="#heading-3-tool-wrappers-prices-and-fundamentals">3. Tool Wrappers: Prices and Fundamentals</a></p>
</li>
<li><p><a href="#heading-4-deterministic-metrics">4. Deterministic Metrics</a></p>
</li>
<li><p><a href="#heading-5-watchlist-utilities">5. Watchlist Utilities</a></p>
</li>
<li><p><a href="#heading-6-facts-object-and-narration">6. Facts Object and Narration</a></p>
</li>
<li><p><a href="#heading-7-the-orchestration-function-run_assistant">7. The Orchestration Function (<code>run_assistant()</code>)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-demo-1-market-brief-for-one-ticker">Demo 1: Market Brief for One Ticker</a></p>
</li>
<li><p><a href="#heading-demo-2-watchlist-snapshot">Demo 2: Watchlist Snapshot</a></p>
</li>
<li><p><a href="#heading-what-makes-this-shippable-and-what-can-be-improved">What Makes this Shippable, and What Can Be Improved?</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This is a code-first guide. I won’t explain every line of Python, so you should be comfortable reading pandas code, basic async/await patterns, and calling APIs from Python.</p>
<p>Before you start, you’ll need:</p>
<ul>
<li><p>Python 3.10+</p>
</li>
<li><p>An EODHD API key (to access the EODHD MCP server)</p>
</li>
<li><p>An OpenAI API key (for the narration step)</p>
</li>
<li><p>The MCP Python client installed, plus the usual data stack: numpy and pandas</p>
</li>
<li><p>A local environment where you can run async Python code (Jupyter or a normal script both work)</p>
</li>
</ul>
<p>If you’ve never worked with async code before, you can still follow along. Just treat the async functions as "network calls" and focus on how the data flows from tool calls, to deterministic metrics, to narration.</p>
<h2 id="heading-what-is-mcp-and-how-does-it-change-the-integration-story">What is MCP, and How Does it Change the Integration Story?</h2>
<img src="https://cdn-images-1.medium.com/max/1000/0*zHMQKv6lzgY5-X7p" alt="source - https://www.civo.com/blog/what-is-mcp" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>MCP (Model Context Protocol) is a protocol for how an LLM application can discover and call external tools exposed by an MCP server. Instead of hardcoding a bunch of function schemas or building custom connectors per framework, you plug into an MCP server and the tools become “available” in a consistent format.</p>
<p>For product teams, this matters because it reduces integration churn. Tool discovery is predictable, you’re not rewriting wrappers every time your stack changes, and you get a clean separation between the model and the data layer.</p>
<p>In our case, that data layer is EOD Historical Data (EODHD), a market data provider. We’ll use <a href="https://eodhd.com/financial-apis/mcp-server-for-financial-data-by-eodhd">EODHD’s MCP server</a>, which exposes market data tools the assistant can call whenever it needs prices or fundamentals.</p>
<p>One important clarification for this tutorial: we’re using an MCP server purely as the data access layer. The model doesn’t decide which MCP tools to call or what parameters to pass. We'll do that deterministically in Python, then hand the model a facts object and let it write the narrative. This keeps the output grounded and makes the system much easier to trust and debug.</p>
<h2 id="heading-architecture-the-narrator-pattern">Architecture: The “Narrator” Pattern</h2>
<p>Here’s the architecture we’re using in this guide:</p>
<img src="https://cdn-images-1.medium.com/max/1500/1*Ljxbr06gEJSs2QdQnPcQSw.png" alt="Architecture diagram showing request parsing, MCP tool calls to EODHD, deterministic Python metrics, and LLM narration" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The idea is simple: we'll separate “getting facts” from “writing words”. The model only does the second part.</p>
<p>First, the user asks a question like “Give me a 30-day brief for AAPL” or “Compare TSLA, NVDA, AMZN over the last 60 days”. That raw text goes into a tiny parser. The parser is intentionally boring. It only extracts what the system needs to operate: a list of tickers and a lookback window.</p>
<p>Once we have tickers and dates, we fetch data by calling MCP tools on the EODHD MCP server. In this case, our MCP client connects to the EODHD MCP server. So instead of the assistant guessing prices or fundamentals, it calls tools like “get historical prices” and “get fundamentals”. At this point we have raw data. Nothing has been computed yet, and the model has not written a single sentence.</p>
<p>Then Python takes over. This is where we compute everything deterministically: returns, volatility, max drawdown, trend slope, and a simple volatility regime label. For watchlists, we align returns and compute correlation. These numbers are the backbone of the output. If you rerun the same query with the same window, you should get the same metrics.</p>
<p>Only after that do we involve the LLM. We pass it a compact facts object. It contains the metrics we computed, plus a few clean fundamentals fields. The prompt is strict. Use only these facts – no extra numbers and no guessing. The model’s job is to turn the facts into a clean note that feels like something a product would show.</p>
<p>Finally, the assistant returns a structured response object. Not just text. You get:</p>
<ul>
<li><p><code>answer</code> (the narrative)</p>
</li>
<li><p><code>metrics</code> (the exact computed numbers)</p>
</li>
<li><p><code>data_used</code> (tickers, date range, and which tools were called)</p>
</li>
<li><p><code>tool_trace_id</code> (a trace id you can log, debug, or attach to monitoring)</p>
</li>
</ul>
<p>This pattern is B2B-friendly for a very practical reason. It reduces hallucinations because the model isn’t doing analysis. It makes numbers repeatable because Python computes them. And it’s easy to audit because you can always show what data was fetched, what window was used, and which tool calls happened.</p>
<h2 id="heading-step-1-mcp-client-wrapper-clientpy">Step 1: MCP Client Wrapper (<code>client.py</code>)</h2>
<p>Before we touch any “assistant logic”, we need one thing: a tiny MCP client wrapper that opens MCP sessions to the EODHD MCP server and calls tools reliably. That’s it.</p>
<p>This file does three jobs:</p>
<ul>
<li><p>opens a streamable HTTP MCP session</p>
</li>
<li><p>calls a tool with a timeout and a small retry loop</p>
</li>
<li><p>returns the tool output plus a small metadata object we can later attach to logs and traces</p>
</li>
</ul>
<p>Here’s the complete <code>client.py</code>:</p>
<pre><code class="language-python">import time
import asyncio

from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

class EODHDMCP:
    def __init__(self, apikey, base_url=None):
        self.apikey = apikey
        self.base_url = base_url or "https://mcp.eodhd.dev/mcp"
        self._tools = None

    def _url(self):
        return f"{self.base_url}?apikey={self.apikey}"

    def _open(self):
        return streamable_http_client(self._url())

    async def list_tools(self):
        if self._tools is not None:
            return self._tools

        async with self._open() as (read, write, _):
            async with ClientSession(read, write) as s:
                await s.initialize()
                resp = await s.list_tools()
                self._tools = [t.name for t in resp.tools]
                return self._tools

    async def call_tool(self, name, args, trace_id, timeout_s=25, retries=1):
        last = None

        for attempt in range(retries + 1):
            t0 = time.time()
            try:
                async with self._open() as (read, write, _):
                    async with ClientSession(read, write) as s:
                        await s.initialize()
                        out = await asyncio.wait_for(s.call_tool(name, args), timeout=timeout_s)
                        dt = time.time() - t0
                        meta = {"trace_id": trace_id, "tool": name, "args": args, "latency_s": round(dt, 3)}
                        return out, meta
            except Exception as e:
                last = e
                if attempt &lt; retries:
                    await asyncio.sleep(0.25)

        raise last
</code></pre>
<p>How this works:</p>
<ul>
<li><p><code>streamablehttp_client(self._url())</code> opens an MCP session over streamable HTTP. The URL includes your API key as a query param, so the server can authenticate.</p>
</li>
<li><p><code>list_tools()</code> is just a convenience. It asks the server which tools exist and caches the names in memory so you don’t fetch them repeatedly.</p>
</li>
<li><p><code>call_tool()</code> is the workhorse. It opens a session, initializes it, calls a tool with <code>call_tool(name, args)</code>, and wraps the result with a <code>meta</code> object.</p>
</li>
<li><p>That <code>meta</code> object is important later. It lets you trace which tool was called, with which params, how long it took, and which request it belonged to (<code>trace_id</code>).</p>
</li>
</ul>
<p>Next, we’ll build the core runner in <code>core.py</code>. This is where we parse the user’s request, fetch prices and fundamentals via MCP, compute metrics in Python, and then hand the facts to the LLM for narration.</p>
<h2 id="heading-step-2-the-assistant-core-corepy">Step 2: The Assistant Core (<code>core.py</code>)</h2>
<p>This is where the assistant actually becomes “real”. <code>client.py</code> was just a connector. Here we decide what data to fetch, how much to fetch, how to compute the numbers, and what we hand to the model for narration.</p>
<h3 id="heading-1-budgets-and-trace-logging">1. Budgets and Trace&nbsp;Logging</h3>
<p>When you build anything that calls real tools, you want limits. Not because you don’t trust your code, but because without limits, one messy prompt can easily turn into an expensive, slow request.</p>
<p>In our case, we cap:</p>
<ul>
<li><p>how far back we’ll fetch data (<code>MAX_LOOKBACK_DAYS</code>)</p>
</li>
<li><p>how many tool calls we allow per request (<code>MAX_TOOL_CALLS</code>)</p>
</li>
<li><p>how many tickers we’ll accept in one query (<code>MAX_TICKERS</code>)</p>
</li>
</ul>
<p>And we log a few events so we can always debug what happened later.</p>
<p>Here’s the top part of <code>core.py</code> for that:</p>
<pre><code class="language-python">import json
import re
import time
import uuid
from datetime import date, timedelta
from openai import OpenAI
import numpy as np
import pandas as pd
import asyncio
from client import EODHDMCP

EODHD_API_KEY = "YOUR EODHD API KEY"
MCP_BASE_URL = "https://mcp.eodhd.dev/mcp"

MAX_LOOKBACK_DAYS = 365
MAX_TOOL_CALLS = 6
MAX_TICKERS = 5

mcp = EODHDMCP(EODHD_API_KEY, base_url=MCP_BASE_URL)
oa = OpenAI(api_key = "OPENAI API KEY")
NARRATION_MODEL = "gpt-5.3-chat-latest"

def log_event(event, trace_id, **k):
    payload = {"event": event, "trace_id": trace_id, "ts": round(time.time(), 3)}
    payload.update(k)
    print(json.dumps(payload, default=str))
</code></pre>
<p>What’s going on here:</p>
<ul>
<li><p><code>MAX_LOOKBACK_DAYS</code>, <code>MAX_TOOL_CALLS</code>, <code>MAX_TICKERS</code> are basically your safety rails. We’ll enforce them later, right after parsing the user query.</p>
</li>
<li><p><code>trace_id</code> is a small id we generate per request. Every log line includes it, so when something breaks, you can reconstruct the exact flow for that request.</p>
</li>
<li><p><code>log_event()</code> prints one JSON line. Nothing fancy –&nbsp;but it’s enough for debugging and it also looks very similar to how real systems emit traces.</p>
</li>
</ul>
<p>Note: Make sure to replace <code>YOUR EODHD API KEY</code> with your actual EODHD API key. If you don’t have one, you can obtain it by creating an EODHD developer account.</p>
<h3 id="heading-2-parsing-the-request">2. Parsing the&nbsp;Request</h3>
<p>This part is intentionally not “smart”. We’re not doing NLP. We’re not letting the model interpret the query. We just want to extract two things in a predictable way:</p>
<ul>
<li><p>tickers</p>
</li>
<li><p>lookback window</p>
</li>
</ul>
<p>That’s it.</p>
<p>The benefit of keeping it dumb is that the behavior is stable. If the query is messy, we still do something consistent, and the rest of the pipeline remains controllable.</p>
<p>Here are the two functions:</p>
<pre><code class="language-python">def parse_request(text):
    t = (text or "").upper()

    raw = re.findall(r"\b[A-Z]{1,5}\b", t)

    bad = {
        "I","A","AN","THE","AND","OR","TO","FOR","OF","IN","ON","BY","WITH","ME","WE","US",
        "GIVE","DAY","DAYS","BRIEF","COMPARE","RANK","OVER","LAST","TREND","VOL","VOLATILITY",
        "DRAWDOWN","FLAG","RISKS","RISK","PLUS","MAX","MIN","LOOKBACK"
    }

    tickers = []
    for x in raw:
        if x in bad:
            continue
        if len(x) &lt; 2:
            continue
        if x not in tickers:
            tickers.append(x)

    days = 30

    if "LAST" in t:
        after = t.split("LAST", 1)[1]
        m = re.search(r"\d{1,4}", after)
        if m:
            days = int(m.group(0))
    
    return tickers, days

def enforce_budgets(tickers, lookback_days):
    if lookback_days &lt; 1:
        lookback_days = 1
    if lookback_days &gt; MAX_LOOKBACK_DAYS:
        lookback_days = MAX_LOOKBACK_DAYS

    tickers = tickers[:MAX_TICKERS]

    return tickers, lookback_days
</code></pre>
<p>How to read this:</p>
<ul>
<li><p><code>re.findall(r"\b[A-Z]{1,5}\b", t)</code> pulls out every short uppercase token. That’s our crude “ticker candidate” list.</p>
</li>
<li><p>The <code>bad</code> set is just a blacklist of common words that show up in prompts but are obviously not tickers.</p>
</li>
<li><p>We keep unique tickers in order, because the first ticker becomes the “base” for correlation in the watchlist demo.</p>
</li>
<li><p>Lookback is simple: the default is 30 days. If the query contains “last&nbsp;…”, we grab the first number after “LAST”. That avoids regex edge cases with punctuation.</p>
</li>
</ul>
<p>Then <code>enforce_budgets()</code> clamps everything so one request can’t ask for 500 tickers or a 10-year window.</p>
<p>Next, we’ll wire these parsed values into a request state and start making actual MCP calls for prices and fundamentals.</p>
<h3 id="heading-3-tool-wrappers-prices-and-fundamentals">3. Tool Wrappers: Prices and Fundamentals</h3>
<p>Now we’re at the point where the assistant actually touches data.</p>
<p>These two functions do the same job in different ways:</p>
<ul>
<li><p><code>fetch_prices()</code> calls the historical prices tool on the EODHD MCP server, then normalizes the output into a tiny DataFrame with just <code>date</code> and <code>price</code>.</p>
</li>
<li><p><code>fetch_fundamentals()</code> calls the fundamentals tool on the EODHD MCP server.</p>
</li>
</ul>
<p>We also keep a small <code>state</code> object per request. It tracks tool calls and keeps a trace of what was called. That’s how we later produce the <code>data_used</code> block in the final response.</p>
<p>Here’s the code:</p>
<pre><code class="language-python">def new_state():
    return {"tool_calls": 0, "tool_trace": [], "rows": {}}

def _bump(state, meta):
    state["tool_calls"] += 1
    state["tool_trace"].append(meta)
    if state["tool_calls"] &gt; MAX_TOOL_CALLS:
        raise RuntimeError("tool call budget exceeded")

def _as_json_text(out):
    if isinstance(out, str):
        return out
    if hasattr(out, "content"):
        try:
            return out.content[0].text
        except Exception:
            pass
    return str(out)

async def fetch_prices(ticker, start_date, end_date, trace_id, state):
    args = {
        "ticker": ticker,
        "start_date": start_date,
        "end_date": end_date,
        "period": "d",
        "order": "a",
        "fmt": "json",
    }

    out, meta = await mcp.call_tool("get_historical_stock_prices", args, trace_id)
    txt = _as_json_text(out)

    _bump(state, meta)

    data = json.loads(txt)
    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])

    df = pd.DataFrame(data)
    if df.empty:
        return df

    cols = [c for c in ["date", "adjusted_close", "close"] if c in                   df.columns]
    df = df[cols].copy()

    if "adjusted_close" in df.columns:
        df = df.rename(columns={"adjusted_close": "price"})
    elif "close" in df.columns:
        df = df.rename(columns={"close": "price"})
    else:
        return pd.DataFrame()

    df["ticker"] = ticker

    state["rows"][f"{meta['tool']}:{ticker}"] = len(df)
    return df

async def fetch_fundamentals(ticker, trace_id, state):
    args = {
        "ticker": ticker,
        "include_financials": False,
        "fmt": "json",
    }

    out, meta = await mcp.call_tool("get_fundamentals_data", args, trace_id)
    txt = _as_json_text(out)

    _bump(state, meta)

    data = json.loads(txt)
    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])

    return data
</code></pre>
<p>What’s happening here:</p>
<ul>
<li><p><code>_bump()</code> is the budget guard. Every time we make a tool call, we increment the counter and store the tool metadata. If we cross the budget, we fail fast.</p>
</li>
<li><p><code>meta</code> comes from <code>client.py</code>. It contains <code>tool</code>, <code>args</code>, and latency. That’s enough to trace “what did we call and how long did it take”.</p>
</li>
<li><p><code>_as_json_text()</code> is there because the tool results returned by the MCP server are not always plain strings. Sometimes it’s an object with&nbsp;<code>.content</code>. This helper just tries to extract the text cleanly.</p>
</li>
<li><p>In <code>fetch_prices()</code>, we intentionally keep only <code>date</code> and <code>price</code>. That’s not because OHLC is useless. It’s because this tutorial’s metrics only need adjusted closes. Fewer columns means simpler code, smaller payloads, and fewer chances to break.</p>
</li>
</ul>
<p>Next, we’ll compute the actual metrics. This is where the assistant stops being “an API caller” and starts producing something useful.</p>
<h3 id="heading-4-deterministic-metrics">4. Deterministic Metrics</h3>
<p>This is the most important design choice in the whole build. The model never computes numbers. Python does.</p>
<p>So for every ticker, we compute a small set of metrics that are easy to explain and are actually useful in a “market brief” style output:</p>
<ul>
<li><p>total return over the window</p>
</li>
<li><p>realized volatility (daily and annualized)</p>
</li>
<li><p>max drawdown (worst peak-to-trough fall)</p>
</li>
<li><p>a simple trend slope (so we can say “mild uptrend” or “downtrend” without vibes)</p>
</li>
<li><p>a lightweight regime label (low, mid, high volatility)</p>
</li>
</ul>
<p>Here’s the code:</p>
<pre><code class="language-python">def compute_metrics(prices_df):
    if prices_df is None or prices_df.empty:
        return {}

    df = prices_df.copy()
    df["date"] = pd.to_datetime(df["date"], errors="coerce")
    df = df.dropna(subset=["date"]).sort_values("date")

    close = pd.to_numeric(df["price"], errors="coerce").dropna()
    if close.empty:
        return {}

    rets = close.pct_change().dropna()

    out = {}

    # realized vol (daily), annualize with sqrt(252)
    if not rets.empty:
        out["vol_daily"] = float(rets.std())
        out["vol_annualized"] = float(rets.std() * np.sqrt(252))
        out["ret_total"] = float((close.iloc[-1] / close.iloc[0]) - 1.0)

    # max drawdown
    peak = close.cummax()
    dd = (close / peak) - 1.0
    out["max_drawdown"] = float(dd.min())

    # simple trend score
    logp = np.log(close.values)
    x = np.arange(len(logp))
    if len(logp) &gt;= 3:
        slope = np.polyfit(x, logp, 1)[0]
        out["trend_slope"] = float(slope)
    else:
        out["trend_slope"] = 0.0

    # basic helpers
    out["n_points"] = int(len(close))
    out["start_close"] = float(close.iloc[0])
    out["end_close"] = float(close.iloc[-1])

    return out

def compute_regime(prices_df, window=20):
    # cheap regime label, based on rolling vol percentile
    if prices_df is None or prices_df.empty:
        return {"regime": "unknown"}

    df = prices_df.copy()
    df["date"] = pd.to_datetime(df["date"], errors="coerce")
    df = df.dropna(subset=["date"]).sort_values("date")

    close = pd.to_numeric(df["price"], errors="coerce").dropna()
    if close.empty:
        return {"regime": "unknown"}

    rets = close.pct_change()
    rv = rets.rolling(window).std()

    last = rv.dropna()
    if last.empty:
        return {"regime": "unknown"}

    cur = float(last.iloc[-1])
    p80 = float(last.quantile(0.8))
    p50 = float(last.quantile(0.5))

    if cur &gt;= p80:
        reg = "high_vol"
    elif cur &gt;= p50:
        reg = "mid_vol"
    else:
        reg = "low_vol"

    return {"regime": reg, "rolling_vol": cur, "window": int(window)}
</code></pre>
<p>How to think about these calculations:</p>
<ul>
<li><p><strong>Total return</strong> is just <code>end / start - 1</code>. It’s the simplest “did it go up or down” number.</p>
</li>
<li><p><strong>Volatility</strong> here is realized volatility of daily returns. That’s just the standard deviation of daily % changes. We annualize it using <code>sqrt(252)</code> because markets have roughly 252 trading days.</p>
</li>
<li><p><strong>Max drawdown</strong> tells you how bad the worst dip was during the window. It’s often more meaningful than return when you’re writing a quick risk note.</p>
</li>
<li><p><strong>Trend slope</strong> is intentionally simple. We fit a straight line to log prices. If the slope is positive, it’s generally drifting up. If it’s negative, it’s drifting down.</p>
</li>
<li><p><strong>Regime label</strong> is not a fancy model. It just says “compared to its own recent rolling volatility, are we currently in a high, medium, or low vol phase”.</p>
</li>
</ul>
<p>The main point is this: these numbers are deterministic. If the assistant says “max drawdown was -13%”, you can trace it back to the exact adjusted close series that produced it.</p>
<p>Next, we’ll handle the watchlist side. That means aligning returns across tickers, computing correlation, and generating a ranked snapshot.</p>
<h3 id="heading-5-watchlist-utilities">5. Watchlist Utilities</h3>
<p>Once you have more than one ticker, you want two extra things:</p>
<ul>
<li><p>a quick ranking so you can say “this is the riskiest name in the basket”</p>
</li>
<li><p>a correlation snapshot so you can see what’s moving together</p>
</li>
</ul>
<p>The only “gotcha” with correlation is dates. If TSLA has 41 price points and NVDA has 39 because of missing days, you can’t just correlate blindly. You need the returns lined up on the same dates first. That’s what <code>align_returns()</code> does.</p>
<p>Here’s the code:</p>
<pre><code class="language-python">def align_returns(price_frames):
    if not price_frames:
        return pd.DataFrame()

    parts = []
    for df in price_frames:
        if df is None or df.empty:
            continue
        x = df.copy()
        x["date"] = pd.to_datetime(x["date"], errors="coerce")
        x = x.dropna(subset=["date"])
        x["price"] = pd.to_numeric(x["price"], errors="coerce")
        x = x.dropna(subset=["price"])
        x = x.sort_values("date")
        x["ret"] = x["price"].pct_change()
        x = x.dropna(subset=["ret"])
        parts.append(x[["date", "ticker", "ret"]])

    if not parts:
        return pd.DataFrame()

    allr = pd.concat(parts, ignore_index=True)
    wide = allr.pivot(index="date", columns="ticker", values="ret").dropna(how="any")
    return wide


def corr_summary(ret_wide, base_ticker, top_n=3):
    if ret_wide is None or ret_wide.empty:
        return []

    if base_ticker not in ret_wide.columns:
        return []

    c = ret_wide.corr()[base_ticker].dropna()
    c = c.drop(labels=[base_ticker], errors="ignore")
    if c.empty:
        return []

    out = []
    for k, v in c.sort_values(ascending=False).head(top_n).items():
        out.append({"ticker": k, "corr": float(v)})

    return out


def rank_watchlist(metrics_by_ticker):
    rows = []
    for t, m in metrics_by_ticker.items():
        if not m:
            continue
        rows.append({
            "ticker": t,
            "vol_annualized": m.get("vol_annualized"),
            "max_drawdown": m.get("max_drawdown"),
            "ret_total": m.get("ret_total"),
            "trend_slope": m.get("trend_slope"),
        })

    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    df = df.sort_values(["vol_annualized", "max_drawdown"], ascending=[False, True])
    return df.reset_index(drop=True)
</code></pre>
<p>What’s happening here:</p>
<ul>
<li><p><code>align_returns()</code> takes a list of price DataFrames, computes daily returns for each, then pivots them into a wide table like: <code>date -&gt; TSLA.US, NVDA.US, AMZN.US</code>.</p>
</li>
<li><p>We drop rows where any ticker is missing, because correlation only makes sense when the returns are aligned on the same dates.</p>
</li>
<li><p><code>corr_summary()</code> is a compact “who moves with whom” helper. We pick one base ticker, compute correlations against everything else, then grab the top few. For a watchlist widget, that’s usually enough.</p>
</li>
<li><p><code>rank_watchlist()</code> is the ranking logic for the snapshot. We sort primarily by annualized volatility, and use drawdown as a secondary risk indicator. You could choose different ranking logic. The point is to keep it deterministic and explainable.</p>
</li>
</ul>
<p>Next, we’ll build the facts objects and narration layer. That’s where we enforce the “model is just a narrator” contract.</p>
<h3 id="heading-6-facts-object-and-narration">6. Facts Object and Narration</h3>
<p>This is where the “narrator pattern” becomes real.</p>
<p>Up to this point, we’ve done everything with MCP and Python. We fetched prices and fundamentals from EODHD, we computed metrics, and we aligned returns. Now we need one clean object that represents “the truth” for this request.</p>
<p>That’s what the <code>facts</code> object is.</p>
<p>The rule is simple.</p>
<ul>
<li><p><code>facts</code> contains only things we actually fetched or computed.</p>
</li>
<li><p>The model never sees raw market data. It sees the cleaned facts.</p>
</li>
<li><p>The model is told to write using only those facts, and not to invent any numbers.</p>
</li>
</ul>
<p>Here are the functions that build those facts objects for the two demos, plus the narration function.</p>
<pre><code class="language-python">def build_facts_single(ticker, lookback_days, metrics, regime, fundamentals):
    # keep this compact. LLM will narrate from this later
    out = {
        "type": "single_ticker_brief",
        "ticker": ticker,
        "lookback_days": int(lookback_days),
        "metrics": metrics,
        "regime": regime,
    }

    if isinstance(fundamentals, dict):
        gen = fundamentals.get("General", {}) or {}
        hi = fundamentals.get("Highlights", {}) or {}
        val = fundamentals.get("Valuation", {}) or {}
        tech = fundamentals.get("Technicals", {}) or {}

        base = {
            "name": gen.get("Name"),
            "exchange": gen.get("Exchange"),
            "sector": gen.get("Sector"),
            "industry": gen.get("Industry"),
        }

        metrics = {
            "market_cap": hi.get("MarketCapitalization"),
            "pe": hi.get("PERatio") or val.get("TrailingPE") or val.get("PERatio"),
            "beta": tech.get("Beta"),
            "div_yield": hi.get("DividendYield"),
        }

        out["fundamentals"] = {k: v for k, v in {**base, **metrics}.items() if v is not None}

    return out


def build_facts_watchlist(tickers, lookback_days, rank_df, corr_bits, metrics_by_ticker):
    out = {
        "type": "watchlist_snapshot",
        "tickers": tickers,
        "lookback_days": int(lookback_days),
        "ranking": rank_df.to_dict(orient="records") if isinstance(rank_df, pd.DataFrame) else [],
        "correlation": corr_bits,
        "metrics_by_ticker": metrics_by_ticker,
    }
    return out


def narrate(facts):
    prompt = (
        "Write a short, product-ready market note using ONLY the facts below.\n"
        "No guessing. No extra numbers. If something is missing, say it's missing.\n"
        "Keep it tight and readable.\n\n"
        f"FACTS:\n{json.dumps(facts, indent=2, default=str)}"
    )

    r = oa.responses.create(
        model=NARRATION_MODEL,
        input=[{"role": "user", "content": prompt}],
    )

    try:
        return r.output_text
    except Exception:
        return str(r)
</code></pre>
<p>What’s happening here:</p>
<ul>
<li><p><code>build_facts_single()</code> takes the ticker, window, computed metrics, the vol regime label, and the fundamentals payload. But it doesn’t dump the entire fundamentals JSON. It picks a handful of fields from the <code>General</code> section and only keeps what exists. That keeps the prompt tight and the output predictable.</p>
</li>
<li><p><code>build_facts_watchlist()</code> is the same idea but for multiple tickers. It passes the ranking table, correlation notes, and per-ticker metrics.</p>
</li>
<li><p><code>narrate()</code> is basically “convert this facts object into human-friendly text”. The prompt is strict on purpose. If the model can only see these facts, it cannot hallucinate numbers outside them.</p>
</li>
</ul>
<p>One small implementation detail: <code>narrate()</code> is a normal blocking function, while everything else is async. That’s why later, inside <code>run_assistant()</code>, we call it with <code>await asyncio.to_thread(...)</code> so it doesn’t block the async flow.</p>
<h3 id="heading-7-the-orchestration-function-runassistant">7. The Orchestration Function (<code>run_assistant()</code>)</h3>
<p>This is the piece that ties everything together. It does four things in order:</p>
<ol>
<li><p>create a trace id and log the request</p>
</li>
<li><p>parse tickers and lookback, then clamp them to budgets</p>
</li>
<li><p>fetch EODHD data via MCP and compute metrics in Python</p>
</li>
<li><p>call the model to narrate the facts, then return a structured response</p>
</li>
</ol>
<p>Here’s the function:</p>
<pre><code class="language-python">def _dates_from_lookback(lookback_days):
    end = date.today()
    start = end - timedelta(days=int(lookback_days))
    return start.isoformat(), end.isoformat()

async def run_assistant(user_text, mode="auto"):
    trace_id = uuid.uuid4().hex[:10]
    log_event("request_started", trace_id, text=user_text, mode=mode)

    tickers, lookback = parse_request(user_text)
    tickers, lookback = enforce_budgets(tickers, lookback)

    if not tickers:
        return {
            "answer": "no tickers found in request",
            "metrics": {},
            "data_used": {},
            "tool_trace_id": trace_id,
        }

    log_event("parsed", trace_id, tickers=tickers, lookback_days=lookback)
    
    start_date, end_date = _dates_from_lookback(lookback)
    state = new_state()
        
    if mode == "auto":
        mode = "watchlist" if len(tickers) &gt; 1 else "single"

    try:
        if mode == "single":
            t = tickers[0]
            t_full = t if "." in t else f"{t}.US"

            log_event("tool_phase", trace_id, mode="single", ticker=t_full, start_date=start_date, end_date=end_date)

            prices = await fetch_prices(t_full, start_date, end_date, trace_id, state)
            metrics = compute_metrics(prices)
            regime = compute_regime(prices)

            fundamentals = await fetch_fundamentals(t_full, trace_id, state)

            facts = build_facts_single(t_full, lookback, metrics, regime, fundamentals)
            answer = await asyncio.to_thread(narrate, facts)

            resp = {
                "answer": answer,
                "metrics": metrics,
                "data_used": {
                    "tickers": [t_full],
                    "date_range": [start_date, end_date],
                    "tools_called": [x.get("tool") for x in state["tool_trace"]],
                    "tool_calls": state["tool_calls"],
                },
                "tool_trace_id": trace_id,
            }

            log_event("request_finished", trace_id, tool_calls=state["tool_calls"])
            return resp

        # watchlist
        full = [x if "." in x else f"{x}.US" for x in tickers]

        log_event("tool_phase", trace_id, mode="watchlist", tickers=full, start_date=start_date, end_date=end_date)

        frames = []
        metrics_by = {}

        for t in full:
            prices = await fetch_prices(t, start_date, end_date, trace_id, state)
            frames.append(prices)
            metrics_by[t] = compute_metrics(prices)

        ret_wide = align_returns(frames)

        base = full[0]
        corr_bits = []
        top = corr_summary(ret_wide, base, top_n=3)
        if top:
            corr_bits.append({"base": base, "top": top})

        rank_df = rank_watchlist(metrics_by)
        facts = build_facts_watchlist(full, lookback, rank_df, corr_bits, metrics_by)
        answer = await asyncio.to_thread(narrate, facts)

        resp = {
            "answer": answer,
            "metrics": {"by_ticker": metrics_by},
            "data_used": {
                "tickers": full,
                "date_range": [start_date, end_date],
                "tools_called": [x.get("tool") for x in state["tool_trace"]],
                "tool_calls": state["tool_calls"],
            },
            "tool_trace_id": trace_id,
        }

        log_event("request_finished", trace_id, tool_calls=state["tool_calls"])
        return resp

    except Exception as e:
        detail = repr(e)
        if hasattr(e, "exceptions"):
            detail = detail + " | " + " ; ".join([repr(x) for x in e.exceptions])

        log_event("request_failed", trace_id, err=detail)
        
        return {
            "answer": f"failed: {e}",
            "metrics": {},
            "data_used": {
                "tickers": tickers,
                "date_range": [start_date, end_date],
                "tools_called": [x.get("tool") for x in state["tool_trace"]],
                "tool_calls": state["tool_calls"],
            },
            "tool_trace_id": trace_id,
        }
</code></pre>
<p>This function is the glue. It creates a <code>trace_id</code>, logs the request, extracts tickers and a lookback window, then clamps both to your budgets so the assistant can’t over-fetch or spam tool calls.</p>
<p>After that, it turns the lookback into a <code>start_date</code> and <code>end_date</code>, initializes a fresh <code>state</code>, and picks a mode. In <code>single</code> mode, it fetches prices and fundamentals for one ticker via EODHD’s MCP tools, computes the metrics in Python, packs everything into a facts object, and asks the LLM to only narrate those facts. In <code>watchlist</code> mode it does the same across multiple tickers, then aligns returns so correlation is computed on matching dates, and builds a ranked snapshot.</p>
<p>The response is always structured the same way. You get the narrative <code>answer</code>, the raw computed <code>metrics</code>, a <code>data_used</code> block that shows tickers, date range, and tools called, plus a <code>tool_trace_id</code> so you can trace any output back to logs.</p>
<p>That structure is the difference between “a chat response” and “a shippable assistant output”. You can plug the same response into a UI card, a Slack alert, or a dashboard without changing anything.</p>
<h2 id="heading-demo-1-market-brief-for-one-ticker">Demo 1: Market Brief for One&nbsp;Ticker</h2>
<p>Let’s start with the simplest flow. One ticker, one lookback window, and a market brief that looks like something you could show inside a product.</p>
<p><strong>Prompt used:</strong></p>
<blockquote>
<p><em>“Give me a 30-day brief for AAPL. trend, volatility, max drawdown, plus 3 fundamental highlights.”</em></p>
</blockquote>
<p><strong>Code (Jupyter Notebook):</strong></p>
<pre><code class="language-python">import asyncio
import json
from core import run_assistant

q1 = "Give me a 30-day brief for AAPL. trend, volatility, max drawdown, plus 3 fundamental highlights."

r1 = await run_assistant(q1, mode="single")
print(json.dumps(r1, indent=2, ensure_ascii=False))
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">{"event": "request_started", "trace_id": "2af550173f", "ts": 1772735388.777, "text": "Give me a 30-day brief for AAPL. trend, volatility, max drawdown, plus 3 fundamental highlights.", "mode": "single"}
{"event": "parsed", "trace_id": "2af550173f", "ts": 1772735388.778, "tickers": ["AAPL"], "lookback_days": 30}
{"event": "tool_phase", "trace_id": "2af550173f", "ts": 1772735388.778, "mode": "single", "ticker": "AAPL.US", "start_date": "2026-02-03", "end_date": "2026-03-05"}
{"event": "request_finished", "trace_id": "2af550173f", "ts": 1772735404.392, "tool_calls": 2}
{
  "answer": "Apple Inc (AAPL.US) | NASDAQ | Technology — Consumer Electronics\n
\nOver the past 30 days, Apple shares declined 2.58%, falling from 269.48 to 
262.52 across 21 trading observations. The trend slope over the period was 
negative (-0.00175), indicating a modest downward drift.\n\nRealized daily 
volatility was 1.93%, equivalent to about 30.65% annualized. The stock is currently 
classified in a high‑volatility regime based on a 20‑day rolling volatility measure.
\n\nMaximum drawdown during the period reached -8.03%.\n\nAdditional fundamentals 
or valuation metrics were not provided.",
  "metrics": {
    "vol_daily": 0.01930981768788001,
    "vol_annualized": 0.3065338527847606,
    "ret_total": -0.02582751966750796,
    "max_drawdown": -0.08032503955127279,
    "trend_slope": -0.0017498633497641184,
    "n_points": 21,
    "start_close": 269.48,
    "end_close": 262.52
  },
  "data_used": {
    "tickers": [
      "AAPL.US"
    ],
    "date_range": [
      "2026-02-03",
      "2026-03-05"
    ],
    "tools_called": [
      "get_historical_stock_prices",
      "get_fundamentals_data"
    ],
    "tool_calls": 2
  },
  "tool_trace_id": "2af550173f"
}
</code></pre>
<p>First, you’ll see the log events. They’re not part of the final response. They’re just the trace trail.</p>
<ul>
<li><p><code>request_started</code> shows the raw prompt and that we forced <code>mode="single"</code>.</p>
</li>
<li><p><code>parsed</code> confirms the parser extracted <code>AAPL</code> and a 30-day lookback.</p>
</li>
<li><p><code>tool_phase</code> shows what we actually fetched: <code>AAPL.US</code> from <code>2026-02-03</code> to <code>2026-03-05</code>.</p>
</li>
<li><p><code>request_finished</code> confirms we made exactly <strong>2 tool calls.</strong></p>
</li>
</ul>
<p>Now the actual response JSON:</p>
<p><code>answer</code> is the narrative. In this run it summarizes:</p>
<ul>
<li><p>return of -2.58% (269.48 to 262.52)</p>
</li>
<li><p>21 price observations in that window</p>
</li>
<li><p>negative trend slope (-0.00175) meaning mild downward drift</p>
</li>
<li><p>daily vol 1.93% and annualized vol 30.65%</p>
</li>
<li><p>max drawdown -8.03%</p>
</li>
<li><p>and it labels the regime as high volatility using the rolling vol logic.</p>
</li>
</ul>
<p><code>metrics</code> is where those numbers come from. This is the deterministic part. <code>ret_total</code>, <code>vol_daily</code>, <code>vol_annualized</code>, <code>max_drawdown</code>, and <code>trend_slope</code> were computed directly from the fetched closes. <code>start_close</code>, <code>end_close</code>, and <code>n_points</code> explain the exact series used.</p>
<p><code>data_used</code> is the audit block for this specific output. It shows:</p>
<ul>
<li><p>ticker normalized to <code>AAPL.US</code></p>
</li>
<li><p>the exact date range pulled</p>
</li>
<li><p>the exact tools called on the MCP server: <code>get_historical_stock_prices</code> and <code>get_fundamentals_data</code></p>
</li>
<li><p>and again, <code>tool_calls: 2</code> so you can quickly spot runaway calls.</p>
</li>
</ul>
<p><code>tool_trace_id</code> (<code>2af550173f</code>) is your handle for debugging. Every log line above carries the same id, so you can trace this brief back to the exact tool calls and parameters.</p>
<h2 id="heading-demo-2-watchlist-snapshot">Demo 2: Watchlist Snapshot</h2>
<p>Now let’s switch to the watchlist flow. Same assistant core. The only difference is we pass multiple tickers and a longer window, so the output becomes a comparative risk snapshot.</p>
<p><strong>Prompt used:</strong></p>
<blockquote>
<p><em>“Compare TSLA, NVDA, AMZN over the last 60 days. rank by volatility and drawdown, and flag valuation risks.”</em></p>
</blockquote>
<p><strong>Code:</strong></p>
<pre><code class="language-python">q2 = "Compare TSLA, NVDA, AMZN over the last 60 days. rank by volatility and drawdown, and flag risk outliers."

r2 = await run_assistant(q2, mode="watchlist")
print(json.dumps(r2, indent=2, ensure_ascii=False))
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">{"event": "request_started", "trace_id": "1b67bb47d6", "ts": 1772735404.394, "text": "Compare TSLA, NVDA, AMZN over the last 60 days. rank by volatility and drawdown, and flag valuation risks.", "mode": "watchlist"}
{"event": "parsed", "trace_id": "1b67bb47d6", "ts": 1772735404.394, "tickers": ["TSLA", "NVDA", "AMZN"], "lookback_days": 60}
{"event": "tool_phase", "trace_id": "1b67bb47d6", "ts": 1772735404.394, "mode": "watchlist", "tickers": ["TSLA.US", "NVDA.US", "AMZN.US"], "start_date": "2026-01-05", "end_date": "2026-03-06"}
{"event": "request_finished", "trace_id": "1b67bb47d6", "ts": 1772735423.004, "tool_calls": 3}
{
  "answer": "Market Watchlist Snapshot (last 60 days)\n\nAll three names show 
negative total returns and downward trend slopes over the period.\n\nNVDA.US 
ranks highest in the group despite a small decline. Total return is -0.027. 
Price moved from 188.12 to 183.04 across 41 observations. Annualized volatility is 
0.3808 and maximum drawdown is -0.107.\n\nTSLA.US shows the second‑highest volatility 
profile with annualized volatility of 0.3561. Total return is -0.101, with price 
falling from 451.67 to 405.94. Maximum drawdown reached -0.131. Trend slope is negative.
\n\nAMZN.US has the lowest volatility in the set (annualized 0.3196) but the deepest 
drawdown at -0.196. Total return is -0.0697, with price moving from 233.06 to 
216.82. Trend slope is also negative.\n\nCorrelation: TSLA shows a stronger 
relationship with NVDA (0.533) than with AMZN (0.177).\n\nMissing from the 
data: trading volume, catalysts, sector context, and forward-looking indicators.",
  "metrics": {
    "by_ticker": {
      "TSLA.US": {
        "vol_daily": 0.02243518393199404,
        "vol_annualized": 0.3561475038122908,
        "ret_total": -0.10124648526579139,
        "max_drawdown": -0.13115770363318358,
        "trend_slope": -0.0026452119688441023,
        "n_points": 41,
        "start_close": 451.67,
        "end_close": 405.94
      },
      "NVDA.US": {
        "vol_daily": 0.023987861378298222,
        "vol_annualized": 0.3807954941476091,
        "ret_total": -0.027004039974484417,
        "max_drawdown": -0.10716326424601319,
        "trend_slope": -4.3573704505466623e-05,
        "n_points": 41,
        "start_close": 188.12,
        "end_close": 183.04
      },
      "AMZN.US": {
        "vol_daily": 0.020129905817481322,
        "vol_annualized": 0.31955234824924766,
        "ret_total": -0.06968162704882863,
        "max_drawdown": -0.1964184655186353,
        "trend_slope": -0.00520436173926906,
        "n_points": 41,
        "start_close": 233.06,
        "end_close": 216.82
      }
    }
  },
  "data_used": {
    "tickers": [
      "TSLA.US",
      "NVDA.US",
      "AMZN.US"
    ],
    "date_range": [
      "2026-01-05",
      "2026-03-06"
    ],
    "tools_called": [
      "get_historical_stock_prices",
      "get_historical_stock_prices",
      "get_historical_stock_prices"
    ],
    "tool_calls": 3
  },
  "tool_trace_id": "1b67bb47d6"
}
</code></pre>
<p>The logs show the assistant correctly extracted <code>TSLA</code>, <code>NVDA</code>, <code>AMZN</code> and a <strong>60-day</strong> lookback, then fetched <code>TSLA.US</code>, <code>NVDA.US</code>, and <code>AMZN.US</code> from <code>2026-01-05</code> to <code>2026-03-06</code>. Since this is a watchlist request, it made exactly <strong>3</strong> tool calls. One <code>get_historical_stock_prices</code> call per ticker.</p>
<p>Inside <code>answer</code>, the model is basically summarizing what Python computed. In this run, all three names had negative returns and negative trend slopes.</p>
<ul>
<li><p>NVDA had the highest annualized volatility at 0.3808 with a relatively small decline of -2.7%.</p>
</li>
<li><p>TSLA was next in volatility (0.3561) with a larger decline (-10.1%) and drawdown of about -13.1%.</p>
</li>
<li><p>AMZN had the lowest volatility (0.3196) but the deepest drawdown at around -19.6%. It also includes a correlation note derived from the aligned returns table.</p>
</li>
<li><p>TSLA’s return series correlated more with NVDA (0.533) than with AMZN (0.177) in this window.</p>
</li>
</ul>
<p><code>metrics.by_ticker</code> is where the snapshot really lives. It contains the full computed metric set per ticker, including observation count (<code>n_points=41</code>) and the start and end closes used for the return calculation. <code>data_used</code> shows exactly what we fetched, including the tickers, the date range, and the three price tool calls. And <code>tool_trace_id</code> is the id that links this output back to the full trace logs.</p>
<p>So how would a product team use this? Well, this output is already shaped like a widget backend. You can render the ranking as a watchlist “risk card”, show the top volatility and drawdown names, and drop the narrative into a compact summary box. Since you also get deterministic <code>metrics</code>, you can build UI elements without parsing text, and still keep the narration as a layer on top.</p>
<h2 id="heading-what-makes-this-shippable-and-what-can-be-improved">What Makes this Shippable, and What Can Be&nbsp;Improved?</h2>
<p>The core reason this works in a real product setting is that the numbers are deterministic. Prices and fundamentals come from EODHD via MCP, metrics are computed in Python, and the model only writes narrative from a facts object.</p>
<p>On top of that, every run is traceable. You get tool logs, <code>data_used</code>, and a <code>tool_trace_id</code>, plus hard limits on lookback, tickers, and tool calls so the system can’t spiral.</p>
<p>At the same time, this is still an MVP. The parsing is a simple heuristic, the metric set is intentionally small, and fundamentals are only lightly extracted.</p>
<p>If you want to take this further, the next upgrades are straightforward: you can add volume and a couple more data tools like earnings calendar and news, introduce caching for repeated requests, build a tiny evaluation harness with fixed prompts and expected outputs, then wrap <code>run_assistant()</code> behind a small API so it can power an actual UI or internal service.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The main takeaway is simple. If you want a financial assistant to be usable beyond casual chat, you need to separate facts from narrative. The MCP protocol gives you a clean way to connect to tool providers via an MCP server. Python gives you deterministic metrics, and the model becomes the last-mile layer that turns those facts into readable output.</p>
<p>This is still a small build, but it’s already shaped like something you can ship. The response format is structured, traceable, and easy to plug into a UI. If you extend it with a few more tools and add basic caching, it can quickly move from a Jupyter notebook demo to a real feature.</p>
<p>If you want to try the same approach with a full market data tool layer out of the box, EODHD’s MCP server is a solid starting point.</p>
<p>With that being said, you’ve reached the end of the article. Hope you learned something new and useful today. Thank you very much for your time.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an MCP Server with Python, Docker, and Claude Code ]]>
                </title>
                <description>
                    <![CDATA[ Every MCP tutorial I've found so far has followed the same basic script: build a server, point Claude Desktop at it, screenshot the chat window, done. This is fine if you want a demo. But it's not fin ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-mcp-server-with-python-docker-and-claude-code/</link>
                <guid isPermaLink="false">69b09018abc0d95001a8f07f</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ML ]]>
                    </category>
                
                    <category>
                        <![CDATA[ claude.ai ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp server ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Balajee Asish Brahmandam ]]>
                </dc:creator>
                <pubDate>Tue, 10 Mar 2026 21:41:44 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/02826050-87fa-42cb-8167-73bca4b42616.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every MCP tutorial I've found so far has followed the same basic script: build a server, point Claude Desktop at it, screenshot the chat window, done.</p>
<p>This is fine if you want a demo. But it's not fine if you want something you can ship, defend in an interview, or hand to another developer without a README that starts with "first, install this Electron app."</p>
<p>So I built an MCP server in Python, containerized it with Docker, and wired it into Claude Code – all from the terminal, no GUI required.</p>
<p>This article walks through the full loop in one afternoon: what MCP actually is, why it matters now that OpenAI and Google have adopted it, the real security problems nobody puts in their tutorial (complete with CVEs), and every command you need to go from an empty directory to a working tool.</p>
<p>If you're between jobs and need a portfolio project that shows you understand how AI tooling actually works under the hood, this is the one.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#what-is-mcp-and-why-should-you-care">What is MCP (and Why Should You Care)?</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#why-claude-code-instead-of-claude-desktop">Why Claude Code Instead of Claude Desktop?</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#step-1-build-the-mcp-server">Step 1: Build the MCP Server</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#step-2-test-it-locally">Step 2: Test It Locally</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#step-3-dockerize-it">Step 3: Dockerize It</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#step-4-wire-it-into-claude-code">Step 4: Wire It Into Claude Code</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#step-5-use-it">Step 5: Use It</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#security-what-the-other-tutorials-leave-out">Security: What the Other Tutorials Leave Out</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#what-to-do-next">What to Do Next</a></p>
</li>
<li><p><a href="https://claude.ai/chat/1a92e709-4c86-4c9a-8fa3-b1533b9d21a5#wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-what-you-will-build">What You Will Build</h2>
<p>By the end of this tutorial, you will have:</p>
<ul>
<li><p>A Python MCP server that exposes custom tools to any MCP-compatible AI client</p>
</li>
<li><p>A Docker container that packages the server for reproducible deployment</p>
</li>
<li><p>A working connection between that container and Claude Code in your terminal</p>
</li>
<li><p>An understanding of the security risks involved and how to mitigate the worst of them</p>
</li>
</ul>
<p>The server we are building is a <strong>project scaffolder</strong>. You give it a project name and a language, and it generates a starter directory structure with the right files. It's simple enough to build in an afternoon, but useful enough to actually put on your résumé.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You will need the following installed on your machine:</p>
<ul>
<li><p><strong>Python 3.10+</strong> (check with <code>python3 --version</code>)</p>
</li>
<li><p><strong>Docker</strong> (check with <code>docker --version</code>)</p>
</li>
<li><p><strong>Claude Code</strong> with an active Claude Pro, Max, or API plan (check with <code>claude --version</code>)</p>
</li>
<li><p><strong>Node.js 20+</strong> (required by Claude Code – check with <code>node --version</code>)</p>
</li>
<li><p>A terminal you are comfortable in</p>
</li>
</ul>
<p>If you don't have Claude Code installed yet, follow the <a href="https://code.claude.com/docs/en/getting-started">official installation instructions</a>. The npm installation method is deprecated, so make sure you use the native binary installer instead.</p>
<h2 id="heading-what-is-mcp-and-why-should-you-care">What is MCP (and Why Should You Care)?</h2>
<p>The Model Context Protocol (MCP) is an open standard that lets AI models connect to external tools and data sources. Anthropic released it in November 2024, and within a year it became the default way to extend what an LLM can do. OpenAI adopted it in March 2025. Google DeepMind followed in April. The protocol now has over 97 million monthly SDK downloads and more than 10,000 active servers.</p>
<p>The easiest way to think about MCP is as a USB-C port for AI. Before MCP, every AI provider had its own way of calling tools. OpenAI had function calling. Google had their own format. If you wanted your tool to work with multiple models, you had to implement it multiple times. MCP gives you one interface that works everywhere.</p>
<p>Here is how the pieces fit together:</p>
<ul>
<li><p>An <strong>MCP server</strong> exposes tools, resources, and prompts. It is your code.</p>
</li>
<li><p>An <strong>MCP client</strong> (like Claude Code, Claude Desktop, or Cursor) discovers those tools and calls them on behalf of the LLM.</p>
</li>
<li><p>The <strong>transport</strong> is how they communicate. For local servers, that's usually stdio (standard input/output). For remote servers, it's HTTP.</p>
</li>
</ul>
<p>When you type a message in Claude Code and it decides to use one of your tools, here is what happens: Claude Code sends a JSON-RPC 2.0 message to your server over stdin, your server executes the tool and writes the result to stdout, and Claude Code reads it back. The LLM never talks to your server directly. The client is always in the middle.</p>
<p>If you want the deeper architecture breakdown, freeCodeCamp already has a <a href="https://www.freecodecamp.org/news/how-does-an-mcp-work-under-the-hood/">solid explainer on how MCP works under the hood</a>. Here, I will focus on building.</p>
<h2 id="heading-why-claude-code-instead-of-claude-desktop">Why Claude Code Instead of Claude Desktop?</h2>
<p>Most MCP tutorials use Claude Desktop as the client. That works, but Claude Code has a few advantages for developers:</p>
<ol>
<li><p><strong>It lives in your terminal.</strong> No GUI to configure. No JSON files to hand-edit in hidden config directories. You add an MCP server with one command and you are done.</p>
</li>
<li><p><strong>It's already where you code.</strong> If you're writing the server, testing it, and connecting it, doing all of that in the same terminal session cuts the context switching.</p>
</li>
<li><p><strong>It works on headless machines.</strong> If you're SSHing into a dev box or running in CI, Claude Desktop isn't an option. Claude Code is.</p>
</li>
<li><p><strong>It's also an MCP server itself.</strong> Claude Code can expose its own tools (file reading, writing, shell commands) to other MCP clients via <code>claude mcp serve</code>. That's a neat trick we won't use today, but it's worth knowing about.</p>
</li>
</ol>
<p>The relevant commands:</p>
<pre><code class="language-bash"># Add an MCP server
claude mcp add &lt;name&gt; -- &lt;command&gt;

# List configured servers
claude mcp list

# Remove a server
claude mcp remove &lt;name&gt;

# Check MCP status inside Claude Code
/mcp
</code></pre>
<h2 id="heading-step-1-build-the-mcp-server">Step 1: Build the MCP Server</h2>
<p>We're using <a href="https://github.com/jlowin/fastmcp">FastMCP</a>, a Python framework that handles all the protocol plumbing so you can focus on your tools. Create a new project directory and set it up:</p>
<pre><code class="language-bash">mkdir mcp-scaffolder &amp;&amp; cd mcp-scaffolder
python3 -m venv .venv
source .venv/bin/activate
pip install "mcp[cli]&gt;=1.25,&lt;2"
</code></pre>
<p>Why pin the version? The MCP Python SDK v2.0 is in development and will change the transport layer significantly. Pinning to &gt;=1.25,&lt;2 keeps your server working until you're ready to migrate.</p>
<p>Now create <code>server.py</code>:</p>
<pre><code class="language-python"># server.py
from mcp.server.fastmcp import FastMCP
import os
import json

mcp = FastMCP("project-scaffolder")

# Templates for different languages
TEMPLATES = {
    "python": {
        "files": {
            "main.py": '"""Entry point."""\n\n\ndef main():\n    print("Hello, world!")\n\n\nif __name__ == "__main__":\n    main()\n',
            "requirements.txt": "",
            "README.md": "# {name}\n\nA Python project.\n\n## Setup\n\n```bash\npip install -r requirements.txt\npython main.py\n```\n",
            ".gitignore": "__pycache__/\n*.pyc\n.venv/\n",
        },
        "dirs": ["tests"],
    },
    "node": {
        "files": {
            "index.js": 'console.log("Hello, world!");\n',
            "package.json": '{{\n  "name": "{name}",\n  "version": "1.0.0",\n  "main": "index.js"\n}}\n',
            "README.md": "# {name}\n\nA Node.js project.\n\n## Setup\n\n```bash\nnpm install\nnode index.js\n```\n",
            ".gitignore": "node_modules/\n",
        },
        "dirs": [],
    },
    "go": {
        "files": {
            "main.go": 'package main\n\nimport "fmt"\n\nfunc main() {{\n\tfmt.Println("Hello, world!")\n}}\n',
            "go.mod": "module {name}\n\ngo 1.21\n",
            "README.md": "# {name}\n\nA Go project.\n\n## Setup\n\n```bash\ngo run main.go\n```\n",
            ".gitignore": "bin/\n",
        },
        "dirs": ["cmd", "internal"],
    },
}


@mcp.tool()
def scaffold_project(name: str, language: str) -&gt; str:
    """Create a new project directory structure.

    Args:
        name: The project name (used as the directory name)
        language: The programming language - one of: python, node, go
    """
    language = language.lower().strip()

    if language not in TEMPLATES:
        return json.dumps({
            "error": f"Unsupported language: {language}",
            "supported": list(TEMPLATES.keys()),
        })

    template = TEMPLATES[language]
    base_path = os.path.join(os.getcwd(), name)

    if os.path.exists(base_path):
        return json.dumps({
            "error": f"Directory already exists: {name}",
        })

    # Create the project directory
    os.makedirs(base_path, exist_ok=True)

    # Create subdirectories
    for dir_name in template["dirs"]:
        os.makedirs(os.path.join(base_path, dir_name), exist_ok=True)

    # Create files
    created_files = []
    for filename, content in template["files"].items():
        filepath = os.path.join(base_path, filename)
        formatted_content = content.replace("{name}", name)
        with open(filepath, "w") as f:
            f.write(formatted_content)
        created_files.append(filename)

    return json.dumps({
        "status": "created",
        "path": base_path,
        "language": language,
        "files": created_files,
        "directories": template["dirs"],
    })


@mcp.tool()
def list_templates() -&gt; str:
    """List all available project templates and their contents."""
    result = {}
    for lang, template in TEMPLATES.items():
        result[lang] = {
            "files": list(template["files"].keys()),
            "directories": template["dirs"],
        }
    return json.dumps(result, indent=2)


if __name__ == "__main__":
    mcp.run(transport="stdio")
</code></pre>
<p>A few things to notice about this code:</p>
<p>Tools return strings. MCP tools communicate through text. I'm returning JSON strings so the LLM can parse the results reliably. You could return plain text, but structured data gives the model more to work with.</p>
<p>The <code>@mcp.tool()</code> decorator does the heavy lifting. FastMCP reads your function signature and docstring to generate the JSON schema that tells the LLM what this tool does, what arguments it takes, and what types they are. Good docstrings aren't optional here – they're how the LLM decides whether to call your tool.</p>
<p><code>transport="stdio"</code> is the key line. This tells FastMCP to communicate over standard input/output, which is what Claude Code expects for local servers.</p>
<h2 id="heading-step-2-test-it-locally">Step 2: Test It Locally</h2>
<p>Before we Dockerize anything, make sure the server actually works:</p>
<pre><code class="language-bash"># Quick smoke test - the server should start without errors
python server.py
</code></pre>
<p>You should see... nothing. That is correct. An MCP server over stdio just sits there waiting for JSON-RPC messages on stdin. Press <code>Ctrl+C</code> to stop it.</p>
<p>For a proper test, use the MCP Inspector (Anthropic's debugging tool):</p>
<pre><code class="language-bash"># Install and run the inspector
npx @modelcontextprotocol/inspector python server.py
</code></pre>
<p>This opens a web interface where you can see your tools, call them manually, and inspect the JSON-RPC messages going back and forth. Verify that both <code>scaffold_project</code> and <code>list_templates</code> show up and return sensible results.</p>
<p><strong>Here's a debugging tip that will save you time:</strong> If your MCP server logs anything to stdout, it will corrupt the JSON-RPC stream and the client will disconnect. Use stderr for all logging: <code>print("debug info", file=sys.stderr)</code>. This is the single most common source of "my server connects but then immediately fails" bugs. The New Stack called stdio transport "incredibly fragile" for exactly this reason.</p>
<h2 id="heading-step-3-dockerize-it">Step 3: Dockerize It</h2>
<p>Create a <code>Dockerfile</code> in your project root:</p>
<pre><code class="language-dockerfile">FROM python:3.12-slim

WORKDIR /app

# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy server code
COPY server.py .

# MCP servers over stdio need unbuffered output
ENV PYTHONUNBUFFERED=1

# The server reads from stdin and writes to stdout
CMD ["python", "server.py"]
</code></pre>
<p>Create <code>requirements.txt</code>:</p>
<pre><code class="language-plaintext">mcp[cli]&gt;=1.25,&lt;2
</code></pre>
<p>Build and verify:</p>
<pre><code class="language-bash">docker build -t mcp-scaffolder .

# Quick test - should start without errors
docker run -i mcp-scaffolder
</code></pre>
<p>Again, you'll see nothing because the server is waiting for input. <code>Ctrl+C</code> to stop.</p>
<p>Two things matter in this Dockerfile:</p>
<ol>
<li><p><code>PYTHONUNBUFFERED=1</code> <strong>is critical.</strong> Without it, Python buffers stdout, and the MCP client may hang waiting for responses that are sitting in a buffer. This is one of those bugs that works fine in local testing and breaks in Docker.</p>
</li>
<li><p><code>docker run -i</code> <strong>(interactive mode) is required.</strong> The <code>-i</code> flag keeps stdin open so the MCP client can send messages to the container. Without it, the server gets an immediate EOF and exits.</p>
</li>
</ol>
<h2 id="heading-step-4-wire-it-into-claude-code">Step 4: Wire It Into Claude Code</h2>
<p>Now connect your Docker container to Claude Code:</p>
<pre><code class="language-bash">claude mcp add scaffolder -- docker run -i --rm mcp-scaffolder
</code></pre>
<p>That's the whole command. Let me break it down:</p>
<ul>
<li><p><code>claude mcp add</code> registers a new MCP server</p>
</li>
<li><p><code>scaffolder</code> is the name you will reference it by</p>
</li>
<li><p>Everything after <code>--</code> is the command Claude Code runs to start the server</p>
</li>
<li><p><code>docker run -i --rm mcp-scaffolder</code> starts the container with interactive stdin and removes it when done</p>
</li>
</ul>
<p>Verify that it registered:</p>
<pre><code class="language-bash">claude mcp list
</code></pre>
<p>You should see <code>scaffolder</code> in the output with a <code>stdio</code> transport type.</p>
<p>Now launch Claude Code and check the connection:</p>
<pre><code class="language-bash">claude
</code></pre>
<p>Once inside Claude Code, type <code>/mcp</code> to see the status of your MCP servers. You should see <code>scaffolder</code> listed as connected with two tools available.</p>
<h2 id="heading-step-5-use-it">Step 5: Use It</h2>
<p>Still inside Claude Code, try it out:</p>
<pre><code class="language-plaintext">Create a new Python project called "weather-api"
</code></pre>
<p>Claude Code should discover your <code>scaffold_project</code> tool, call it with <code>name="weather-api"</code> and <code>language="python"</code>, and report back what it created. Check your filesystem and you should see the full project structure.</p>
<p>Try a few more:</p>
<pre><code class="language-plaintext">What project templates are available?
</code></pre>
<pre><code class="language-plaintext">Scaffold a Go project called "url-shortener"
</code></pre>
<p>If Claude Code doesn't pick up your tools, run <code>/mcp</code> to check the connection status. If it shows as disconnected, the most common causes are that the Docker image failed to build, stdout is being polluted (check for stray print statements), or the Docker daemon is not running.</p>
<h2 id="heading-security-what-the-other-tutorials-leave-out">Security: What the Other Tutorials Leave Out</h2>
<p>This is the section most MCP tutorials skip. They should not. MCP has had real security incidents, not theoretical ones, and understanding them makes you a better developer.</p>
<h3 id="heading-the-prompt-injection-problem">The Prompt Injection Problem</h3>
<p>MCP servers execute code on your machine based on what an LLM decides to do. If an attacker can influence what the LLM sees, they can influence what your server does. This is called prompt injection, and it is the number one unsolved security problem in the MCP ecosystem.</p>
<p>In May 2025, researchers at Invariant Labs demonstrated this against the official GitHub MCP server. They created a malicious GitHub issue that, when read by an AI agent, hijacked the agent into leaking private repository data (including salary information) into a public pull request. The root cause was an overly broad Personal Access Token combined with untrusted content landing in the LLM's context window.</p>
<p>This was not a contrived lab demo. It used the official GitHub MCP server, the kind of thing people install from the MCP server directory without a second thought.</p>
<h3 id="heading-real-cves-not-theory">Real CVEs, Not Theory</h3>
<p>The ecosystem has accumulated real vulnerability reports:</p>
<ul>
<li><p><strong>CVE-2025-6514:</strong> A critical command-injection bug in <code>mcp-remote</code>, a popular OAuth proxy that 437,000+ environments used. An attacker could execute arbitrary OS commands through crafted OAuth redirect URIs.</p>
</li>
<li><p><strong>CVE-2025-6515:</strong> Session hijacking in <code>oatpp-mcp</code> through predictable session IDs, letting attackers inject prompts into other users' sessions.</p>
</li>
<li><p><strong>MCP Inspector RCE:</strong> Anthropic's own debugging tool allowed unauthenticated remote code execution. Inspecting a malicious server meant giving the attacker a shell on your machine.</p>
</li>
</ul>
<p>An Equixly security assessment found command injection in 43% of tested MCP server implementations. Nearly a third were vulnerable to server-side request forgery.</p>
<h3 id="heading-what-you-should-actually-do">What You Should Actually Do</h3>
<p>For the server we built today, here is what matters:</p>
<h4 id="heading-limit-file-system-access">Limit file system access</h4>
<p>Our Docker container doesn't mount your home directory. That's intentional. If you need the server to write files to your host, mount only the specific directory you need: <code>docker run -i --rm -v $(pwd)/projects:/app/projects mcp-scaffolder</code>. Never mount <code>/</code> or <code>~</code>.</p>
<h4 id="heading-validate-all-inputs">Validate all inputs</h4>
<p>Our <code>scaffold_project</code> tool checks that the language is in a known list and that the directory does not already exist. But think about what happens if someone passes <code>name="../../etc/passwd"</code> as the project name. Path traversal is the kind of thing you need to catch. Add this to the tool:</p>
<pre><code class="language-python"># Add this validation at the top of scaffold_project
if ".." in name or "/" in name or "\\" in name:
    return json.dumps({"error": "Invalid project name"})
</code></pre>
<h4 id="heading-use-least-privilege-tokens">Use least-privilege tokens</h4>
<p>If your MCP server connects to an API, give it the minimum permissions it needs. The GitHub MCP incident happened because the PAT had access to every private repo. A read-only token scoped to one repo would have contained the blast radius.</p>
<h4 id="heading-do-not-install-mcp-servers-from-untrusted-sources">Do not install MCP servers from untrusted sources</h4>
<p>A malicious npm package posing as a "Postmark MCP Server" was caught silently BCC'ing all emails to an attacker's address. Treat MCP server packages with the same caution you would give any code that runs on your machine with your permissions.</p>
<h2 id="heading-what-to-do-next">What to Do Next</h2>
<p>You have a working MCP server in a Docker container, connected to Claude Code. Here is how to make it portfolio-ready:</p>
<ol>
<li><p><strong>Add more tools:</strong> The scaffolder is a starting point. Add a tool that reads a project's dependency file and lists outdated packages. Add one that generates a Dockerfile for an existing project. Each tool is a function with a decorator – the pattern is the same every time.</p>
</li>
<li><p><strong>Add tests:</strong> Write pytest tests that call your tool functions directly and verify the output. MCP tools are just Python functions. Test them like Python functions.</p>
</li>
<li><p><strong>Push the Docker image:</strong> Tag it and push to Docker Hub or GitHub Container Registry. Then your <code>claude mcp add</code> command becomes <code>claude mcp add scaffolder -- docker run -i --rm yourusername/mcp-scaffolder:latest</code> and anyone can use it.</p>
</li>
<li><p><strong>Write a README that explains the security model:</strong> What permissions does your server need? What file system access? What happens if inputs are malicious? Answering these questions in your README signals that you think about security, which is exactly what hiring managers are looking for right now.</p>
</li>
</ol>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>We built a Python MCP server with FastMCP, containerized it with Docker, and connected it to Claude Code. The whole thing fits in about 100 lines of Python, a six-line Dockerfile, and one <code>claude mcp add</code> command.</p>
<p>The MCP ecosystem is real and growing fast. The protocol has the backing of Anthropic, OpenAI, and Google. It's now governed by the Linux Foundation. But it's also young, and the security story is still being written. Build with it, but build with your eyes open.</p>
<p>If you want to go deeper, here are the resources I found most useful:</p>
<ul>
<li><p><a href="https://modelcontextprotocol.io/specification/2025-11-25">MCP specification</a>: the actual protocol docs</p>
</li>
<li><p><a href="https://code.claude.com/docs/en/mcp">Claude Code MCP documentation</a>: how Claude Code implements MCP</p>
</li>
<li><p><a href="https://github.com/jlowin/fastmcp">FastMCP GitHub</a>: the Python framework we used</p>
</li>
<li><p><a href="https://authzed.com/blog/timeline-mcp-breaches">AuthZed's timeline of MCP security incidents</a>: required reading if you are building MCP servers for production</p>
</li>
<li><p><a href="https://simonwillison.net/2025/Apr/9/mcp-prompt-injection/">Simon Willison on MCP prompt injection</a>: the clearest explanation of why this is hard to solve</p>
</li>
</ul>
<p>The complete source code for this tutorial is on <a href="https://github.com/balajeeasish/ai-workshop/tree/main/mcp-server">GitHub</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build MCP Servers for Your Internal Data ]]>
                </title>
                <description>
                    <![CDATA[ The Model Context Protocol (MCP) is changing how AI applications connect to external tools and data. While some tutorials stop at "connect to GitHub" or "read a file," the real power of MCP is unlocki ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-mcp-servers-for-your-internal-data/</link>
                <guid isPermaLink="false">69a849e9e55311e40f03f00e</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp server ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mayur Vekariya ]]>
                </dc:creator>
                <pubDate>Wed, 04 Mar 2026 15:04:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/2e428238-cbc3-4892-97df-c1dd854c74c3.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The Model Context Protocol (MCP) is changing how AI applications connect to external tools and data. While some tutorials stop at "connect to GitHub" or "read a file," the real power of MCP is unlocking your <em>internal</em> data—databases, internal APIs, knowledge bases, and proprietary systems—for AI assistants in a structured, secure way.</p>
<p>In this guide, I'll walk you through building production-grade MCP servers that expose your organization's internal data to AI models. We'll go beyond simple examples and cover authentication, multi-tenancy, streaming, and deployment patterns you'll actually need.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#what-is-mcp-and-why-does-it-matter-for-internal-data">What is MCP and Why Does It Matter for Internal Data?</a></p>
</li>
<li><p><a href="#architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#setting-up-the-project">Setting Up the Project</a></p>
</li>
<li><p><a href="#building-the-mcp-server">Building the MCP Server</a></p>
<ul>
<li><p><a href="#step-1-server-skeleton">Step 1: Server Skeleton</a></p>
</li>
<li><p><a href="#step-2-connecting-to-internal-data">Step 2: Connecting to Internal Data</a></p>
</li>
<li><p><a href="#step-3-defining-tools">Step 3: Defining Tools</a></p>
</li>
<li><p><a href="#tool-design-principles">Tool Design Principles</a></p>
</li>
<li><p><a href="#step-4-exposing-resources">Step 4: Exposing Resources</a></p>
</li>
<li><p><a href="#step-5-transport-and-startup">Step 5: Transport and Startup</a></p>
</li>
</ul>
</li>
<li><p><a href="#adding-authentication">Adding Authentication</a></p>
<ul>
<li><p><a href="#bearer-token-authentication">Bearer Token Authentication</a></p>
</li>
<li><p><a href="#oauth-20-for-mcp">OAuth 2.0 for MCP</a></p>
</li>
</ul>
</li>
<li><p><a href="#scoping-data-access-per-user">Scoping Data Access Per User</a></p>
</li>
<li><p><a href="#connecting-to-internal-apis">Connecting to Internal APIs</a></p>
</li>
<li><p><a href="#building-a-rag-tool-for-internal-documents">Building a RAG Tool for Internal Documents</a></p>
</li>
<li><p><a href="#production-deployment">Production Deployment</a></p>
<ul>
<li><p><a href="#dockerizing-the-mcp-server">Dockerizing the MCP Server</a></p>
</li>
<li><p><a href="#health-checks-and-monitoring">Health Checks and Monitoring</a></p>
</li>
<li><p><a href="#logging-and-audit-trail">Logging and Audit Trail</a></p>
</li>
</ul>
</li>
<li><p><a href="#connecting-your-mcp-server-to-ai-clients">Connecting Your MCP Server to AI Clients</a></p>
<ul>
<li><p><a href="#claude-desktop">Claude Desktop</a></p>
</li>
<li><p><a href="#custom-application-using-the-mcp-client-sdk">Custom Application (using the MCP Client SDK)</a></p>
</li>
</ul>
</li>
<li><p><a href="#common-pitfalls">Common Pitfalls</a></p>
</li>
<li><p><a href="#wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This is an advanced guide. You should be comfortable with:</p>
<ul>
<li><p>TypeScript / Node.js</p>
</li>
<li><p>REST APIs and server-side development</p>
</li>
<li><p>Basic understanding of LLMs and tool calling</p>
</li>
<li><p>Familiarity with protocols like JSON-RPC</p>
</li>
</ul>
<h2 id="heading-what-is-mcp-and-why-does-it-matter-for-internal-data">What is MCP, and Why Does It Matter for Internal Data?</h2>
<p>MCP is an open protocol (created by Anthropic) that standardizes how AI assistants discover and invoke external tools. Think of it as a USB-C port for AI — one standard interface that lets any AI model connect to any data source.</p>
<p>Before MCP, connecting an AI assistant to your internal database meant:</p>
<ul>
<li><p>Writing custom tool definitions for each LLM provider</p>
</li>
<li><p>Hardcoding data access logic into your AI application</p>
</li>
<li><p>Rebuilding everything when you switched models or added new data sources</p>
</li>
</ul>
<p>MCP separates the <em>data layer</em> from the <em>AI layer</em>. Your MCP server exposes tools and resources. Any MCP-compatible client—Claude, ChatGPT, your custom app—can use them without modification.</p>
<p>For internal data, this is significant because:</p>
<ul>
<li><p><strong>Your CRM, ERP, ticketing system, and wiki all become AI-accessible</strong> through one protocol</p>
</li>
<li><p><strong>Access control stays in your MCP server</strong>, not scattered across AI application code</p>
</li>
<li><p><strong>New AI models or clients automatically get access</strong> without rewiring integrations</p>
</li>
<li><p><strong>Tool definitions live close to the data</strong>, making them easier to maintain and version</p>
</li>
</ul>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<p>Here's what we're building:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6763484155ac493748dfb95b/3b8f4b25-aa15-4cf1-971d-94f2f7ee9e70.png" alt="MCP server architecture connecting an AI client to internal data sources — PostgreSQL, Internal API, and File Store — via JSON-RPC over HTTP/SSE/stdio." style="display:block;margin:0 auto" width="906" height="397" loading="lazy">

<p>The MCP server sits between your AI client and your internal systems. It handles:</p>
<ul>
<li><p><strong>Tool discovery</strong>: Tells the AI what operations are available</p>
</li>
<li><p><strong>Parameter validation</strong>: Ensures the AI sends correct inputs</p>
</li>
<li><p><strong>Data access</strong>: Queries your internal systems</p>
</li>
<li><p><strong>Response formatting</strong>: Returns structured data the AI can reason about</p>
</li>
<li><p><strong>Authentication</strong>: Verifies who's making the request</p>
</li>
</ul>
<h2 id="heading-setting-up-the-project">Setting Up the Project</h2>
<p>Let's build an MCP server that exposes an internal employee directory and project management system.</p>
<pre><code class="language-shell">mkdir internal-data-mcp &amp;&amp; cd internal-data-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod express pg
npm install -D typescript @types/node @types/express @types/pg tsx
</code></pre>
<p>These commands scaffold the project. <code>npm install</code> pulls in the runtime dependencies: the official MCP SDK, Zod for schema validation, Express for the HTTP server, and <code>pg</code> for PostgreSQL. The <code>-D</code> flag installs TypeScript and its type definitions as dev-only dependencies — they're needed to compile the code but don't ship to production. <code>tsx</code> lets you run TypeScript directly during development without a separate compile step.</p>
<p>Now, create your <code>tsconfig.json</code>:</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}
</code></pre>
<p>This TypeScript config targets ES2022, which supports modern JavaScript features like top-level <code>await</code>. <code>"module": "Node16"</code> and <code>"moduleResolution": "Node16"</code> are required when using the MCP SDK's <code>.js</code> import extensions. <code>"strict": true</code> enables all of TypeScript's strictness checks, which helps catch bugs in tool handlers before they reach production. The <code>outDir</code>/<code>rootDir</code> pair tells the compiler to take source files from <code>src/</code> and emit compiled JavaScript into <code>dist/</code>.</p>
<h2 id="heading-building-the-mcp-server">Building the MCP Server</h2>
<h3 id="heading-step-1-server-skeleton">Step 1: Server Skeleton</h3>
<p>Create <code>src/server.ts</code>:</p>
<pre><code class="language-typescript">import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer(
  { name: "internal-data", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } }
);
</code></pre>
<p>The <code>McpServer</code> class from the official SDK handles the JSON-RPC protocol, transport negotiation, and lifecycle management. We declare support for both <code>tools</code> (actions the AI can take) and <code>resources</code> (data the AI can read).</p>
<h3 id="heading-step-2-connecting-to-internal-data">Step 2: Connecting to Internal Data</h3>
<p>Let's say you have a PostgreSQL database with employee and project data. Create a data access layer:</p>
<pre><code class="language-typescript">// src/db.ts
import pg from "pg";

const pool = new pg.Pool({
  connectionString: process.env.INTERNAL_DB_URL,
  max: 10,
  idleTimeoutMillis: 30000,
});

export interface Employee {
  id: string;
  name: string;
  email: string;
  department: string;
  role: string;
  manager_id: string | null;
  start_date: string;
}

export interface Project {
  id: string;
  name: string;
  status: "active" | "completed" | "on_hold";
  lead_id: string;
  department: string;
  deadline: string | null;
}

export async function searchEmployees(
  query: string,
  department?: string
): Promise&lt;Employee[]&gt; {
  const conditions = ["(name ILIKE \(1 OR email ILIKE \)1 OR role ILIKE $1)"];
  const params: string[] = [`%${query}%`];

  if (department) {
    conditions.push(`department = $${params.length + 1}`);
    params.push(department);
  }

  const result = await pool.query&lt;Employee&gt;(
    `SELECT id, name, email, department, role, manager_id, start_date
     FROM employees
     WHERE ${conditions.join(" AND ")}
     ORDER BY name
     LIMIT 25`,
    params
  );

  return result.rows;
}

export async function getProjectsByStatus(
  status: string
): Promise&lt;Project[]&gt; {
  const result = await pool.query&lt;Project&gt;(
    `SELECT id, name, status, lead_id, department, deadline
     FROM projects
     WHERE status = $1
     ORDER BY deadline ASC NULLS LAST`,
    [status]
  );

  return result.rows;
}

export async function getProjectMembers(
  projectId: string
): Promise&lt;Employee[]&gt; {
  const result = await pool.query&lt;Employee&gt;(
    `SELECT e.id, e.name, e.email, e.department, e.role,
            e.manager_id, e.start_date
     FROM employees e
     JOIN project_members pm ON pm.employee_id = e.id
     WHERE pm.project_id = $1
     ORDER BY e.name`,
    [projectId]
  );

  return result.rows;
}
</code></pre>
<p>Notice this is plain SQL with parameterized queries. Your MCP server's data access layer should use whatever your team already uses — Prisma, Drizzle, Knex, raw SQL. MCP doesn't dictate your data access patterns.</p>
<h3 id="heading-step-3-defining-tools">Step 3: Defining Tools</h3>
<p>Now expose this data through MCP tools. This is where the design matters most. Good tool definitions directly impact how well the AI uses your data.</p>
<pre><code class="language-typescript">// src/tools.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {
  searchEmployees,
  getProjectsByStatus,
  getProjectMembers,
} from "./db.js";

export function registerTools(server: McpServer) {
  // Tool 1: Search the employee directory
  server.tool(
    "search_employees",
    `Search the internal employee directory by name, email, or role.
     Returns matching employees with their department and reporting structure.
     Use this when the user asks about people, teams, or org structure.`,
    {
      query: z
        .string()
        .describe("Search term: employee name, email, or role title"),
      department: z
        .string()
        .optional()
        .describe(
          "Filter by department name (e.g., 'Engineering', 'Marketing')"
        ),
    },
    async ({ query, department }) =&gt; {
      const employees = await searchEmployees(query, department);

      if (employees.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: `No employees found matching "\({query}"\){department ? ` in ${department}` : ""}.`,
            },
          ],
        };
      }

      const formatted = employees
        .map(
          (e) =&gt;
            `- **\({e.name}** (\){e.email})\n  Role: \({e.role} | Dept: \){e.department} | Since: ${e.start_date}`
        )
        .join("\n");

      return {
        content: [
          {
            type: "text",
            text: `Found \({employees.length} employee(s):\n\n\){formatted}`,
          },
        ],
      };
    }
  );

  // Tool 2: List projects by status
  server.tool(
    "list_projects",
    `List internal projects filtered by status.
     Returns project name, lead, department, and deadline.
     Use this when the user asks about ongoing work, project status, or deadlines.`,
    {
      status: z
        .enum(["active", "completed", "on_hold"])
        .describe("Project status to filter by"),
    },
    async ({ status }) =&gt; {
      const projects = await getProjectsByStatus(status);

      if (projects.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: `No ${status} projects found.`,
            },
          ],
        };
      }

      const formatted = projects
        .map(
          (p) =&gt;
            `- **\({p.name}** [\){p.status}]\n  Lead: \({p.lead_id} | Dept: \){p.department} | Deadline: ${p.deadline ?? "None"}`
        )
        .join("\n");

      return {
        content: [
          {
            type: "text",
            text: `\({projects.length} \){status} project(s):\n\n${formatted}`,
          },
        ],
      };
    }
  );

  // Tool 3: Get team members for a project
  server.tool(
    "get_project_team",
    `Get all team members assigned to a specific project.
     Returns employee details for each member.
     Use this when the user asks who is working on a project.`,
    {
      project_id: z
        .string()
        .uuid()
        .describe("The UUID of the project to look up"),
    },
    async ({ project_id }) =&gt; {
      const members = await getProjectMembers(project_id);

      if (members.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: "No team members found for this project.",
            },
          ],
        };
      }

      const formatted = members
        .map((m) =&gt; `- \({m.name} (\){m.role}, ${m.department})`)
        .join("\n");

      return {
        content: [
          {
            type: "text",
            text: `Project team (\({members.length} members):\n\n\){formatted}`,
          },
        ],
      };
    }
  );
}
</code></pre>
<p><code>server.tool()</code> registers each tool with four arguments: the tool name, a plain-English description the AI reads to decide when to call it, a Zod schema defining the parameters, and the async handler that runs when the tool is invoked. The handler receives validated, typed parameters — Zod rejects malformed inputs before your handler ever runs. Each handler returns a <code>content</code> array; the <code>type: "text"</code> block is the most common format and tells the AI client to treat the response as readable text. Returning an empty result (zero matches) is handled explicitly so the AI gets a useful message rather than an empty array it might misinterpret.</p>
<h3 id="heading-tool-design-principles">Tool Design Principles</h3>
<p>Three things make the difference between tools an AI uses well and tools it struggles with:</p>
<p><strong>1. Descriptive names and descriptions.</strong> The AI decides which tool to call based entirely on the description. Be specific about <em>when</em> to use the tool, not just <em>what</em> it does. Compare:</p>
<pre><code class="language-plaintext">// Vague — the AI won't know when to pick this
"Search employees"

// Specific — the AI knows exactly when this tool is relevant
"Search the internal employee directory by name, email, or role.
 Use this when the user asks about people, teams, or org structure."
</code></pre>
<p><strong>2. Typed parameters with descriptions.</strong> Use Zod's <code>.describe()</code> on every parameter. The AI needs to understand what each field expects:</p>
<pre><code class="language-typescript">// The AI has to guess what format "query" expects
{ query: z.string() }

// The AI knows exactly what to pass
{ query: z.string().describe("Search term: employee name, email, or role title") }
</code></pre>
<p><strong>3. Structured return values.</strong> Return data in a format the AI can reason about. Use markdown tables or structured lists rather than raw JSON dumps. The AI processes structured text better than deeply nested objects.</p>
<h3 id="heading-step-4-exposing-resources">Step 4: Exposing Resources</h3>
<p>Resources are read-only data the AI can pull into its context. Unlike tools (which the AI invokes during reasoning), resources are typically loaded upfront to provide background knowledge.</p>
<pre><code class="language-typescript">// src/resources.ts
import {
  McpServer,
  ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";

export function registerResources(server: McpServer) {
  // Static resource: org chart overview
  server.resource(
    "org-structure",
    "internal://org-structure",
    {
      description:
        "Overview of the organization structure including departments and leadership",
      mimeType: "text/markdown",
    },
    async (uri) =&gt; ({
      contents: [
        {
          uri: uri.href,
          mimeType: "text/markdown",
          text: await generateOrgOverview(),
        },
      ],
    })
  );

  // Dynamic resource template: department details
  server.resource(
    "department-info",
    new ResourceTemplate("internal://departments/{name}", {
      list: undefined,
    }),
    {
      description: "Detailed information about a specific department",
      mimeType: "text/markdown",
    },
    async (uri, variables) =&gt; ({
      contents: [
        {
          uri: uri.href,
          mimeType: "text/markdown",
          text: await getDepartmentDetails(
            variables.name as string
          ),
        },
      ],
    })
  );
}
</code></pre>
<p><code>server.resource()</code> registers two kinds of resources here. The first uses a fixed URI (<code>internal://org-structure</code>) — this is a static resource the AI can request by name. The second uses a <code>ResourceTemplate</code>, which defines a URI pattern with a <code>{name}</code> placeholder; the AI can request <code>internal://departments/Engineering</code> and the <code>variables.name</code> parameter will be populated with <code>"Engineering"</code> at runtime. Both resources return a <code>contents</code> array with <code>mimeType: "text/markdown"</code> — this tells the client how to render the response. Resources differ from tools in that they're meant to be read as background context, not invoked as actions.</p>
<p>Resources are useful for data that provides context rather than answering a specific question — company policies, API documentation, database schemas, configuration references.</p>
<h3 id="heading-step-5-transport-and-startup">Step 5: Transport and Startup</h3>
<p>MCP supports multiple transports. For internal data servers, you'll typically use one of two:</p>
<p><strong>Streamable HTTP</strong> — the recommended transport for remote servers (replaces the older SSE transport):</p>
<pre><code class="language-typescript">// src/index.ts
import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { registerTools } from "./tools.js";
import { registerResources } from "./resources.js";

const app = express();
app.use(express.json());

const server = new McpServer(
  { name: "internal-data", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } }
);

registerTools(server);
registerResources(server);

// Store transports by session ID
const transports = new Map&lt;string, StreamableHTTPServerTransport&gt;();

// Handle all MCP requests on a single endpoint
app.all("/mcp", async (req, res) =&gt; {
  // Check for existing session
  const sessionId = req.headers["mcp-session-id"] as string | undefined;

  if (sessionId &amp;&amp; transports.has(sessionId)) {
    // Existing session — route to its transport
    const transport = transports.get(sessionId)!;
    await transport.handleRequest(req, res);
    return;
  }

  if (sessionId &amp;&amp; !transports.has(sessionId)) {
    // Unknown session ID
    res.status(404).json({ error: "Session not found" });
    return;
  }

  // New session — create transport and connect
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () =&gt; randomUUID(),
    onsessioninitialized: (id) =&gt; {
      transports.set(id, transport);
    },
  });

  transport.onclose = () =&gt; {
    if (transport.sessionId) {
      transports.delete(transport.sessionId);
    }
  };

  await server.connect(transport);
  await transport.handleRequest(req, res);
});

app.listen(3100, () =&gt; {
  console.log("MCP server running on http://localhost:3100/mcp");
});
</code></pre>
<p>This sets up a single <code>/mcp</code> endpoint that handles all MCP communication. When a new client connects (no <code>mcp-session-id</code> header), a <code>StreamableHTTPServerTransport</code> is created and stored in the <code>transports</code> Map keyed by a generated UUID. On subsequent requests, the session ID from the header is used to look up the existing transport and route the request to it — this is how the server maintains stateful sessions with multiple clients simultaneously. <code>transport.onclose</code> cleans up the Map entry when a session ends, preventing memory leaks. The <code>StdioServerTransport</code> alternative (shown below) skips all of this: it reads from stdin and writes to stdout, which is how Claude Desktop spawns local servers as child processes.</p>
<p><strong>Stdio</strong> — for local development or when the MCP client spawns the server as a child process:</p>
<pre><code class="language-typescript">// src/stdio.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerTools } from "./tools.js";
import { registerResources } from "./resources.js";

const server = new McpServer(
  { name: "internal-data", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } }
);

registerTools(server);
registerResources(server);

const transport = new StdioServerTransport();
await server.connect(transport);
</code></pre>
<p>For internal data in a production setting, HTTP/SSE is almost always what you want. Stdio is convenient for development and when the client and server run on the same machine.</p>
<h2 id="heading-adding-authentication">Adding Authentication</h2>
<p>Internal data servers need authentication. You don't want every AI client on the network querying your employee database unauthenticated.</p>
<h3 id="heading-bearer-token-authentication">Bearer Token Authentication</h3>
<p>The simplest approach is to validate a token on every request:</p>
<pre><code class="language-typescript">// src/auth-middleware.ts
import { Request, Response, NextFunction } from "express";

interface AuthenticatedRequest extends Request {
  userId?: string;
  orgId?: string;
}

export function authMiddleware(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing authorization header" });
  }

  const token = authHeader.slice(7);

  try {
    // Validate against your internal auth system
    const claims = validateInternalToken(token);
    req.userId = claims.sub;
    req.orgId = claims.org;
    next();
  } catch {
    return res.status(403).json({ error: "Invalid token" });
  }
}

function validateInternalToken(token: string) {
  // Replace with your actual token validation:
  // - JWT verification against your auth service
  // - API key lookup in your database
  // - Session token validation against Redis
  // This is a placeholder
  return { sub: "user-123", org: "org-456" };
}
</code></pre>
<p>The middleware checks every request for an <code>Authorization: Bearer &lt;token&gt;</code> header before it reaches the MCP handler. <code>validateInternalToken</code> is a placeholder — replace it with your real validation logic: JWT verification using a library like <code>jsonwebtoken</code>, an API key lookup in your database, or a session token check against Redis. The validated claims are attached to the request object (<code>req.userId</code>, <code>req.orgId</code>) so downstream tool handlers can use them for access scoping. The <code>app.use("/mcp", authMiddleware)</code> line ensures no request reaches the MCP endpoint without passing this check first.</p>
<p>Add it to your Express app:</p>
<pre><code class="language-typescript">app.use("/mcp", authMiddleware);
</code></pre>
<h3 id="heading-oauth-20-for-mcp">OAuth 2.0 for MCP</h3>
<p>For clients that support MCP's built-in OAuth flow (like Claude Desktop), you can implement the full OAuth handshake. The MCP SDK provides the <code>OAuthServerProvider</code> interface with these required methods:</p>
<pre><code class="language-typescript">import type { OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/provider.js";
import type {
  AuthorizationParams,
  OAuthClientInformationFull,
  OAuthRegisteredClientsStore,
  OAuthTokens,
  AuthInfo,
} from "@modelcontextprotocol/sdk/server/auth/types.js";

class InternalOAuthProvider implements OAuthServerProvider {
  // Store for registered OAuth clients
  get clientsStore(): OAuthRegisteredClientsStore {
    return this._clientsStore;
  }

  private _clientsStore: OAuthRegisteredClientsStore = {
    async getClient(clientId: string) {
      // Look up the registered client in your database
      return db.getOAuthClient(clientId);
    },
    async registerClient(clientMetadata) {
      // Register a new dynamic client
      return db.createOAuthClient(clientMetadata);
    },
  };

  // Redirect the user to your internal SSO for authorization
  async authorize(
    client: OAuthClientInformationFull,
    params: AuthorizationParams,
    res: Response
  ): Promise&lt;void&gt; {
    const authUrl = new URL(
      "https://sso.internal.company.com/authorize"
    );
    authUrl.searchParams.set("client_id", client.client_id);
    authUrl.searchParams.set("redirect_uri", params.redirectUri);
    authUrl.searchParams.set("state", params.state ?? "");
    authUrl.searchParams.set(
      "code_challenge",
      params.codeChallenge
    );
    // The method writes to the response directly
    res.redirect(authUrl.toString());
  }

  // Return the PKCE challenge for a given authorization code
  async challengeForAuthorizationCode(
    _client: OAuthClientInformationFull,
    authorizationCode: string
  ): Promise&lt;string&gt; {
    const session = await db.getSessionByCode(authorizationCode);
    return session.codeChallenge;
  }

  // Exchange authorization code for access + refresh tokens
  async exchangeAuthorizationCode(
    client: OAuthClientInformationFull,
    authorizationCode: string,
    _codeVerifier?: string,
    _redirectUri?: string
  ): Promise&lt;OAuthTokens&gt; {
    const response = await fetch(
      "https://sso.internal.company.com/token",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          grant_type: "authorization_code",
          code: authorizationCode,
          client_id: client.client_id,
        }),
      }
    );

    return response.json() as Promise&lt;OAuthTokens&gt;;
  }

  // Refresh expired tokens
  async exchangeRefreshToken(
    client: OAuthClientInformationFull,
    refreshToken: string
  ): Promise&lt;OAuthTokens&gt; {
    const response = await fetch(
      "https://sso.internal.company.com/token",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          grant_type: "refresh_token",
          refresh_token: refreshToken,
          client_id: client.client_id,
        }),
      }
    );

    return response.json() as Promise&lt;OAuthTokens&gt;;
  }

  // Validate an access token on every request
  async verifyAccessToken(token: string): Promise&lt;AuthInfo&gt; {
    const response = await fetch(
      "https://sso.internal.company.com/introspect",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({ token }),
      }
    );

    const data = await response.json();
    if (!data.active) throw new Error("Token inactive");

    return {
      token,
      clientId: data.client_id,
      scopes: data.scope?.split(" ") ?? [],
      expiresAt: data.exp,
    };
  }
}
</code></pre>
<p><code>InternalOAuthProvider</code> implements the <code>OAuthServerProvider</code> interface, which the MCP SDK calls at each stage of the OAuth flow. <code>clientsStore</code> handles dynamic client registration — MCP clients like Claude Desktop register themselves the first time they connect. <code>authorize()</code> redirects the user to your internal SSO; it writes directly to the Express response. <code>challengeForAuthorizationCode()</code> returns the PKCE code challenge stored when the authorization session began — this is how the token exchange is verified without transmitting secrets. <code>exchangeAuthorizationCode()</code> and <code>exchangeRefreshToken()</code> make server-to-server calls to your SSO's token endpoint, keeping credentials out of the browser. <code>verifyAccessToken()</code> is called on every incoming MCP request using the token introspection endpoint to confirm the token is still active and extract the user's scopes.</p>
<h2 id="heading-scoping-data-access-per-user">Scoping Data Access Per User</h2>
<p>This is the most important part of an internal data MCP server: <strong>the AI should only access data the requesting user is authorized to see.</strong></p>
<p>Don't skip this. Without user-scoped access, you're building a data exfiltration tool with an AI wrapper.</p>
<pre><code class="language-typescript">// src/scoped-tools.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function registerScopedTools(
  server: McpServer,
  getUserContext: () =&gt; { userId: string; orgId: string; role: string }
) {
  server.tool(
    "search_employees",
    "Search the employee directory. Results are filtered based on your access level.",
    {
      query: z.string().describe("Name, email, or role to search for"),
    },
    async ({ query }) =&gt; {
      const ctx = getUserContext();

      // Enforce access boundaries
      let departmentFilter: string | undefined;

      if (ctx.role === "manager") {
        // Managers see their department only
        departmentFilter = await getUserDepartment(ctx.userId);
      } else if (ctx.role === "employee") {
        // Regular employees see limited fields
        departmentFilter = await getUserDepartment(ctx.userId);
      }
      // Admins and HR see everything — no filter

      const employees = await searchEmployees(query, departmentFilter);

      // Redact sensitive fields based on role
      const results = employees.map((e) =&gt; ({
        name: e.name,
        email: e.email,
        department: e.department,
        role: e.role,
        // Only HR and admins see start date and manager info
        ...(["admin", "hr"].includes(ctx.role)
          ? { start_date: e.start_date, manager_id: e.manager_id }
          : {}),
      }));

      return {
        content: [
          {
            type: "text",
            text: formatEmployeeList(results),
          },
        ],
      };
    }
  );
}
</code></pre>
<p>The pattern here:</p>
<ol>
<li><p><strong>Extract user context</strong> from the authenticated session</p>
</li>
<li><p><strong>Filter queries</strong> at the database level (not after fetching everything)</p>
</li>
<li><p><strong>Redact fields</strong> the user shouldn't see</p>
</li>
<li><p><strong>Log access</strong> for audit trails</p>
</li>
</ol>
<h2 id="heading-connecting-to-internal-apis">Connecting to Internal APIs</h2>
<p>Not all internal data lives in databases. You often need to wrap existing internal APIs:</p>
<pre><code class="language-typescript">server.tool(
  "get_ticket_details",
  `Look up a support ticket from the internal ticketing system.
   Returns ticket status, assignee, priority, and recent updates.`,
  {
    ticket_id: z
      .string()
      .regex(/^TK-\d+$/)
      .describe("Ticket ID in format TK-12345"),
  },
  async ({ ticket_id }) =&gt; {
    const ctx = getUserContext();

    const response = await fetch(
      `\({process.env.TICKETING_API_URL}/api/v2/tickets/\){ticket_id}`,
      {
        headers: {
          Authorization: `Bearer ${process.env.TICKETING_SERVICE_TOKEN}`,
          "X-On-Behalf-Of": ctx.userId,
        },
      }
    );

    if (response.status === 404) {
      return {
        content: [
          { type: "text", text: `Ticket ${ticket_id} not found.` },
        ],
      };
    }

    if (response.status === 403) {
      return {
        content: [
          {
            type: "text",
            text: `You don't have access to ticket ${ticket_id}.`,
          },
        ],
      };
    }

    const ticket = await response.json();

    return {
      content: [
        {
          type: "text",
          text: [
            `**\({ticket.id}: \){ticket.title}**`,
            `Status: \({ticket.status} | Priority: \){ticket.priority}`,
            `Assignee: ${ticket.assignee?.name ?? "Unassigned"}`,
            `Created: ${ticket.created_at}`,
            "",
            `**Latest Update:**`,
            ticket.updates?.[0]?.body ?? "No updates yet.",
          ].join("\n"),
        },
      ],
    };
  }
);
</code></pre>
<p>Key points when wrapping internal APIs:</p>
<ul>
<li><p><strong>Use service tokens</strong> for server-to-server auth, but pass user identity via headers like <code>X-On-Behalf-Of</code></p>
</li>
<li><p><strong>Handle HTTP errors explicitly</strong> — return user-friendly messages, not raw error objects</p>
</li>
<li><p><strong>Validate input formats</strong> — the regex on <code>ticket_id</code> prevents injection and guides the AI on expected format</p>
</li>
<li><p><strong>Don't leak internal implementation details</strong> in error messages</p>
</li>
</ul>
<h2 id="heading-building-a-rag-tool-for-internal-documents">Building a RAG Tool for Internal Documents</h2>
<p>One of the highest-value use cases: letting the AI search your internal knowledge base. Here's a tool that performs vector search against an internal document store:</p>
<pre><code class="language-typescript">server.tool(
  "search_internal_docs",
  `Search the internal knowledge base for relevant documents.
   Covers engineering docs, runbooks, architecture decisions, and policies.
   Use this when the user asks about internal processes, systems, or decisions.`,
  {
    query: z
      .string()
      .describe("Natural language search query"),
    category: z
      .enum(["engineering", "policy", "runbook", "architecture", "all"])
      .default("all")
      .describe("Document category to search within"),
    limit: z
      .number()
      .min(1)
      .max(10)
      .default(5)
      .describe("Maximum number of results"),
  },
  async ({ query, category, limit }) =&gt; {
    // Generate embedding for the search query
    const embedding = await generateEmbedding(query);

    // Vector similarity search against your document store
    const results = await pool.query(
      `SELECT
         d.id,
         d.title,
         d.category,
         d.content_chunk,
         d.source_url,
         d.updated_at,
         1 - (d.embedding &lt;=&gt; $1::vector) AS similarity
       FROM document_chunks d
       WHERE (\(2 = 'all' OR d.category = \)2)
         AND 1 - (d.embedding &lt;=&gt; $1::vector) &gt; 0.7
       ORDER BY d.embedding &lt;=&gt; $1::vector
       LIMIT $3`,
      [JSON.stringify(embedding), category, limit]
    );

    if (results.rows.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: `No relevant documents found for "${query}".`,
          },
        ],
      };
    }

    const formatted = results.rows
      .map(
        (doc, i) =&gt;
          `### \({i + 1}. \){doc.title}\n` +
          `Category: \({doc.category} | Updated: \){doc.updated_at} | Relevance: ${(doc.similarity * 100).toFixed(0)}%\n\n` +
          `${doc.content_chunk}\n\n` +
          `Source: ${doc.source_url}`
      )
      .join("\n\n---\n\n");

    return {
      content: [
        {
          type: "text",
          text: `Found \({results.rows.length} relevant document(s):\n\n\){formatted}`,
        },
      ],
    };
  }
);
</code></pre>
<p>This tool combines two operations: embedding generation and vector similarity search. <code>generateEmbedding(query)</code> calls an embedding model (such as OpenAI's <code>text-embedding-3-small</code> or a self-hosted model) to convert the user's query into a numeric vector. The SQL query then uses pgvector's <code>&lt;=&gt;</code> operator to compute cosine distance between the query vector and stored document chunk embeddings — lower distance means higher similarity. The <code>1 - (embedding &lt;=&gt; $1) &gt; 0.7</code> condition filters out results below 70% similarity, so the AI doesn't receive loosely related noise. Results are ordered by ascending distance (most similar first) and capped by the <code>limit</code> parameter. The formatted output includes a relevance percentage so the AI can communicate confidence levels to the user.</p>
<h2 id="heading-production-deployment">Production Deployment</h2>
<h3 id="heading-dockerizing-the-mcp-server">Dockerizing the MCP Server</h3>
<pre><code class="language-dockerfile">FROM node:22-slim AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim AS runtime

WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

ENV NODE_ENV=production
EXPOSE 3100

HEALTHCHECK --interval=30s --timeout=5s \
  CMD curl -f http://localhost:3100/health || exit 1

CMD ["node", "dist/index.js"]
</code></pre>
<p>The Dockerfile uses a two-stage build. The <code>builder</code> stage installs all dependencies (including devDependencies) and compiles TypeScript to JavaScript in <code>dist/</code>. The <code>runtime</code> stage starts fresh from a clean Node image and copies only the compiled output and <code>node_modules</code> — devDependencies like TypeScript are excluded, keeping the final image small. The <code>HEALTHCHECK</code> instruction tells Docker (and orchestrators like Kubernetes) to poll <code>/health</code> every 30 seconds; if the endpoint fails, the container is marked unhealthy and can be automatically restarted or removed from the load balancer rotation.</p>
<h3 id="heading-health-checks-and-monitoring">Health Checks and Monitoring</h3>
<p>Add a health endpoint that verifies your dependencies:</p>
<pre><code class="language-typescript">app.get("/health", async (_req, res) =&gt; {
  const checks = {
    database: false,
    ticketingApi: false,
  };

  try {
    await pool.query("SELECT 1");
    checks.database = true;
  } catch {}

  try {
    const resp = await fetch(
      `${process.env.TICKETING_API_URL}/health`
    );
    checks.ticketingApi = resp.ok;
  } catch {}

  const healthy = Object.values(checks).every(Boolean);
  res.status(healthy ? 200 : 503).json({
    status: healthy ? "healthy" : "degraded",
    checks,
    uptime: process.uptime(),
  });
});
</code></pre>
<p>The <code>/health</code> endpoint runs two dependency checks in parallel: a lightweight <code>SELECT 1</code> query to confirm the database connection is live, and an HTTP ping to the ticketing API. Both results are collected into a <code>checks</code> object. If any check fails, the endpoint returns HTTP 503 (Service Unavailable) — this is the signal load balancers and container orchestrators use to stop routing traffic to an unhealthy instance. <code>process.uptime()</code> is included as a diagnostic field so you can quickly tell whether a degraded instance just started or has been running for hours.</p>
<h3 id="heading-logging-and-audit-trail">Logging and Audit Trail</h3>
<p>Every tool invocation against internal data should be logged:</p>
<pre><code class="language-typescript">function createAuditLogger() {
  return {
    logToolCall(params: {
      userId: string;
      tool: string;
      input: Record&lt;string, unknown&gt;;
      resultSize: number;
      durationMs: number;
    }) {
      // Ship to your logging infrastructure
      // (Datadog, ELK, CloudWatch, etc.)
      console.log(
        JSON.stringify({
          event: "mcp_tool_call",
          timestamp: new Date().toISOString(),
          ...params,
        })
      );
    },
  };
}
</code></pre>
<p><code>createAuditLogger</code> returns a logger object rather than a class instance, which makes it easy to swap the underlying transport (stdout, a logging SDK, etc.) without changing the call sites. The <code>audited</code> wrapper function is a higher-order function: it takes a tool handler and returns a new function with the same signature, but with timing and logging added around the original call. The <code>try/catch</code> ensures a log entry is written even when the handler throws — you want failed calls in your audit trail, not just successful ones. Shipping these logs to a centralized store (Datadog, CloudWatch, ELK) lets you answer questions like "what data did this user's AI session access last Tuesday?" — which is often required for compliance in organizations handling sensitive internal data.</p>
<p>Wrap your tool handlers to automatically log every call:</p>
<pre><code class="language-typescript">function audited&lt;T extends Record&lt;string, unknown&gt;&gt;(
  handler: (params: T) =&gt; Promise&lt;ToolResult&gt;,
  toolName: string,
  audit: ReturnType&lt;typeof createAuditLogger&gt;
) {
  return async (params: T): Promise&lt;ToolResult&gt; =&gt; {
    const start = Date.now();
    const ctx = getUserContext();

    try {
      const result = await handler(params);
      audit.logToolCall({
        userId: ctx.userId,
        tool: toolName,
        input: params,
        resultSize: JSON.stringify(result).length,
        durationMs: Date.now() - start,
      });
      return result;
    } catch (error) {
      audit.logToolCall({
        userId: ctx.userId,
        tool: toolName,
        input: params,
        resultSize: 0,
        durationMs: Date.now() - start,
      });
      throw error;
    }
  };
}
</code></pre>
<h2 id="heading-connecting-your-mcp-server-to-ai-clients">Connecting Your MCP Server to AI Clients</h2>
<h3 id="heading-claude-desktop">Claude Desktop</h3>
<p>Add to your <code>claude_desktop_config.json</code>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "internal-data": {
      "url": "http://localhost:3100/mcp",
      "headers": {
        "Authorization": "Bearer your-internal-token"
      }
    }
  }
}
</code></pre>
<h3 id="heading-custom-application-using-the-mcp-client-sdk">Custom Application (using the MCP Client SDK)</h3>
<pre><code class="language-typescript">import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("http://localhost:3100/mcp"),
  {
    requestInit: {
      headers: {
        Authorization: `Bearer ${userToken}`,
      },
    },
  }
);

const client = new Client(
  { name: "my-ai-app", version: "1.0.0" }
);

await client.connect(transport);

// Discover available tools
const { tools } = await client.listTools();
console.log("Available tools:", tools.map((t) =&gt; t.name));

// Call a tool
const result = await client.callTool({
  name: "search_employees",
  arguments: { query: "engineering manager" },
});

console.log(result.content);
</code></pre>
<p><code>StreamableHTTPClientTransport</code> manages the HTTP connection to your MCP server, including attaching the <code>Authorization</code> header to every request. <code>client.connect(transport)</code> performs the MCP initialization handshake — the client announces its capabilities and the server responds with the list of available tools and resources. <code>client.listTools()</code> returns the full tool catalog, which you can use to dynamically build a UI or pass directly to an LLM's tool-calling API. <code>client.callTool()</code> sends a JSON-RPC request to invoke a specific tool by name and returns the <code>content</code> array from the handler — the same format the AI model receives. In a production application, you'd pass this <code>content</code> back to the model as a tool result in the conversation history.</p>
<h2 id="heading-common-pitfalls">Common Pitfalls</h2>
<p><strong>1. Returning too much data.</strong> LLMs have context limits. If your database query returns 500 rows, don't send them all. Paginate, summarize, or limit results. 25 items is a reasonable default.</p>
<p><strong>2. Tool descriptions that are too generic.</strong> If you have <code>search_employees</code> and <code>search_contractors</code>, the AI needs to know the difference. Don't rely on the tool name alone — the description is what the model reads.</p>
<p><strong>3. Missing error handling.</strong> When a database query fails, return a structured error message, not a stack trace. The AI needs to tell the user something useful, and raw errors leak implementation details.</p>
<p><strong>4. No rate limiting.</strong> AI tool calls can happen in loops. If the model calls your tool 50 times in one conversation, you need circuit breakers:</p>
<pre><code class="language-typescript">const rateLimiter = new Map&lt;string, number[]&gt;();

function checkRateLimit(userId: string, limit = 30, windowMs = 60000) {
  const now = Date.now();
  const calls = rateLimiter.get(userId) ?? [];
  const recent = calls.filter((t) =&gt; now - t &lt; windowMs);

  if (recent.length &gt;= limit) {
    throw new Error(
      `Rate limit exceeded. Max ${limit} calls per minute.`
    );
  }

  recent.push(now);
  rateLimiter.set(userId, recent);
}
</code></pre>
<p><strong>5. Not testing with actual AI models.</strong> Your tools might look correct in unit tests but confuse the model. Test the full loop: AI model receives tool definitions, decides to call a tool, gets the result, and reasons about it. Adjust descriptions based on how the model actually behaves.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Building MCP servers for internal data is about three things:</p>
<ol>
<li><p><strong>Good tool design</strong> — clear descriptions, typed parameters, structured responses</p>
</li>
<li><p><strong>Proper access control</strong> — authenticate users, scope data access, log everything</p>
</li>
<li><p><strong>Production readiness</strong> — health checks, rate limiting, error handling, monitoring</p>
</li>
</ol>
<p>The protocol itself is straightforward. The hard work is designing the right abstractions over your internal systems so the AI can use them effectively without leaking data or overwhelming the context window.</p>
<p>Start with one or two high-value tools (employee lookup, document search), test them with real users, and expand from there. The best internal MCP servers grow organically based on what people actually ask the AI.</p>
<p>The full source code from this guide is available on <a href="https://github.com/mayur9210/build-mcp-server-template">GitHub</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Learn RAG & MCP Fundamentals ]]>
                </title>
                <description>
                    <![CDATA[ Building AI today is about more than just a clever prompt. If you really want to move from playing with standalone tools to creating integrated systems that actually work with your data, our new crash course on the freeCodeCamp.org YouTube channel is... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/learn-rag-and-mcp-fundamentals/</link>
                <guid isPermaLink="false">6972357968889fc0fe8adf6b</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Thu, 22 Jan 2026 14:34:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769092417621/b2dddb48-37e0-4303-b111-57f643b39bee.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Building AI today is about more than just a clever prompt. If you really want to move from playing with standalone tools to creating integrated systems that actually work with your data, our new crash course on the <a target="_blank" href="http://freeCodeCamp.org">freeCodeCamp.org</a> YouTube channel is exactly where you need to start.</p>
<h3 id="heading-mastering-rag-retrieval-augmented-generation">Mastering RAG (Retrieval Augmented Generation)</h3>
<p>Everyone is talking about RAG, but many people struggle to understand how it works under the hood. This course starts by breaking down how to connect a model to your own private information. You will learn how to turn documents into embeddings (mathematical representations of meaning) and store them in vector databases like Chroma.</p>
<p>The course also covers the "precision problem." You will learn why just uploading a massive PDF doesn't work and how to use chunking strategies to ensure the AI finds exactly the right paragraph to answer a user's question.</p>
<h3 id="heading-coordination-with-mcp">Coordination with MCP</h3>
<p>While RAG gives an AI knowledge, the Model Context Protocol (MCP) gives it the ability to coordinate actions. MCP allows AI agents to interact with third-party software, databases, and local files. Instead of writing custom code for every single API, MCP provides a standardized way for agents to discover what a server can do and then execute tasks.</p>
<p>You will learn how to build your own MCP server and client using the Python SDK, giving your AI the "hands" it needs to perform real-world tasks.</p>
<p>Watch the full course on <a target="_blank" href="https://youtu.be/I7_WXKhyGms">the freeCodeCamp.org YouTube channel</a> (2-hour watch).</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/I7_WXKhyGms" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How Does an MCP Work Under the Hood? MCP Workflow Explained ]]>
                </title>
                <description>
                    <![CDATA[ We’ve all faced that awkward limitation with AI: it can write code or explain complex topics in seconds, but the moment you ask it to check a local file or run a quick database query, it hits a wall. It’s like having a genius assistant who is locked ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-does-an-mcp-work-under-the-hood/</link>
                <guid isPermaLink="false">6941a65f3076ac3edd6fdaf0</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Model Context Protocol ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ajay Patel ]]>
                </dc:creator>
                <pubDate>Tue, 16 Dec 2025 18:35:11 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1765909617721/fa533504-3dab-48c3-9b92-0b89a81af025.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>We’ve all faced that awkward limitation with AI: it can write code or explain complex topics in seconds, but the moment you ask it to check a local file or run a quick database query, it hits a wall. It’s like having a genius assistant who is locked in an empty room—smart, but completely cut off from your actual work. This is where the Model Context Protocol (MCP) changes the game. In this article, we’ll explore MCP in depth.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-mcp-server-a-z-of-model-context-protocol">MCP Server: A-Z of Model Context Protocol</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-mcp-model-context-protocol">What is MCP (Model Context Protocol)?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-architecture-of-mcp">Architecture of MCP</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-does-mcp-work">How Does MCP Work?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-mcp-vs-rag">MCP vs RAG</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-mcp-vs-a2a">MCP vs A2A</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-resources">Resources</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-mcp-server-a-z-of-model-context-protocol">MCP Server: A-Z of Model Context Protocol</h2>
<p>LLMs possess impressive knowledge and reasoning skills, which allow them to perform many complex tasks. But the problem is that their knowledge is limited to their initial training data. It means they can’t access your calendar, run SQL queries, or send an email.</p>
<p>It was clear that, to give the LLMs real-world knowledge, we have to provide some integrations that enable them to access real-time knowledge or perform some actions in the real world. This leads to the classic MxN problems, where developers have to build and maintain custom integrations for every combination of M models and N tools.</p>
<p>The image below properly demonstrates the MxN Problem:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764841852514/f4279e47-416d-4559-8908-16199eab3820.jpeg" alt="mxn problem - connecting every model to every tool individually" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>Function calling (also known as tool calling) provides a powerful and flexible way for OpenAI models to interface with external systems and access data outside their training data. However, this feature is currently exclusive to OpenAI models, creating vendor lock-in.</p>
<p>That’s where MCP steps in. MCP is a write once, use anywhere approach to the problem. An app developer can write a single MCP server for any AI system to use and expose a set of tools and data. Similarly, an AI system can implement the protocol and connect to any MCP server that exists today or in the future.</p>
<h2 id="heading-what-is-mcp-model-context-protocol">What is MCP (Model Context Protocol)?</h2>
<p>MCP is an open-source standard, developed by Anthropic, for connecting AI applications to external systems.</p>
<p>By using an MCP, AI applications like Claude or ChatGPT can connect to data sources like local files and databases, tools like search engines and calculators, and workflows like specialized prompts—enabling them to access key information and perform tasks.</p>
<p>Think of an MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect electronic devices, an MCP provides a standardized way to connect AI applications to external systems.</p>
<p>The image below will help you to better understand the MCP Server:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764763126029/45a8d0a7-a4f4-47e4-afb9-268930bd1c47.png" alt="structure of model context protocol" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h2 id="heading-architecture-of-mcp">Architecture of MCP</h2>
<p>The Model Context Protocol has a clear structure with components that work together to help LLMs and outside systems interact easily. An MCP follows a simple client-server architecture, which can be broken down into three simple key components:</p>
<h3 id="heading-mcp-host"><strong>MCP Host</strong></h3>
<p>The host is the user-facing AI application, the environment where the AI model lives and interacts with the user. Hosts manage the discovery, permissions, and communication between clients and servers. This ca be a chat application like OpenAI’s ChatGPT interface or Anthropic’s Claude desktop app, or an AI-enhanced IDE like Cursor &amp; Windsurf.</p>
<h3 id="heading-mcp-client"><strong>MCP Client</strong></h3>
<p>The MCP client is a component within the host that handles the low-level communication with the MCP server. MCP clients are instantiated by host applications to communicate with particular MCP servers. Each client handles one direct communication with one server.</p>
<p>Here, the difference is important: the host is the application users interact with, while clients are the components that enable server connections.</p>
<h3 id="heading-mcp-server"><strong>MCP Server</strong></h3>
<p>The MCP server is the external program or service that exposes the capabilities (tools, data, and so on) to the application. An MCP server can be seen as a wrapper around some functionality, which exposes a set of tools or resources in a standardized way so that any MCP client can invoke them.</p>
<p>Servers can run locally on the same machine as the host, or remotely on some cloud service, since an MCP is designed to support both scenarios seamlessly</p>
<p>The image below will help you to better understand the concept:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764841995822/fdec43d4-705e-4385-8eac-b436ec22c386.jpeg" alt="how does mcp work" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>An MCP server can expose one or more capabilities to the client. Capabilities are essentially the features or functions that the server makes available.</p>
<p>The MCP server provides the following capabilities:</p>
<ul>
<li><p><strong>Tools:</strong> Tools are the functions that do something on behalf of the AI model. An AI can use this tool whenever required. Tools are triggered by the AI model’s choice, which means the LLM (via the host) decides to call a tool when it determines it needs to perform a specific task. For example: send_email -&gt; send the email to the user</p>
</li>
<li><p><strong>Resources:</strong> Resources provide read-only data to the AI model. A resource can be a database record or a knowledge base that the AI can query to get information, but can’t modify.</p>
</li>
<li><p><strong>Prompts:</strong> Prompts are the predefined templates or workflows that the server can provide.</p>
</li>
</ul>
<h3 id="heading-transport-layer"><strong>Transport Layer</strong></h3>
<p>The transport layer uses JSON-RPC 2.0 messages to communicate between the client and server. For this, we have mainly two transport methods:</p>
<ul>
<li><p><strong>Standard Input/Output (stdio):</strong> Ideal for local environments, providing fast and synchronous message transmission.</p>
</li>
<li><p><strong>Server-Sent Events (SSE):</strong> Best suited for remote resources, enabling efficient, real-time, one-way data streaming from the server to the client.</p>
</li>
</ul>
<h2 id="heading-how-does-mcp-work">How Does MCP Work?</h2>
<p>An MCP gives an AI assistant the ability to securely use external tools, databases, and services. Imagine you ask Claude:</p>
<blockquote>
<p>“Find the latest sales report in our database and email it to my manager.”</p>
</blockquote>
<h3 id="heading-step-1-tool-discovery"><strong>Step #1 - Tool Discovery</strong></h3>
<p>When we launch any MCP client (Claude Desktop), it connects to your configured MCP servers and asks: “What can I do with available tools?”</p>
<p>Each server responds with its available tools:</p>
<p><code>database_query</code> ,<code>email_sender</code> ,<code>file_browser</code></p>
<p>Now, Claude knows about the tools it has.</p>
<h3 id="heading-step-2-understanding-your-requirement"><strong>Step #2 - Understanding Your Requirement</strong></h3>
<p>Claude reads your query and realizes:</p>
<ul>
<li><p>It needs to retrieve information it doesn’t have (in this case, it has to find the sales data <code>database_query</code>)</p>
</li>
<li><p>It needs to take an external action (send email <code>email_sender</code> )</p>
</li>
</ul>
<p>So Claude plans a 2-step tool sequence.</p>
<h3 id="heading-step-3-ask-for-permission"><strong>Step #3 - Ask for Permission</strong></h3>
<p>Before any external action happens, Claude Desktop prompts you: “Claude wants to query your sales database. Allow?”</p>
<p>Nothing proceeds without your approval. This is core to the MCP’s security model.</p>
<h3 id="heading-step-4-querying-the-database"><strong>Step #4 - Querying the Database</strong></h3>
<p>Once you grant the permission, Claude sends a structured MCP tool call to the <code>database_query</code> server.</p>
<p>Next, the server will run a secure database lookup and return the latest sales report data. This doesn’t give Claude direct access to the database.</p>
<h3 id="heading-step-5-sending-the-email"><strong>Step #5 - Sending the Email</strong></h3>
<p>Once Claude has the data, Claude triggers a second permission prompt: “Claude wants to send an email on your behalf. Approve?”</p>
<p>Once approved, MCP sends the information to the <code>email_sender</code> server, and Claude will format the email &amp; deliver it to your manager</p>
<h3 id="heading-step-6-natural-answer"><strong>Step #6 - Natural Answer</strong></h3>
<p>Claude wraps everything up nicely and sends a response to you, “Done! I found the latest sales report and emailed it to your manager.”</p>
<p>The entire process typically happens in seconds. From your perspective, Claude simply "knows" how to access your database and send emails, but in reality, the MCP has orchestrated a secure, standardized exchange between multiple systems.</p>
<p>The beauty of MCP is that it transforms AI assistants from isolated conversational tools into genuine productivity partners that can interact with your entire digital ecosystem, safely and with your explicit permission every step of the way.</p>
<h2 id="heading-mcp-vs-rag">MCP vs RAG</h2>
<p>Fundamentally, MCP and RAG are built for serving different purposes.</p>
<p>RAG is a technique that is used to supply the relevant knowledge that we have stored in a vector database. In RAG, the user’s query is converted to a vector embedding, which searches through embeddings in the vector database and finds the relevant context based on similarity. This relevant context is then provided to the LLM. It is great for answering questions from large documents like company wikis, knowledge bases, or research papers.</p>
<p>An MCP enables AI models to perform real-world actions with the help of tools. It lets the AI connect to tools and services like databases, APIs, Gmail, calendar, and so on.</p>
<h2 id="heading-mcp-vs-a2a">MCP vs A2A</h2>
<p>The Model Context Protocol (MCP) and the Agent-to-Agent (A2A) protocol are complementary open standards in AI architecture that serve different purposes in how AI agents connect with external systems.</p>
<ul>
<li><p>MCP standardizes how a single AI agent connects to tools, data, and external systems (agent-to-tool communication).</p>
</li>
<li><p>A2A standardizes how multiple, independent AI agents communicate and collaborate with each other (agent-to-agent communication).</p>
</li>
</ul>
<h2 id="heading-resources">Resources</h2>
<p>For more information on the MCP, you can refer to the official website: <a target="_blank" href="http://modelcontextprotocol.io">modelcontextprotocol.io</a>.</p>
<p><strong>Some of the awesome MCP Servers which you can check:</strong></p>
<ul>
<li><p><a target="_blank" href="https://github.com/brave/brave-search-mcp-server">Brave Search MCP Server</a></p>
<ul>
<li>An MCP server implementation that integrates the Brave Search API, providing both web and local search capabilities.</li>
</ul>
</li>
<li><p><a target="_blank" href="https://github.com/getsentry/sentry-mcp">Sentry MCP server</a></p>
<ul>
<li>This server provides tools to inspect error reports, stacktraces, and other debugging information from your Sentry account.</li>
</ul>
</li>
<li><p><a target="_blank" href="https://developers.google.com/maps/ai/mcp">Google Maps MCP Server</a></p>
<ul>
<li>MCP Server for the Google Maps API.</li>
</ul>
</li>
<li><p><a target="_blank" href="https://flyonui.com/mcp">Tailwind MCP Server</a> by FlyonUI</p>
<ul>
<li>MCP Server for FlyoUI - Generate Amazing UIs/Themes/Sections with just a single prompt.</li>
</ul>
</li>
<li><p><a target="_blank" href="https://github.com/idosal/git-mcp">git MCP server</a></p>
<ul>
<li>A Model Context Protocol server for Git repository interaction and automation. This server provides tools to read, search, and manipulate Git repositories via Large Language Models.</li>
</ul>
</li>
<li><p><a target="_blank" href="https://github.com/github/github-mcp-server">GitHub MCP Server</a></p>
<ul>
<li>MCP Server for the GitHub API, enabling file operations, repository management, search functionality, and more.</li>
</ul>
</li>
<li><p><a target="_blank" href="https://shadcnstudio.com/mcp">Shadcn MCP Server</a></p>
<ul>
<li>MCP Server for shadcn/studio - Generate Amazing UIs/Themes/Sections with just a single prompt.</li>
</ul>
</li>
</ul>
<p>You can explore a list of available MCP servers here: <a target="_blank" href="https://github.com/punkpeye/awesome-mcp-servers">https://github.com/punkpeye/awesome-mcp-servers</a></p>
<p>If you're interested in learning how to build your own MCP server, check out this detailed course on Hugging Face: <a target="_blank" href="https://huggingface.co/mcp-course**">https://huggingface.co/mcp-course</a>.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>MCP (Model Context Protocol) is an open-source standard for connecting AI applications to external systems. With MCP, AI models are not just chatbots, they are fully capable agents that can work with your local files, query your database, send emails with your permission and control.</p>
<p>It has also solved the classic MxN problem—developers only need to build the MCP server once, then all other AI systems can integrate the MCP server in their application.</p>
<p>MCP is the revolution in how AI systems can interact with the real world. As the ecosystem of the MCP continues to grow, it will enable AI agents to become more powerful assistants that can operate across diverse environments with reliability and security.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Your First MCP Server using FastMCP ]]>
                </title>
                <description>
                    <![CDATA[ Model Context Protocol, or MCP, is changing how large language models connect with data and tools.  Instead of treating an AI model as a black box, MCP gives it structured access to information and actions.  It is like the USB-C port for AI, creating... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-your-first-mcp-server-using-fastmcp/</link>
                <guid isPermaLink="false">693070aab61ab28f12ea4687</guid>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Wed, 03 Dec 2025 17:17:30 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1764782216715/fc1f7b09-d71d-4917-a249-f95686d23520.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Model Context Protocol, or MCP, is changing how large language models connect with data and tools. </p>
<p>Instead of treating an AI model as a black box, MCP gives it structured access to information and actions. </p>
<p>It is like the USB-C port for AI, creating a standard way for models to interact with servers that hold real-world data or perform useful tasks.</p>
<p><a target="_blank" href="https://gofastmcp.com/getting-started/welcome">FastMCP</a> is the easiest and fastest framework for building MCP servers with Python. It hides all the complex protocol details and lets you focus on your logic. </p>
<p>In this guide, you will learn what MCP is, how FastMCP works, and how to build and run your first MCP server from scratch.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-is-mcp">What is MCP</a>?</p>
</li>
<li><p><a class="post-section-overview" href="#heading-why-use-fastmcp">Why use FastMCP</a>?</p>
</li>
<li><p><a class="post-section-overview" href="#heading-creating-your-first-mcp-server">Creating Your First MCP Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-running-the-server">Running the Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-adding-more-tools">Adding More Tools</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-adding-resources">Adding Resources</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-using-context-in-tools">Using Context in Tools</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-connecting-with-an-mcp-client">Connecting with an MCP Client</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-authentication-and-security">Authentication and Security</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-deploying-your-mcp-server">Deploying Your MCP Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-using-the-mcp-server-with-an-llm-application">Using the MCP Server with an LLM Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-mcp">What is MCP?</h2>
<p>MCP is a standard protocol that allows language models to talk to external systems in a secure and consistent way. <a target="_blank" href="https://www.turingtalks.ai/p/difference-between-mcp-and-api">MCP is similar to an API</a>, but built for large language models instead of humans.</p>
<p>An MCP server can do three main things:</p>
<ul>
<li><p>It can expose data as resources (similar to GET endpoints)</p>
</li>
<li><p>It can provide actions through tools (similar to POST requests)</p>
</li>
<li><p>And it can define prompts that guide how the model interacts with data or users.</p>
</li>
</ul>
<p>For example, a resource might return a list of articles, a tool might analyze those articles, and a prompt might define how the model summarizes them. By connecting an LLM to such an MCP server, you give it the power to use your own data and logic in real time.</p>
<h2 id="heading-why-use-fastmcp">Why Use FastMCP?</h2>
<p>While you could build an MCP server using the <a target="_blank" href="https://modelcontextprotocol.io/">official SDK</a>, FastMCP takes things much further. It’s a production-ready framework with enterprise authentication, client libraries, testing tools, and automatic API generation.</p>
<p>You can use FastMCP to build secure, scalable MCP applications that integrate with providers like Google, GitHub, and Azure. It also supports deployment to the cloud or your own infrastructure. </p>
<p>Most importantly, the framework is extremely developer-friendly. You can create a working MCP server in just a few lines of Python code.</p>
<h2 id="heading-creating-your-first-mcp-server">Creating Your First MCP Server</h2>
<p>Before you start building, install FastMCP in your Python environment. You can use pip or uv. The uv tool is recommended because it handles environments and dependencies efficiently.</p>
<pre><code class="lang-powershell">uv pip install fastmcp
</code></pre>
<p>Once installed, you are ready to write your first server.</p>
<p>Every MCP server starts with the <code>FastMCP</code> class. This class represents your application and manages your tools, resources, and prompts. Let’s start by creating a simple server that adds two numbers together.</p>
<p>Create a file named <code>server.py</code> and add the following code:</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> fastmcp <span class="hljs-keyword">import</span> FastMCP

mcp = FastMCP(<span class="hljs-string">"Demo Server 🚀"</span>)

<span class="hljs-meta">@mcp.tool</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">add</span>(<span class="hljs-params">a: int, b: int</span>) -&gt; int:</span>
    <span class="hljs-string">"""Add two numbers and return the result"""</span>
    <span class="hljs-keyword">return</span> a + b
<span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">"__main__"</span>:
    mcp.run()
</code></pre>
<p>That’s all you need. You have just created a fully working MCP server with one tool called <code>add</code>. When a client calls this tool, the server adds two numbers and returns the result.</p>
<h2 id="heading-running-the-server">Running the Server</h2>
<p>To run your server locally, open your terminal and type:</p>
<pre><code class="lang-powershell">fastmcp run server.py
</code></pre>
<p>This command starts the MCP server. You can also use <a target="_blank" href="https://www.pubnub.com/guides/server-sent-events/">HTTP or SSE</a> transports for web-based deployments. For example, to run your server over HTTP, use:</p>
<pre><code class="lang-python">mcp.run(transport=<span class="hljs-string">"http"</span>, host=<span class="hljs-string">"127.0.0.1"</span>, port=<span class="hljs-number">8000</span>, path=<span class="hljs-string">"/mcp"</span>)
</code></pre>
<p>Once the server is running, clients can connect and call the <code>add</code> tool remotely.</p>
<h2 id="heading-adding-more-tools">Adding More Tools</h2>
<p>FastMCP tools are simple Python functions that you decorate with <code>@mcp.tool</code>. You can add as many as you like. Let’s add a multiplication tool next:</p>
<pre><code class="lang-python"><span class="hljs-meta">@mcp.tool</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">multiply</span>(<span class="hljs-params">a: float, b: float</span>) -&gt; float:</span>
    <span class="hljs-string">"""Multiply two numbers"""</span>
    <span class="hljs-keyword">return</span> a * b
</code></pre>
<p>You can now run the server again, and clients will have access to both the <code>add</code> and <code>multiply</code> tools. </p>
<p>FastMCP automatically generates schemas based on your function signatures and docstrings, making it easy for clients to understand your API.</p>
<h2 id="heading-adding-resources">Adding Resources</h2>
<p>Resources in MCP represent read-only data that clients can access. You can create static resources or dynamic templates that take parameters. For example, you might expose a version number or a user profile.</p>
<pre><code class="lang-python"><span class="hljs-meta">@mcp.resource("config://version")</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_version</span>():</span>
    <span class="hljs-keyword">return</span> <span class="hljs-string">"1.0.0"</span>

<span class="hljs-meta">@mcp.resource("user://{user_id}/profile")</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_profile</span>(<span class="hljs-params">user_id: int</span>):</span>
    <span class="hljs-keyword">return</span> {<span class="hljs-string">"name"</span>: <span class="hljs-string">f"User <span class="hljs-subst">{user_id}</span>"</span>, <span class="hljs-string">"status"</span>: <span class="hljs-string">"active"</span>}
</code></pre>
<p>In this example, the first resource always returns the version number, while the second resource dynamically fetches a user profile based on the ID provided.</p>
<h2 id="heading-using-context-in-tools">Using Context in Tools</h2>
<p>FastMCP allows you to access the session context within any tool, resource, or prompt by including a <code>ctx: Context</code> parameter. The context gives you powerful capabilities like logging, <a target="_blank" href="https://modelcontextprotocol.io/specification/2025-06-18/client/sampling">LLM sampling</a>, progress tracking, and resource access.</p>
<p>Here is an example that shows how to use context:</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> fastmcp <span class="hljs-keyword">import</span> Context

<span class="hljs-meta">@mcp.tool</span>
<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">summarize</span>(<span class="hljs-params">uri: str, ctx: Context</span>):</span>
    <span class="hljs-keyword">await</span> ctx.info(<span class="hljs-string">f"Reading resource from <span class="hljs-subst">{uri}</span>"</span>)
    data = <span class="hljs-keyword">await</span> ctx.read_resource(uri)
    summary = <span class="hljs-keyword">await</span> ctx.sample(<span class="hljs-string">f"Summarize this: <span class="hljs-subst">{data.content[:<span class="hljs-number">500</span>]}</span>"</span>)
    <span class="hljs-keyword">return</span> summary.text
</code></pre>
<p>This tool logs a message, reads a resource, and then asks the client’s language model to summarise it. Context makes your MCP tools smarter and more interactive.</p>
<h2 id="heading-connecting-with-an-mcp-client">Connecting with an MCP Client</h2>
<p>Once your server is running, you can connect to it using the <code>fastmcp.Client</code> class. The client can communicate via STDIO, HTTP, or SSE, and can even run in-memory for testing.</p>
<p>Here is a simple example of connecting to your local server and calling the <code>add</code> tool:</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> fastmcp <span class="hljs-keyword">import</span> Client
<span class="hljs-keyword">import</span> asyncio

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">main</span>():</span>
    <span class="hljs-keyword">async</span> <span class="hljs-keyword">with</span> Client(<span class="hljs-string">"server.py"</span>) <span class="hljs-keyword">as</span> client:
        tools = <span class="hljs-keyword">await</span> client.list_tools()
        print(<span class="hljs-string">"Available tools:"</span>, tools)
        result = <span class="hljs-keyword">await</span> client.call_tool(<span class="hljs-string">"add"</span>, {<span class="hljs-string">"a"</span>: <span class="hljs-number">5</span>, <span class="hljs-string">"b"</span>: <span class="hljs-number">7</span>})
        print(<span class="hljs-string">"Result:"</span>, result.content[<span class="hljs-number">0</span>].text)
asyncio.run(main())
</code></pre>
<p>You can also connect to multiple servers using a standard MCP configuration file, making it easy to build complex systems that interact with several services simultaneously.</p>
<h2 id="heading-authentication-and-security">Authentication and Security</h2>
<p>When you move from development to production, authentication becomes important. </p>
<p>FastMCP has built-in support for enterprise-grade authentication providers such as Google, GitHub, Microsoft Azure, Auth0, and WorkOS. You can enable secure OAuth-based access with just a few lines of code:</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> fastmcp.server.auth.providers.google <span class="hljs-keyword">import</span> GoogleProvider
<span class="hljs-keyword">from</span> fastmcp <span class="hljs-keyword">import</span> FastMCP

auth = GoogleProvider(client_id=<span class="hljs-string">"..."</span>, client_secret=<span class="hljs-string">"..."</span>, base_url=<span class="hljs-string">"https://myserver.com"</span>)
mcp = FastMCP(<span class="hljs-string">"Secure Server"</span>, auth=auth)
</code></pre>
<p>Now only authenticated users can access your server. On the client side, you can connect using an OAuth flow like this:</p>
<pre><code class="lang-python"><span class="hljs-keyword">async</span> <span class="hljs-keyword">with</span> Client(<span class="hljs-string">"https://secure-server.com/mcp"</span>, auth=<span class="hljs-string">"oauth"</span>) <span class="hljs-keyword">as</span> client:
    result = <span class="hljs-keyword">await</span> client.call_tool(<span class="hljs-string">"protected_tool"</span>)
</code></pre>
<p>FastMCP handles tokens, refreshes, and error handling automatically.</p>
<h2 id="heading-deploying-your-mcp-server">Deploying Your MCP Server</h2>
<p>You can deploy FastMCP servers anywhere. </p>
<p>For testing, the <code>fastmcp run</code> command is enough. For production, you can deploy to <a target="_blank" href="https://fastmcp.cloud/">FastMCP Cloud</a>, which provides instant HTTPS endpoints and built-in authentication.</p>
<p>If you prefer to self-host, use the HTTP or SSE transport to serve your MCP endpoints from your own infrastructure. A simple deployment command might look like this:</p>
<pre><code class="lang-python">mcp.run(transport=<span class="hljs-string">"http"</span>, host=<span class="hljs-string">"0.0.0.0"</span>, port=<span class="hljs-number">8080</span>)
</code></pre>
<p>Once deployed, your MCP server is ready to connect with language models, web clients, or automation workflows.</p>
<h2 id="heading-using-the-mcp-server-with-an-llm-application">Using the MCP Server with an LLM Application</h2>
<p>Once your MCP server is running, the next step is to connect it to a large language model. This allows an LLM to securely call your server’s functions, read resources, and perform actions as part of a conversation.</p>
<p>To connect an LLM application, you first define your MCP configuration file. This file lists the available servers, their connection methods, and any authentication requirements. </p>
<p>Once configured, the LLM can automatically discover your MCP tools and call them when needed.</p>
<p>For example, if your server exposes an <code>add</code> or <code>summarize</code> tool, the model can directly use them as if they were built-in capabilities. In a chat-based environment, when a user asks the model to perform a task such as “Summarize the latest article,” the LLM will call your <code>summarize</code> tool, process the result, and respond with the output.</p>
<p>If you are building a custom LLM application with frameworks like OpenAI’s Assistants API or LangChain, you can register your MCP server as an external tool. The LLM then interacts with it through the MCP client library. </p>
<p>Here is a simple example:</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> fastmcp <span class="hljs-keyword">import</span> Client
<span class="hljs-keyword">from</span> openai <span class="hljs-keyword">import</span> OpenAI
<span class="hljs-keyword">import</span> asyncio

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">main</span>():</span>
    <span class="hljs-comment"># Connect to your MCP server</span>
    <span class="hljs-keyword">async</span> <span class="hljs-keyword">with</span> Client(<span class="hljs-string">"http://localhost:8000/mcp"</span>) <span class="hljs-keyword">as</span> client:
        <span class="hljs-comment"># Call an MCP tool directly</span>
        result = <span class="hljs-keyword">await</span> client.call_tool(<span class="hljs-string">"add"</span>, {<span class="hljs-string">"a"</span>: <span class="hljs-number">10</span>, <span class="hljs-string">"b"</span>: <span class="hljs-number">5</span>})
        print(<span class="hljs-string">"MCP Result:"</span>, result.content[<span class="hljs-number">0</span>].text)
        <span class="hljs-comment"># Use the result inside an LLM prompt</span>
        llm = OpenAI(api_key=<span class="hljs-string">"YOUR_KEY"</span>)
        response = llm.chat.completions.create(
            model=<span class="hljs-string">"gpt-4"</span>,
            messages=[
                {<span class="hljs-string">"role"</span>: <span class="hljs-string">"system"</span>, <span class="hljs-string">"content"</span>: <span class="hljs-string">"You are an AI assistant using MCP tools."</span>},
                {<span class="hljs-string">"role"</span>: <span class="hljs-string">"user"</span>, <span class="hljs-string">"content"</span>: <span class="hljs-string">f"The sum of 10 and 5 is <span class="hljs-subst">{result.content[<span class="hljs-number">0</span>].text}</span>. Explain how MCP helps with this integration."</span>}
            ]
        )
        print(response.choices[<span class="hljs-number">0</span>].message.content)

asyncio.run(main())
</code></pre>
<p>In this setup, the LLM can seamlessly combine its reasoning with your server’s logic. It uses the MCP client to fetch data or perform computations and then incorporates the output into its conversation or workflow.</p>
<p>This approach lets you build intelligent systems that go beyond static prompts. You can connect your LLM to real databases, APIs, or automation tools, turning it into an active agent that can read, write, and execute with real-world context.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>FastMCP makes it simple to bring your data, APIs, and tools into the world of AI through the Model Context Protocol. With just a few lines of Python, you can create powerful MCP servers that connect to language models, automate workflows, and handle real-world logic securely.</p>
<p>Whether you are building a quick demo or an enterprise-grade system, FastMCP gives you the shortest path from idea to production. Install it today, start your first server, and explore how MCP can unlock the next level of AI integration.</p>
<p>If you want to learn more about general MCP concepts and how to build an MCP server with Python, I wrote another article about that <a target="_blank" href="https://www.freecodecamp.org/news/how-to-build-your-own-mcp-server-with-python/">which you can check out here</a>.</p>
<p><em>Hope you enjoyed this article. Sign up for my free newsletter</em> <a target="_blank" href="https://www.turingtalks.ai/"><strong><em>TuringTalks.ai</em></strong></a> <em>for more hands-on tutorials on AI. You can also</em> <a target="_blank" href="https://manishshivanandhan.com/"><strong><em>visit my website</em></strong></a><em>.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Your Own MCP Server with Python ]]>
                </title>
                <description>
                    <![CDATA[ Artificial intelligence is evolving at a remarkable pace. Models today can reason, write, code, and analyze information in ways that once seemed impossible. But there’s one major limitation that still holds them back: context. Most AI models don’t ha... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-your-own-mcp-server-with-python/</link>
                <guid isPermaLink="false">69038e6549c53ba349744d5b</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Model Context Protocol ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Thu, 30 Oct 2025 16:12:21 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761774966304/dace2a12-ea92-4c59-980a-5c16fb2d317d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Artificial intelligence is evolving at a remarkable pace. Models today can reason, write, code, and analyze information in ways that once seemed impossible.</p>
<p>But there’s one major limitation that still holds them back: context.</p>
<p>Most AI models don’t have access to your system, files, APIs, or live data. They only know what you tell them in a prompt.</p>
<p>The <a target="_blank" href="https://www.turingtalks.ai/p/how-model-context-protocol-works">Model Context Protocol</a>, also known as MCP, was created to address this problem. It enables AI models to securely connect to your own tools, APIs, and systems via small, structured servers known as MCP servers.</p>
<p>In this guide, you’ll learn how to build your own MCP server using Python. We’ll walk through each part of the code and I’ll explain how it works. </p>
<p>By the end, you’ll have a running MCP server that can add numbers, return random words, and fetch live weather data from the internet. We will also see how to host this MCP server on the cloud. </p>
<h3 id="heading-what-well-cover">What we’ll cover:</h3>
<ul>
<li><p><a class="post-section-overview" href="#heading-understanding-the-model-context-protocol">What is Model Context Protocol</a>?</p>
</li>
<li><p><a class="post-section-overview" href="#heading-setting-up-your-environment">Setting Up Your Environment</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-creating-the-project">Creating the Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-configuring-logging">Configuring Logging</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-creating-the-mcp-server">Creating the MCP Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-defining-tools">Defining Tools</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-example-1-adding-two-numbers">Example 1: Adding Two Numbers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-example-2-returning-a-random-secret-word">Example 2: Returning a Random Secret Word</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-example-3-fetching-weather-data">Example 3: Fetching Weather Data</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-running-the-server">Running the Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-testing-the-tools">Testing the Tools</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-deploying-your-mcp-server-to-sevalla">Deploying Your MCP Server to Sevalla</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-why-build-your-own-mcp-server">Why Build Your Own MCP Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-expanding-the-server">Expanding the Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-model-context-protocol">What is Model Context Protocol?</h2>
<p>Before diving into the code, it’s important to understand what the Model Context Protocol actually is.</p>
<p>MCP is an open standard that defines how AI models and external systems communicate. You can think of it as an API that’s designed specifically for AI assistants.</p>
<p>If an API lets two software programs exchange data, MCP allows an AI model to talk to your system. This opens up endless possibilities.</p>
<p>You could build an MCP server that lets ChatGPT read files from your local machine, or one that calls your company’s internal APIs to fetch data. You could even expose your own Python functions so that a model can use them as tools.</p>
<p>MCP makes this communication structured, secure, and extendable. It runs on familiar web technologies such as <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events">Server-Sent Events</a>, or SSE, which allow the server to send real-time data streams to the client.</p>
<h2 id="heading-setting-up-your-environment">Setting Up Your Environment</h2>
<p>To follow along, you’ll need Python version 3.9 or higher. You can find the code for this example <a target="_blank" href="https://github.com/sevalla-templates/python-demo-mcp-server">in this repository</a>.</p>
<p>We’ll use a library called <a target="_blank" href="https://github.com/jlowin/fastmcp">FastMCP</a> that simplifies the process of building MCP servers. You can install it using pip:</p>
<pre><code class="lang-powershell">pip install fastmcp requests
</code></pre>
<p>The <code>requests</code> library will be used to make HTTP calls later in the example. Once installed, you’re ready to create your first MCP server.</p>
<h2 id="heading-creating-the-project">Creating the Project</h2>
<p>Create a new file called <code>server.py</code> and start by importing the necessary modules:</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> logging
<span class="hljs-keyword">import</span> os
<span class="hljs-keyword">import</span> random
<span class="hljs-keyword">import</span> sys
<span class="hljs-keyword">import</span> requests
<span class="hljs-keyword">from</span> mcp.server.fastmcp <span class="hljs-keyword">import</span> FastMCP
</code></pre>
<p>Here’s what each one does:</p>
<ul>
<li><p>The <code>logging</code> module records what your server is doing.</p>
</li>
<li><p><code>os</code> is used to access environment variables like port numbers.</p>
</li>
<li><p><code>random</code> will help us generate random words.</p>
</li>
<li><p><code>sys</code> allows the script to exit gracefully in case of errors.</p>
</li>
<li><p><code>requests</code> lets us fetch live data from APIs.</p>
</li>
<li><p>And finally, <code>FastMCP</code> turns our Python functions into tools that can be called through the MCP protocol.</p>
</li>
</ul>
<h2 id="heading-configuring-logging">Configuring Logging</h2>
<p>Logging gives you visibility into what your server is doing. It helps during development and is vital when you deploy your server in production.</p>
<pre><code class="lang-python">name = <span class="hljs-string">"demo-mcp-server"</span>
logging.basicConfig(
    level=logging.INFO,
    format=<span class="hljs-string">'%(name)s - %(levelname)s - %(message)s'</span>,
    handlers=[logging.StreamHandler()]
)
logger = logging.getLogger(name)
</code></pre>
<p>This configuration prints log messages to the console in a simple format showing the server name, the log level, and the message. Every time a tool runs, a message will appear in the logs such as:</p>
<pre><code class="lang-powershell">demo<span class="hljs-literal">-mcp</span><span class="hljs-literal">-server</span> - INFO - Tool called: add(<span class="hljs-number">3</span>, <span class="hljs-number">5</span>)
</code></pre>
<h2 id="heading-creating-the-mcp-server">Creating the MCP Server</h2>
<p>Next, we’ll create the server instance that will host our tools.</p>
<pre><code class="lang-python">port = int(os.environ.get(<span class="hljs-string">'PORT'</span>, <span class="hljs-number">8080</span>))
mcp = FastMCP(name, logger=logger, port=port)
</code></pre>
<p>The server will run on the port specified by the environment variable <code>PORT</code>. If that variable isn’t set, it defaults to 8080. The <code>FastMCP</code> object now represents your running MCP server.</p>
<h2 id="heading-defining-tools">Defining Tools</h2>
<p>Each function that you decorate with <code>@mcp.tool()</code> becomes an accessible tool that clients can call. Let’s start with a simple example: an addition tool.</p>
<h3 id="heading-example-1-adding-two-numbers"><strong>Example 1: Adding Two Numbers</strong></h3>
<pre><code class="lang-python"><span class="hljs-meta">@mcp.tool()</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">add</span>(<span class="hljs-params">a: int, b: int</span>) -&gt; int:</span>
    <span class="hljs-string">"""Add two numbers"""</span>
    logger.info(<span class="hljs-string">f"Tool called: add(<span class="hljs-subst">{a}</span>, <span class="hljs-subst">{b}</span>)"</span>)
    <span class="hljs-keyword">return</span> a + b
</code></pre>
<p>This tool takes two numbers, logs the call, and returns their sum. Calling <code>add(3, 5)</code> will return 8.</p>
<p>Even though it’s simple, this shows the basic structure of every MCP tool: input parameters, a logging statement, and a return value.</p>
<h3 id="heading-example-2-returning-a-random-secret-word"><strong>Example 2: Returning a Random Secret Word</strong></h3>
<p>Let’s make another tool that returns a random word from a small list.</p>
<pre><code class="lang-python"><span class="hljs-meta">@mcp.tool()</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_secret_word</span>() -&gt; str:</span>
    <span class="hljs-string">"""Get a random secret word"""</span>
    logger.info(<span class="hljs-string">"Tool called: get_secret_word()"</span>)
    <span class="hljs-keyword">return</span> random.choice([<span class="hljs-string">"apple"</span>, <span class="hljs-string">"banana"</span>, <span class="hljs-string">"cherry"</span>])
</code></pre>
<p>When you call this function, it picks one of the three words at random. Each time you call it, you might get a different result. This function demonstrates how MCP tools can use logic or randomness just like any regular Python function.</p>
<h3 id="heading-example-3-fetching-weather-data">Example 3: Fetching Weather Data</h3>
<p>Now let’s build something more practical. We’ll create a tool that fetches live weather data from the web using the <code>requests</code> library.</p>
<pre><code class="lang-python"><span class="hljs-meta">@mcp.tool()</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_current_weather</span>(<span class="hljs-params">city: str</span>) -&gt; str:</span>
    <span class="hljs-string">"""Get current weather for a city"""</span>
    logger.info(<span class="hljs-string">f"Tool called: get_current_weather(<span class="hljs-subst">{city}</span>)"</span>)

<span class="hljs-keyword">try</span>:
        endpoint = <span class="hljs-string">"https://wttr.in"</span>
        response = requests.get(<span class="hljs-string">f"<span class="hljs-subst">{endpoint}</span>/<span class="hljs-subst">{city}</span>"</span>, timeout=<span class="hljs-number">10</span>)
        response.raise_for_status()
        <span class="hljs-keyword">return</span> response.text
    <span class="hljs-keyword">except</span> requests.RequestException <span class="hljs-keyword">as</span> e:
        logger.error(<span class="hljs-string">f"Error fetching weather data: <span class="hljs-subst">{str(e)}</span>"</span>)
        <span class="hljs-keyword">return</span> <span class="hljs-string">f"Error fetching weather data: <span class="hljs-subst">{str(e)}</span>"</span>
</code></pre>
<p>This tool accepts a city name, sends a request to the public weather service at <code>wttr.in</code>, and returns the text-based weather report. If there’s any issue, such as a network timeout or invalid city name, the function logs an error and returns a descriptive message.</p>
<p>Calling <code>get_current_weather("London")</code> will print a short weather summary for that city.</p>
<h2 id="heading-running-the-server">Running the Server</h2>
<p>Once all your tools are defined, you can start the server. Add the following code to the bottom of your file:</p>
<pre><code class="lang-python"><span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">"__main__"</span>:
    logger.info(<span class="hljs-string">f"Starting MCP Server on port <span class="hljs-subst">{port}</span>..."</span>)
    <span class="hljs-keyword">try</span>:
        mcp.run(transport=<span class="hljs-string">"sse"</span>)
    <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
        logger.error(<span class="hljs-string">f"Server error: <span class="hljs-subst">{str(e)}</span>"</span>)
        sys.exit(<span class="hljs-number">1</span>)
    <span class="hljs-keyword">finally</span>:
        logger.info(<span class="hljs-string">"Server terminated"</span>)
</code></pre>
<p>This block starts the server using the Server-Sent Events transport method. If anything goes wrong, it logs the error and shuts down cleanly.</p>
<p>You can now run the server from your terminal:</p>
<pre><code class="lang-powershell">python server.py
</code></pre>
<p>If everything is working, you’ll see:</p>
<pre><code class="lang-powershell">demo<span class="hljs-literal">-mcp</span><span class="hljs-literal">-server</span> - INFO - Starting MCP Server on port <span class="hljs-number">8080</span>...
</code></pre>
<p>Your MCP server is now live and ready to accept requests.</p>
<h2 id="heading-testing-the-tools">Testing the Tools</h2>
<p>To test your tools, you need an MCP-compatible client such as ChatGPT with developer features or another app that supports the protocol. Once connected, the client will list your available tools.</p>
<p>For example, you can send a request like this:</p>
<pre><code class="lang-powershell">{
  <span class="hljs-string">"tool"</span>: <span class="hljs-string">"add"</span>,
  <span class="hljs-string">"args"</span>: [<span class="hljs-number">5</span>, <span class="hljs-number">7</span>]
}
</code></pre>
<p>The server will respond with:</p>
<pre><code class="lang-powershell">{
  <span class="hljs-string">"result"</span>: <span class="hljs-number">12</span>
}
</code></pre>
<p>The same applies to the other tools such as <code>get_secret_word</code> or <code>get_current_weather</code>.</p>
<p>If you want to test the server directly without the MCP client, you can still send HTTP requests manually (though this bypasses the full protocol logic).</p>
<p>For example, to test your weather tool, you can send a simple GET request:</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">curl</span> http://localhost:<span class="hljs-number">8080</span>/tool/get_current_weather?city=London
</code></pre>
<p>or in Python:</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> requests
response = requests.get(<span class="hljs-string">"http://localhost:8080/tool/get_current_weather"</span>, params={<span class="hljs-string">"city"</span>: <span class="hljs-string">"London"</span>})
print(response.text)
</code></pre>
<p>This won’t use the MCP structure (like <code>sse</code> streaming), but it’s a quick sanity check that your server works.</p>
<h2 id="heading-deploying-your-mcp-server-to-sevalla">Deploying Your MCP Server to Sevalla</h2>
<p>You can run this server locally for development. But if you want to use it in production applications, you have to deploy it to a server.</p>
<p>You can choose any cloud provider, like AWS, Heroku, or others to set up this project. But I will be using Sevalla.</p>
<p><a target="_blank" href="https://sevalla.com/">Sevalla</a> is a modern, usage-based Platform-as-a-service provider. It offers application hosting, database, object storage, and static site hosting for your projects.</p>
<p>I am using Sevalla for hosting for two reasons:</p>
<ul>
<li><p>Every platform will charge you for creating a cloud resource. Sevalla comes with a $50 credit for us to use, so we won’t incur any costs for this example.</p>
</li>
<li><p>Sevalla has a <a target="_blank" href="https://docs.sevalla.com/templates/overview">template for Python MCP server</a>, so it simplifies the manual installation and setup for each resource you will need for installation.</p>
</li>
</ul>
<p><a target="_blank" href="https://app.sevalla.com/login">Log in</a> to Sevalla and click on Templates. You can see Python MCP Server as one of the templates.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761652364887/5003918a-f19a-42bf-94ad-306a3f6ab93c.png" alt="Sevalla Templates" class="image--center mx-auto" width="1000" height="340" loading="lazy"></p>
<p>Click on the “Python MCP Server” template. You will see the resources needed to provision the application. Click on “Deploy Template”. </p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761652387263/871bf43f-214a-49c4-9734-7f71d0e5ce32.png" alt="Python MCP Server Resources" class="image--center mx-auto" width="1000" height="428" loading="lazy"></p>
<p>You can see the resource being provisioned. If the deployment doesn't start automatically, click “Deploy now”. </p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761652411264/3e5a71c0-71c1-4cf9-92c2-e01fa77b7f45.png" alt="Python MCP Server Resources Provisioning" class="image--center mx-auto" width="1000" height="513" loading="lazy"></p>
<p>Wait for a few minutes. Once the deployment is complete, you will see a green checkmark. </p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761652436109/68303890-91a4-4c8e-90d1-f6c142c571a6.png" alt="Python MCP Server Deployment" class="image--center mx-auto" width="1000" height="433" loading="lazy"></p>
<p>Once deployment is complete, click on “Visit app”. You will get a cloud url eg. <a target="_blank" href="https://python-mcp-server-rlfdk.sevalla.app/">https://python-mcp-server-rlfdk.sevalla.app</a>. Use this as the base url instead of the localhost:3000 url. </p>
<p>You now have a production-grade MCP server running on the cloud. You can plug this into any application to fetch data for our LLM applications. </p>
<h2 id="heading-why-build-your-own-mcp-server">Why Build Your Own MCP Server?</h2>
<p>Building an MCP server gives you control and flexibility. </p>
<p>You can connect AI models directly to your databases or internal systems, automate repetitive actions, and decide exactly what data an AI model can access. </p>
<p>It also allows you to experiment quickly. You can start small with a few simple tools and expand later into complex workflows.</p>
<p>By creating your own MCP server, you’re not just writing code – you’re defining how intelligent systems interact with the real world through your logic and data.</p>
<h2 id="heading-expanding-the-server">Expanding the Server</h2>
<p>Once you’ve mastered the basics, it’s easy to extend your server. You can add tools that read and write files, query databases, interact with APIs like GitHub or Slack, or monitor your system. Each new function becomes another tool that your AI can use.</p>
<p>This modular approach lets you build an entire ecosystem of AI-aware tools, each performing a specific task but working together through the same MCP interface.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you learned how to create an MCP server in Python using the FastMCP library. You configured logging, set up a server, defined multiple tools, and learned how to run and test it. You also saw how easily these tools can expose real functionality, like fetching live weather data or performing basic computations.</p>
<p>This structure is simple yet powerful. With just a few lines of Python code, you can build bridges between your systems and intelligent models. The Model Context Protocol represents a step toward AI systems that can truly understand and interact with real-world data and actions.</p>
<p><em>Hope you enjoyed this article. Signup for my free newsletter</em> <a target="_blank" href="https://www.turingtalks.ai/"><strong><em>TuringTalks.ai</em></strong></a> <em>for more hands-on tutorials on AI. You can also</em> <a target="_blank" href="https://manishshivanandhan.com/"><strong><em>visit my website</em></strong></a><em>.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ MCP vs APIs: What's the Real Difference? ]]>
                </title>
                <description>
                    <![CDATA[ APIs and MCPs both help systems talk to each other.  At first, they might look the same. Both allow one piece of software to ask another for data or perform an action. But the way they work and the reason they exist are completely different. An API, ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/mcp-vs-apis-whats-the-real-difference/</link>
                <guid isPermaLink="false">69028c7be37f0cfba4d00775</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ APIs ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Wed, 29 Oct 2025 21:51:55 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761774679622/477d1991-a083-4ae6-8e3b-2a186d254274.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>APIs and MCPs both help systems talk to each other. </p>
<p>At first, they might look the same. Both allow one piece of software to ask another for data or perform an action. But the way they work and the reason they exist are completely different.</p>
<p>An API, or <a target="_blank" href="https://www.ibm.com/think/topics/api">Application Programming Interface</a>, is built for developers. It’s how one program communicates with another. MCP, or <a target="_blank" href="https://modelcontextprotocol.io/">Model Context Protocol</a>, is built for AI models. It’s how a large language model like GPT or Claude can safely talk to external systems and use tools.</p>
<p>Let’s look at what makes them different, why MCP exists when APIs already do the job, and how they work in real examples.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-is-an-api">What is an API</a>?</p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-mcp">What is MCP</a>?</p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-mcp-works">How MCP Works</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-why-not-just-use-an-api">Why Not Just Use an API</a>?</p>
</li>
<li><p><a class="post-section-overview" href="#heading-mcp-vs-api-in-practice">MCP vs API in Practice</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-key-conceptual-difference">Key Conceptual Difference</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-discovery-and-schema">Discovery and Schema</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-security-and-privacy">Security and Privacy</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-the-future-of-mcp">The Future of MCP</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-an-api">What is an API?</h2>
<p>An API is a set of rules that lets software talk to software. </p>
<p>It is like a waiter in a restaurant. You tell the waiter what you want, the kitchen prepares it, and the waiter brings it back. You never go into the kitchen yourself.</p>
<p>For example, if you want to get details of a GitHub user, you can make a simple API request.</p>
<pre><code class="lang-plaintext">GET https://api.github.com/users/username
</code></pre>
<p>The server replies with a response like this:</p>
<pre><code class="lang-plaintext">{
  "login": "john",
  "id": 12345,
  "followers": 120,
  "repos": 42
}
</code></pre>
<p>The API follows a pattern that both the client and the server understand. Developers use APIs every day to connect systems like payment gateways, weather data, or user accounts.</p>
<p>APIs are built for humans to code against. A developer writes the logic, sends requests, handles errors, adds authentication, and decides what to do with the response.</p>
<h2 id="heading-what-is-mcp">What is MCP?</h2>
<p>MCP stands for <a target="_blank" href="https://www.turingtalks.ai/p/how-model-context-protocol-works">Model Context Protocol</a>. It’s a new standard that allows AI models to interact with external tools, data, and systems in a safe, structured way.</p>
<p>MCP is not meant for developers directly. It’s meant for large language models. </p>
<p>An AI model cannot make network requests by itself. It doesn’t know how to use headers, tokens, or API formats. It just predicts text based on what you type.</p>
<p>So if you tell a model, “Get the weather for Delhi,” it might generate some text that looks like a Python request. But it cannot actually run that code.</p>
<p>That is where MCP comes in. MCP acts like a bridge between the AI model and the real world. It defines a set of “tools” that the model can use safely. </p>
<p>Each tool is described using a schema so that the model knows what the tool does, what inputs it takes, and what it returns.</p>
<h2 id="heading-how-mcp-works">How MCP Works</h2>
<p>You can think of MCP as a server that runs in the background. It exposes tools that an AI model can call. Each tool is a small piece of code that performs an action.</p>
<p>For example, you can write a simple MCP server in Python like this:</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> mcp.server.fastmcp <span class="hljs-keyword">import</span> FastMCP
<span class="hljs-keyword">import</span> requests

mcp = FastMCP(name=<span class="hljs-string">"github-tools"</span>)
<span class="hljs-meta">@mcp.tool()</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_repos</span>(<span class="hljs-params">username: str</span>):</span>
    <span class="hljs-string">"""Fetch public repositories for a user"""</span>
    url = <span class="hljs-string">f"https://api.github.com/users/<span class="hljs-subst">{username}</span>/repos"</span>
    <span class="hljs-keyword">return</span> requests.get(url).json()
mcp.run()
</code></pre>
<p>This server defines a single tool called get_repos. It takes a username and fetches their GitHub repositories using the GitHub API.</p>
<p>Now, if an AI model is connected to this MCP server, it can ask for “get_repos for user john” and receive the data. The model does not know or care about the actual URL, headers, or tokens. The MCP server handles that part.</p>
<h2 id="heading-why-not-just-use-an-api">Why Not Just Use an API?</h2>
<p>You might wonder, why not just let the AI model call the API directly? If the model can talk to APIs, why add another layer?</p>
<p>The short answer is that AI models cannot safely call APIs on their own. They have no built-in execution environment, no way to store secrets, and no limits. </p>
<p>Letting a model make arbitrary network requests would be dangerous. It could expose keys, access private data, or even cause damage by mistake.</p>
<p>MCP solves that problem by creating a controlled layer between the model and your systems. You decide which tools the model can use. You can restrict inputs, filter outputs, and monitor everything the model does.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761561683902/6b0f2299-041e-4ef4-a6ce-726899c52fbf.png" alt="MCP Architecture" class="image--center mx-auto" width="1000" height="407" loading="lazy"></p>
<p>In an MCP setup, the model never sees API keys or sensitive URLs. It just calls a tool that you define. The tool itself handles the network call and returns only the safe data.</p>
<p>This makes MCP much safer for real-world use, especially in enterprise or private environments.</p>
<h2 id="heading-mcp-vs-api-in-practice">MCP vs API in Practice</h2>
<p>Let’s take a simple example. Suppose you want an AI to fetch weather data.</p>
<p>If you were using an API, you might write code like this:</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> requests
response = requests.get(<span class="hljs-string">"https://api.weatherapi.com/v1/current.json?key=API_KEY&amp;q=Delhi"</span>)
print(response.json())
</code></pre>
<p>That works fine if a human developer runs it. But if an AI model tried to do the same, it would need access to your API key, network, and code execution. That is unsafe.</p>
<p>With MCP, you can define a tool like this:</p>
<pre><code class="lang-python"><span class="hljs-meta">@mcp.tool()</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_weather</span>(<span class="hljs-params">city: str</span>):</span>
    <span class="hljs-string">"""Get weather for a city"""</span>
    <span class="hljs-keyword">import</span> requests
    url = <span class="hljs-string">f"https://api.weatherapi.com/v1/current.json?key=API_KEY&amp;q=<span class="hljs-subst">{city}</span>"</span>
    <span class="hljs-keyword">return</span> requests.get(url).json()
</code></pre>
<p>Now the AI model can simply say, “Call get_weather with city=Delhi,” and the MCP server runs the function.</p>
<p>The model does not see the API key or the actual URL. It just uses the tool safely.</p>
<h2 id="heading-key-conceptual-difference">Key Conceptual Difference</h2>
<p>The difference between MCP and API is not just technical. It’s also philosophical.</p>
<p>APIs are for humans to use directly. They assume the caller understands the system, can handle tokens, and knows how to format requests.</p>
<p>MCP is for AI models. It assumes the caller is an intelligent but untrusted system that cannot hold secrets or execute code. The protocol gives the model only what it needs to perform reasoning and tool usage.</p>
<p>So while APIs expose endpoints like <code>/users</code> or <code>/weather</code>, MCP exposes capabilities like “get_user_info” or “get_weather.” The AI model does not call URLs. It calls functions with typed parameters.</p>
<h2 id="heading-discovery-and-schema">Discovery and Schema</h2>
<p>Another big advantage of MCP is that it can tell the model what tools are available.</p>
<p>When an AI model connects to an MCP server, it can ask for a list of tools. The server replies with their names, descriptions, and parameters in a structured format.</p>
<p>For example, the model might receive something like this:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"tools"</span>: [
    {
      <span class="hljs-attr">"name"</span>: <span class="hljs-string">"get_weather"</span>,
      <span class="hljs-attr">"description"</span>: <span class="hljs-string">"Get weather for a city"</span>,
      <span class="hljs-attr">"parameters"</span>: {
        <span class="hljs-attr">"city"</span>: {<span class="hljs-attr">"type"</span>: <span class="hljs-string">"string"</span>}
      }
    }
  ]
}
</code></pre>
<p>This means the model does not need separate documentation or prompt tuning. It knows exactly how to call each tool.</p>
<p>In contrast, an API would require reading human-written docs, copying sample requests, and guessing formats.</p>
<h2 id="heading-security-and-privacy">Security and Privacy</h2>
<p>MCP provides better control over what the model can do.</p>
<p>Since the tools are defined in your server, you can add rules, limits, and validations. You can prevent the model from sending dangerous inputs or accessing private data.</p>
<p>For example, your tool can reject requests that ask for too much data or contain suspicious patterns. You can also log every call for audit purposes.</p>
<p>APIs, on the other hand, are exposed over the internet. If an API key leaks or a model calls the wrong endpoint, you could face a data breach.</p>
<p>With MCP, everything can run locally, behind a firewall, or on a private network. The model never needs direct access to the outside world.</p>
<h2 id="heading-the-future-of-mcp">The Future of MCP</h2>
<p>Big AI companies like OpenAI and Anthropic are adopting MCP as a shared standard. That means any model that supports MCP can use your tools without modification.</p>
<p>If you build a weather MCP server today, it could work with GPT, Claude, or any other MCP-compatible model in the future.</p>
<p>This makes MCP a unifying layer between AI systems and external tools, much like APIs are for web applications.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>At a glance, MCP and APIs might seem similar because both pass data between systems. But the difference lies in for whom they are built.</p>
<p>APIs are built for developers and systems that can safely make network calls. MCP is built for AI models that reason with text but cannot safely execute code.</p>
<p>An API gives you endpoints to access data. MCP gives the AI tools to use that data safely.</p>
<p>Think of it this way. APIs connect machines. MCP connects intelligence to machines.</p>
<p>That is why MCP is not replacing APIs but sitting above them as a new layer. APIs will still provide the data. MCP will just make it possible for AI to use those APIs safely, with structure, control, and understanding.</p>
<p><em>Hope you enjoyed this article. Signup for my free AI newsletter</em> <a target="_blank" href="https://www.turingtalks.ai/"><strong><em>TuringTalks.ai</em></strong></a> <em>for more hands-on tutorials on AI. You can also find</em> <a target="_blank" href="https://manishshivanandhan.com/"><strong><em>visit my website</em></strong></a><em>.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How the Model Context Protocol Works ]]>
                </title>
                <description>
                    <![CDATA[ The world of artificial intelligence is moving fast. Every week, it seems like there’s a new tool, framework, or model that promises to make AI better. But as developers build more AI applications, one big problem keeps showing up: the lack of contex... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-the-model-context-protocol-works/</link>
                <guid isPermaLink="false">68fbd8268c0c93a0a754d14e</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #ai-tools ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Fri, 24 Oct 2025 19:48:54 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761335265026/4e06906c-3f4b-4f88-b49d-8bc58f984e55.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The world of artificial intelligence is moving fast. Every week, it seems like there’s a new tool, framework, or model that promises to make AI better.</p>
<p>But as developers build more AI applications, one big problem keeps showing up: the lack of context.</p>
<p>Each tool works on its own. Each model has its own memory, its own data, and its own way of understanding the world. This makes it hard for different parts of an AI system to talk to each other.</p>
<p>That’s where Model Context Protocol, or MCP, comes in.</p>
<p>It is a new standard for how AI tools share context and communicate. It allows large language models and <a target="_blank" href="https://www.turingtalks.ai/p/how-an-ai-agent-works">AI agents</a> to connect with external data sources, apps, and tools in a structured way.</p>
<p>MCP is like the missing piece that helps AI systems work together instead of apart.</p>
<p>MCP is becoming one of the most important ideas in modern AI development. In this article, you’ll learn how the MCP connects AI tools and data sources, making modern AI apps smarter, faster, and far easier to build.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-the-problem-with-disconnected-ai-tools">The Problem with Disconnected AI Tools</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-model-context-protocol">What is Model Context Protocol</a>?</p>
</li>
<li><p><a class="post-section-overview" href="#heading-from-plugins-to-protocols">From Plugins to Protocols</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-making-ai-apps-smarter">Making AI Apps Smarter</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-making-ai-apps-faster-and-simpler">Making AI Apps Faster (and Simpler)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-the-bigger-picture">The Bigger Picture</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-the-problem-with-disconnected-ai-tools"><strong>The Problem with Disconnected AI Tools</strong></h2>
<p>Imagine you’re building a customer support chatbot using a large language model like GPT. The model can generate great responses, but it doesn’t know anything about your actual customers.</p>
<p>To make it useful, you connect it to your CRM so it can look up customer records. Then you connect it to your ticketing system to see open cases. You might also connect it to a knowledge base for reference.</p>
<p>Each of these integrations is a separate task. You write custom API calls, format responses, manage authentication, and handle errors. Every new data source means more glue code. The LLM doesn’t naturally know how to interact with these systems.</p>
<p>Now imagine you have five or ten such tools like your AI assistant, your search engine, your summarization tool, and a few automation scripts. Each one stores information in a different way.</p>
<p>None of them share context. If one model learns something about a user’s intent, the others can’t use it. You end up with silos of intelligence instead of a connected ecosystem.</p>
<p>This is the problem that MCP was built to solve.</p>
<h2 id="heading-what-is-model-context-protocol"><strong>What is Model Context Protocol?</strong></h2>
<p>Model Context Protocol is a standard that defines how AI systems should exchange context. It was introduced to make it easier for models, tools, and environments to communicate in a predictable way. You can think of it as an “API for AI context.”</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761218383566/cd96896e-b41e-4ac4-b2fe-61cdcb69a128.png" alt="Diagram showing how things work without MCP and with MCP" class="image--center mx-auto" width="787" height="420" loading="lazy"></p>
<p>At its core, MCP allows three types of communication:</p>
<ol>
<li><p>Models can request context from external tools or data sources.</p>
</li>
<li><p>Tools can send updates or new information back to the model.</p>
</li>
<li><p>Both can share metadata about what they know and how they can help.</p>
</li>
</ol>
<p>This sounds technical, but the outcome is simple. It makes AI apps more aware of their environment.</p>
<p>Instead of manually wiring integrations, developers can rely on a shared protocol that defines how everything fits together.</p>
<h2 id="heading-from-plugins-to-protocols"><strong>From Plugins to Protocols</strong></h2>
<p>To understand MCP, it helps to look back at how OpenAI handled this problem before.</p>
<p>When <a target="_blank" href="https://openai.com/index/chatgpt-plugins/">ChatGPT Plugins</a> were introduced, they allowed GPT models to access external APIs, for example, to book a flight, get weather updates, or search the web. Each plugin had its own schema that described what data it could handle and what actions it could perform.</p>
<p>MCP takes that idea further. Instead of plugins designed only for ChatGPT, MCP defines a universal language that any AI system can use. It’s like moving from private integrations to an open standard.</p>
<p>If you’ve ever worked with APIs, you can think of MCP as doing for AI what HTTP did for the web. HTTP allowed browsers and servers to communicate using shared rules. MCP allows models and tools to share context consistently.</p>
<p>Below is a pseudocode example showing how you might build a Model Context Protocol (MCP) server that exposes a SQL database as a context source to AI models.</p>
<p>This is conceptual pseudocode. It captures the flow, not specific syntax, and assumes an MCP-compatible environment where LLMs can request data from external tools via a standard interface.</p>
<p>The goals is to expose your SQL database (for example, a <code>customers</code> or <code>orders</code> table) through an MCP server so an AI model can query and understand its contents contextually. For example, you could say “Show me all pending orders.”</p>
<pre><code class="lang-plaintext">// MCP SQL Context Server Pseudocode
---

// Step 1: Initialize server and dependencies
MCPServer = new MCPServer(name="SQLContextServer")

Database = connect_to_sql(
    host="localhost",
    user="admin",
    password="password",
    database="ecommerce"
)

// Step 2: Define available context schemas
// These describe what data the server can provide
MCPServer.register_context_schema("orders", {
    "order_id": "integer",
    "customer_name": "string",
    "status": "string",
    "amount": "float",
    "created_at": "datetime"
})

// Step 3: Define request handler for context queries
MCPServer.on_context_request("orders", function(queryParams):
    sql_query = build_sql_query(
        table="orders",
        filters=queryParams.filters,
        limit=queryParams.limit or 50
    )
    results = Database.execute(sql_query)
    return MCPResponse(data=results)
)

// Step 4: Define actions (optional)
// Allows the model to perform updates, inserts, etc.
MCPServer.register_action("update_order_status", {
    "order_id": "integer",
    "new_status": "string"
}, function(args):
    Database.execute("UPDATE orders SET status = ? WHERE order_id = ?", 
                     [args.new_status, args.order_id])
    return MCPResponse(message="Order updated successfully")
)

// Step 5: Start the MCP server and listen for model requests
MCPServer.start(port=8080)
log("MCP SQL Context Server is running on port 8080")

// Example of how a model might call this server:
//
// Model -&gt; MCPServer:
//   RequestContext("orders", filters={"status": "pending"})
//
// MCPServer -&gt; Model:
//   [{"order_id": 42, "customer_name": "John Doe", "status": "pending", "amount": 199.99}]
</code></pre>
<p>How it works:</p>
<ol>
<li><p>The model sends a request via MCP, asking for context like <code>orders where status = 'pending'</code>.</p>
</li>
<li><p>The server translates this into a SQL query, fetches the data, and returns it as structured context.</p>
</li>
<li><p>The model now uses this context to give accurate answers, automate workflows, or make decisions (like “Send a refund email to pending orders older than 5 days”).</p>
</li>
<li><p>Optional MCP actions let the model perform safe updates, enabling bi-directional workflows (context in, actions out).</p>
</li>
</ol>
<h2 id="heading-making-ai-apps-smarter"><strong>Making AI Apps Smarter</strong></h2>
<p>Smartness in AI doesn’t only come from the size of the model. It also comes from how much relevant context the model has.</p>
<p>A small model with rich context can outperform a large one that’s unaware of its surroundings. With MCP, a model can access the right context at the right time.</p>
<p>For example, let’s say a customer support bot gets a message saying,</p>
<blockquote>
<p><strong><em>“I’m still waiting for my refund.”</em></strong></p>
</blockquote>
<p>Normally, the model might respond with a generic apology. But with MCP, it can pull the customer’s order history from a connected tool, check refund status, and reply with something like,</p>
<blockquote>
<p><em>“<strong><strong>Your refund for Order #1423 has been processed and should reach your account by Tuesday.</strong></strong>”</em></p>
</blockquote>
<p>This is possible because MCP lets the model request information from external sources using structured calls. It no longer works blindly. It works with context, making the response more relevant and accurate.</p>
<p>As more tools adopt MCP, models will become context-aware across multiple domains, from finance and healthcare to software development and education.</p>
<h2 id="heading-making-ai-apps-faster-and-simpler"><strong>Making AI Apps Faster (and Simpler)</strong></h2>
<p>Speed in AI applications isn’t just about how quickly a model generates text. True speed comes from how efficiently the system gathers, processes, and applies information.</p>
<p>Without MCP, AI systems waste time doing repetitive work like fetching data from different sources, cleaning it, and converting it into compatible formats.</p>
<p>Every new integration adds latency. Developers often build caching layers, write adapters, or batch process data just to make things run smoothly. All of this adds complexity and slows down development.</p>
<p>MCP removes much of this overhead. Because it defines a shared structure for context, models and tools can exchange data seamlessly. There’s no need to translate or reformat information, since everything speaks the same language. The result is lower latency, faster responses, and cleaner architecture.</p>
<p>Consider an example: you’re building an AI coding assistant. Without MCP, you’d need to manually connect to your file system, your Git repository, and your IDE, each requiring a different integration.</p>
<p>With MCP, all three can communicate through a single shared protocol. The assistant instantly understands where your code lives, what files have changed, and what actions it can perform.</p>
<p>This simplicity benefits not just developers but also users. With MCP, your context, your preferences, recent work, and open projects, can travel with you across different apps. It’s like having a portable memory layer for the AI world, one that keeps every tool aware of what you’re doing no matter where you go.</p>
<h2 id="heading-the-bigger-picture"><strong>The Bigger Picture</strong></h2>
<p>The rise of MCP points to a shift in how we think about AI systems. We’re moving from isolated models to connected ecosystems.</p>
<p>In the early days of the web, each site was its own island. Then came standards like HTTP and HTML, which made everything interoperable. That’s when the web truly exploded.</p>
<p>AI is at a similar point. Right now, every company is building its own stack, its own integrations, prompts, and memory systems. But that approach doesn’t scale. MCP could be the layer that connects them all.</p>
<p>Once context becomes shareable and portable, AI apps can collaborate in new ways. A writing assistant could talk to your research tool. A design bot could work with your file system. A coding assistant could coordinate with your deployment manager.</p>
<p>This kind of shared intelligence is what makes AI truly useful. It’s no longer about one model doing everything. It’s about many specialized models working together seamlessly.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>MCP is still new, but the idea behind it is powerful. By creating a shared protocol for context, it lowers the barrier for innovation.</p>
<p>Developers can focus on what their AI does, not how it connects. Companies can build products that play well with others instead of locking users into closed systems.</p>
<p>In the long run, this could lead to an open AI ecosystem, where models, tools, and data sources interact freely, much like websites do today. You could mix and match capabilities without friction.</p>
<p>The goal is not just smarter AI, but simpler AI. AI that understands what’s happening around it, reacts in real time, and works naturally with the tools you already use.</p>
<p>Model Context Protocol is a big step toward that future. It’s the bridge between intelligence and context, and it’s what will make tomorrow’s AI systems faster, more reliable, and far more human in how they understand the world.</p>
<p><em>Hope you enjoyed this article. Signup for my free AI newsletter</em> <a target="_blank" href="https://www.turingtalks.ai/"><strong><em>TuringTalks.ai</em></strong></a> <em>for more hands-on tutorials on AI. You can also find</em> <a target="_blank" href="https://manishshivanandhan.com/"><strong><em>visit my website</em></strong></a><em>.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a To-Do List MCP Server Using TypeScript – with Auth, Database, and Billing ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, you’ll build a To-Do list MCP server using TypeScript. You’ll learn how to implement authentication, persistence, and billing, to make the server robust and functional for real users. By the end, you’ll have a working MCP server tha... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-to-do-list-mcp-server-using-typescript/</link>
                <guid isPermaLink="false">68f93792b7f64a597dc407f9</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp server ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Wed, 22 Oct 2025 19:59:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761162036666/77972b3f-9dc8-404f-b40d-fb70ee73e2a5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, you’ll build a To-Do list MCP server using TypeScript. You’ll learn how to implement authentication, persistence, and billing, to make the server robust and functional for real users.</p>
<p>By the end, you’ll have a working MCP server that:</p>
<ul>
<li><p>Authenticates users with Kinde.</p>
</li>
<li><p>Stores to-do data in a Neon Postgres database.</p>
</li>
<li><p>Enforces billing limits and supports upgrades.</p>
</li>
<li><p>Exposes all these features as MCP tools inside Cursor.</p>
</li>
</ul>
<p>This article will walk you through each step, helping you understand design decisions that you can adapt for your own projects.</p>
<h2 id="heading-what-youll-learn">What You’ll Learn</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-why-go-beyond-basic-mcp-servers">Why Go Beyond Basic MCP Servers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-youll-build">What You’ll Build</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-database-setup-with-neon-postgresql">Database Setup with Neon PostgreSQL</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-connect-your-neon-database">1. Connect your Neon Database</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-create-your-db-file">2. Create your DB File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-step-by-step-breakdown-of-setup-dbts">3. Step-by-Step Breakdown of setup-db.ts</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-full-setup-dbts-file">4. Full setup-db.ts File</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-authentication-with-kinde">Authentication with Kinde</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-create-a-kinde-application">1. Create a Kinde Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-configure-kinde-settings">2. Configure Kinde Settings</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-environment-variables">3. Environment Variables</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-create-the-kinde-auth-server">4. Create the Kinde Auth Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-5-complete-authentication-flow">5. Complete Authentication Flow</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-6-why-this-matters">6. Why This Matters</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-7-key-connections">7. Key Connections</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-8-full-kinde-auth-serverts-file">8. Full kinde-auth-server.ts File</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-mcp-server-implementation-with-billing-system-integration">MCP Server Implementation (with Billing System Integration)</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-create-your-file">1. Create Your File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-project-setup-and-imports">2. Project Setup and Imports</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-database-connection-and-configuration">3. Database Connection and Configuration</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-authentication-system">4. Authentication System</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-5-core-helper-functions">5. Core Helper Functions</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-6-core-server-implementation">6. Core Server Implementation</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-7-register-tools">7. Register Tools</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-8-tool-handlers">8. Tool Handlers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-9-full-serverts-file">9. Full server.ts File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-10-data-flow-amp-integration">10. Data Flow &amp; Integration</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-11-error-handling-amp-security">11. Error Handling &amp; Security</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-12-testing-amp-deployment">12. Testing &amp; Deployment</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-testing-the-complete-system">Testing the Complete System</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-start-the-services">1. Start the Services</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-configure-cursor-mcp">2. Configure Cursor MCP</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-test-the-complete-flow">3. Test the Complete Flow</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-troubleshooting">Troubleshooting</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-mcp-server-not-detected">1. MCP Server Not Detected</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-database-connection-issues">2. Database Connection Issues</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-kinde-authentication-problems">3. Kinde Authentication Problems</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-token-errors">4. Token Errors</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-final-mcp-server-architecture">Final MCP Server Architecture</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-next-steps">Next Steps</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-resources">Resources</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-why-go-beyond-basic-mcp-servers">Why Go Beyond Basic MCP Servers?</h2>
<p>If you read this <a target="_blank" href="https://www.freecodecamp.org/news/how-to-build-a-custom-mcp-server-with-typescript-a-handbook-for-developers">freeCodeCamp MCP handbook</a>, you learned how to set up a simple MCP server in TypeScript. That’s useful for learning the protocol, but it doesn’t reflect what you need in production.</p>
<p>A real application requires:</p>
<ul>
<li><p><strong>Authentication</strong> so each user has their own data and permissions.</p>
</li>
<li><p><strong>Persistence</strong> so data is stored in a reliable database.</p>
</li>
<li><p><strong>Billing</strong> so you can enforce limits and monetize usage.</p>
</li>
</ul>
<p>Without these, an MCP server is just a demo.</p>
<h2 id="heading-what-youll-build">What You’ll Build</h2>
<p>In this tutorial, you’ll build a to-do MCP server with TypeScript that includes the essentials of a production-ready backend:</p>
<ul>
<li><p><strong>Authentication</strong> with Kinde</p>
</li>
<li><p><strong>Database persistence</strong> with Neon Postgres</p>
</li>
<li><p><strong>Billing enforcement</strong> with a free tier and upgrade path</p>
</li>
<li><p><strong>MCP tool exposure</strong> so all of this works seamlessly</p>
</li>
</ul>
<p>By the end, you’ll have an MCP server that feels more like the backend of a SaaS app and a template you can extend for your own ideas.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before we start, you'll need:</p>
<p><strong>Accounts &amp; Services (all free to use):</strong></p>
<ul>
<li><p><a target="_blank" href="https://kinde.com">Kinde Account</a> <strong>→</strong> for authentication and billing</p>
</li>
<li><p><a target="_blank" href="https://neon.com">Neon Account</a> <strong>→</strong> for PostgreSQL database</p>
</li>
<li><p>Node.js (v18+) (<a target="_blank" href="https://nodejs.org/en/download">download</a>)</p>
</li>
<li><p>Cursor IDE <strong>→</strong> for MCP integration and tool testing (<a target="_blank" href="https://cursor.com/download">download</a>)</p>
</li>
</ul>
<p><strong>Development Tools:</strong></p>
<ul>
<li><p>Terminal/Command line access</p>
</li>
<li><p>Git (optional, for version control)</p>
</li>
</ul>
<h2 id="heading-project-setup">Project Setup</h2>
<p>First, create a new folder:</p>
<pre><code class="lang-powershell">mkdir todo<span class="hljs-literal">-mcp</span><span class="hljs-literal">-server</span>
<span class="hljs-built_in">cd</span> todo<span class="hljs-literal">-mcp</span><span class="hljs-literal">-server</span>
</code></pre>
<p>Then initialize a Node.js project:</p>
<pre><code class="lang-powershell">npm init <span class="hljs-literal">-y</span>
</code></pre>
<p>Next, install the dependencies your server will need:</p>
<pre><code class="lang-powershell">npm install @modelcontextprotocol/sdk @neondatabase/serverless @kinde<span class="hljs-literal">-oss</span>/kinde<span class="hljs-literal">-typescript</span><span class="hljs-literal">-sdk</span> express jsonwebtoken jwks<span class="hljs-literal">-client</span> express<span class="hljs-literal">-session</span>
</code></pre>
<p>The <code>@modelcontextprotocol/sdk</code> package gives us everything we need to build and expose MCP servers and tools. We’re using <code>@neondatabase/serverless</code> to connect to a Neon Postgres database, and <code>@kinde-oss/kinde-typescript-sdk</code> handles authentication and billing through Kinde.</p>
<p>We’ll also install <code>express</code>, which makes it easy to define routes and handle middleware. To verify user tokens from Kinde, we’ll use <code>jsonwebtoken</code> together with <code>jwks-client</code>. And finally, <code>express-session</code> will take care of managing session state so users can stay logged in across requests.</p>
<p>Next, set up TypeScript and a few type definitions for development:</p>
<pre><code class="lang-powershell">npm install <span class="hljs-literal">-D</span> typescript @types/node @types/express @types/express<span class="hljs-literal">-session</span> tsx
</code></pre>
<p>The <code>typescript</code> package enables TypeScript in your project so you can write strongly typed code. The <code>@types/*</code> packages provide type definitions for Node.js, Express, and the session middleware, giving you better autocomplete and error checking in your editor.</p>
<p>Finally, <code>tsx</code> makes it super easy to run TypeScript files directly without the need to pre-compile them before running your app.</p>
<p>Then create a <code>.env</code> file in your project root and paste these variables:</p>
<pre><code class="lang-json"># Database
DATABASE_URL=postgresql:<span class="hljs-comment">//user:pass@host:port/db</span>

# Kinde Authentication
KINDE_ISSUER_URL=https:<span class="hljs-comment">//your-domain.kinde.com</span>
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret

# Security
JWT_SECRET=your_secret_key

# Environment
NODE_ENV=development
</code></pre>
<p>This stores all the credentials that you’ll be using for this project.</p>
<p>Next, create a <code>tsconfig.json</code> in the project root to tell the TypeScript compiler how to handle your code:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"compilerOptions"</span>: {
    <span class="hljs-attr">"target"</span>: <span class="hljs-string">"ES2022"</span>,
    <span class="hljs-attr">"module"</span>: <span class="hljs-string">"ESNext"</span>,
    <span class="hljs-attr">"moduleResolution"</span>: <span class="hljs-string">"node"</span>,
    <span class="hljs-attr">"strict"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"esModuleInterop"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"skipLibCheck"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"forceConsistentCasingInFileNames"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"outDir"</span>: <span class="hljs-string">"./dist"</span>,
    <span class="hljs-attr">"rootDir"</span>: <span class="hljs-string">"./src"</span>
  },
  <span class="hljs-attr">"include"</span>: [<span class="hljs-string">"src/**/*"</span>],
  <span class="hljs-attr">"exclude"</span>: [<span class="hljs-string">"node_modules"</span>, <span class="hljs-string">"dist"</span>]
}
</code></pre>
<p>Finally, update your <code>package.json</code> scripts</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"scripts"</span>: {
    <span class="hljs-attr">"build"</span>: <span class="hljs-string">"tsc"</span>,
    <span class="hljs-attr">"dev"</span>: <span class="hljs-string">"tsx src/server.ts"</span>,
    <span class="hljs-attr">"start"</span>: <span class="hljs-string">"node dist/server.js"</span>,
    <span class="hljs-attr">"auth-server"</span>: <span class="hljs-string">"tsx src/kinde-auth-server.ts"</span>,
    <span class="hljs-attr">"setup-db"</span>: <span class="hljs-string">"tsx src/setup-db.ts"</span>
  }
}
</code></pre>
<h2 id="heading-database-setup-with-neon-postgresql">Database Setup with Neon PostgreSQL</h2>
<p>To power your to-do MCP server, you’ll use Neon, a serverless PostgreSQL platform. This gives us a fully managed, scalable database without worrying about infrastructure.</p>
<h3 id="heading-1-connect-your-neon-database">1. Connect your Neon Database</h3>
<ul>
<li><p>Sign up or log in to your <a target="_blank" href="https://console.neon.tech">Neon account console</a>.</p>
</li>
<li><p>Create a new project.</p>
</li>
<li><p>Copy the connection string, you’ll need it in your <code>.env</code> file.</p>
</li>
</ul>
<h3 id="heading-2-create-your-db-file">2. Create your DB File</h3>
<p>Inside your project, create a new file in your <code>src/</code> folder and name it <code>setup-db.ts</code>. This file will create the tables, indexes, and schema your app relies on.</p>
<p>Database Architecture Overview:</p>
<pre><code class="lang-markdown">┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   setup-db.ts   │───▶│  Neon Database  │───▶│   PostgreSQL    │
│   (Schema)      │    │   (Serverless)  │    │   (Tables)      │
└─────────────────┘    └─────────────────┘    └─────────────────┘
</code></pre>
<h3 id="heading-3-step-by-step-breakdown-of-setup-dbts">3. Step-by-Step Breakdown of <code>setup-db.ts</code></h3>
<p><strong>Step 1: Imports and Database Connection</strong></p>
<p>Start by importing the packages you’ll need and setting up your database connection:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { neon } <span class="hljs-keyword">from</span> <span class="hljs-string">'@neondatabase/serverless'</span>;
<span class="hljs-keyword">import</span> dotenv <span class="hljs-keyword">from</span> <span class="hljs-string">'dotenv'</span>;

dotenv.config();

<span class="hljs-keyword">const</span> sql = neon(process.env.DATABASE_URL!);
</code></pre>
<p>The <code>dotenv</code> package loads your environment variables from a <code>.env</code> file so you don’t have to hardcode secrets in your code. The <code>neon</code> function connects your app to your Neon Postgres database, and the <code>sql</code> variable gives you a clean, type-safe way to run queries.</p>
<p>At this point, your app has everything it needs to talk to the database.</p>
<p><strong>Step 2: Main Setup Function</strong></p>
<p>Now let’s create a function to handle the database setup process:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">setupDatabase</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Setting up database schema...'</span>);
  <span class="hljs-keyword">try</span> {
    <span class="hljs-comment">// Database operations here</span>
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error setting up database:'</span>, error);
    process.exit(<span class="hljs-number">1</span>);
  }
}
</code></pre>
<p>This function keeps all your schema creation logic in one place, making it easy to manage. It also catches and logs any errors instead of failing silently, so you’ll immediately know if something goes wrong. The console messages give you real-time feedback as the setup runs which is super helpful when you’re debugging or deploying.</p>
<p><strong>Step 3: To-Dos Table</strong></p>
<p>Next, create a table to store all user to-dos:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE TABLE IF NOT EXISTS todos (
    id SERIAL PRIMARY KEY,
    user_id TEXT NOT NULL,
    title TEXT NOT NULL,
    description TEXT,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  )
`</span>;
</code></pre>
<p>This table holds every user’s tasks. The <code>user_id</code> column links each to-do to the user who created it, while the <code>completed</code> field tracks whether a task is done or still pending. The automatic <code>created_at</code> and <code>updated_at</code> timestamps make it easy to sort tasks or track their history over time.</p>
<p><strong>Step 4: Users Table</strong></p>
<p>Now, let’s define a table to manage user accounts and billing details:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    user_id TEXT UNIQUE NOT NULL,
    name TEXT,
    email TEXT,
    subscription_status TEXT DEFAULT 'free' CHECK (subscription_status IN ('free', 'active', 'cancelled')),
    plan TEXT DEFAULT 'free',
    free_todos_used INTEGER DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  )
`</span>;
</code></pre>
<p>This table stores each user’s basic information along with their subscription details. The <code>user_id</code> value comes directly from Kinde during authentication. The <code>subscription_status</code> and <code>free_todos_used</code> columns help you enforce billing tiers and limit how many free tasks a user can create before needing to upgrade.</p>
<p><strong>Step 5: Performance Indexes</strong></p>
<p>Next, let’s add a few indexes to make common database operations faster:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id)
`</span>;

<span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE INDEX IF NOT EXISTS idx_todos_created_at ON todos(created_at)
`</span>;

<span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE INDEX IF NOT EXISTS idx_users_user_id ON users(user_id)
`</span>;
</code></pre>
<p>These indexes help speed up lookups and sorting. The first one lets the database quickly find to-dos that belong to a specific user. The second makes it faster to sort tasks by their creation date. And the last one allows fast lookups of users based on their Kinde <code>user_id</code>.</p>
<p><strong>Step 6: Success Logging</strong></p>
<p>After everything runs, it’s helpful to log a clear summary of what was created:</p>
<pre><code class="lang-typescript"><span class="hljs-built_in">console</span>.log(<span class="hljs-string">'✅ Database schema created successfully!'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'📋 Tables created:'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'  - todos (id, user_id, title, description, completed, created_at, updated_at)'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'  - users (id, user_id, subscription_status, plan, free_todos_used, created_at, updated_at)'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'🔍 Indexes created for optimal performance'</span>);
</code></pre>
<p>These logs give you immediate feedback once the setup completes. They show exactly which tables and indexes were created, making it easy to confirm that your database schema is ready to go and everything ran as expected.</p>
<p><strong>Step 7: Error Handling</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">try</span> {
} <span class="hljs-keyword">catch</span> (error) {
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'❌ Error setting up database:'</span>, error);
  process.exit(<span class="hljs-number">1</span>);
}
</code></pre>
<p>This handles any database setup errors gracefully.</p>
<p><strong>Step 8:</strong> Update your <code>.env</code> file in your project root with your Neon database connection string:</p>
<pre><code class="lang-json">DATABASE_URL=postgresql:<span class="hljs-comment">//username:password@host/database?sslmode=require</span>
</code></pre>
<p><strong>Step 9: Function Execution</strong></p>
<p>At the bottom of <code>setup-db.ts</code>, run the function:</p>
<pre><code class="lang-typescript">setupDatabase();
</code></pre>
<p>This immediately executes the database setup when the script runs.</p>
<p>Now, run this command in your CLI:</p>
<pre><code class="lang-powershell">npm run setup<span class="hljs-literal">-db</span>
</code></pre>
<p>Expected output:</p>
<pre><code class="lang-powershell">🚀 Setting up database schema...
✅ Database schema created successfully!
📋 Tables created:
  - todos (id, user_id, title, description, completed, created_at, updated_at)
  - users (id, user_id, subscription_status, plan, free_todos_used, created_at, updated_at)
🔍 Indexes created <span class="hljs-keyword">for</span> optimal performance
</code></pre>
<h3 id="heading-4-full-setup-dbts-file">4. Full <code>setup-db.ts</code> File</h3>
<p>You can view the complete implementation of the <code>setup-db.ts</code> file in the <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server/blob/main/src/setup-db.ts">GitHub repo</a> and copy it directly into your project.</p>
<h2 id="heading-authentication-with-kinde">Authentication with Kinde</h2>
<p>To secure your MCP server, you’ll use <a target="_blank" href="https://kinde.com">Kinde</a>, an authentication provider that makes it easy to handle logins, user sessions, and tokens.</p>
<p>You’ll also connect Kinde to your Neon database so new users are automatically created when they log in.</p>
<h3 id="heading-1-create-a-kinde-application">1. Create a Kinde Application</h3>
<ul>
<li><p>Go to the <a target="_blank" href="https://app.kinde.com/admin">Kinde Dashboard</a>.</p>
</li>
<li><p>Create a new application.</p>
</li>
<li><p>Note down these values (you’ll use them shortly):</p>
<ul>
<li><p><strong>Domain</strong>: <a target="_blank" href="https://your-domain.kinde.com"><code>https://your-domain.kinde.com</code></a></p>
</li>
<li><p><strong>Client ID</strong>: <code>your-client-id</code></p>
</li>
<li><p><strong>Client Secret</strong>: <code>your-client-secret</code></p>
</li>
</ul>
</li>
</ul>
<h3 id="heading-2-configure-kinde-settings">2. Configure Kinde Settings</h3>
<p>In your Kinde dashboard, save these URLs as your:</p>
<ul>
<li><p>Redirect URL → <a target="_blank" href="http://localhost:3000/callback"><code>http://localhost:3000/callback</code></a></p>
</li>
<li><p>Logout URL → <a target="_blank" href="http://localhost:3000"><code>http://localhost:3000</code></a></p>
</li>
</ul>
<h3 id="heading-3-environment-variables">3. Environment Variables</h3>
<p>Update the <code>.env</code> file in your project root with the credentials from your Kinde Dashboard:</p>
<pre><code class="lang-json">KINDE_ISSUER_URL=https:<span class="hljs-comment">//your-domain.kinde.com</span>
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret
</code></pre>
<h3 id="heading-4-create-the-kinde-auth-server">4. Create the Kinde Auth Server</h3>
<p>You’ll build an Express server (<code>src/kinde-auth-server.ts</code>) to handle authentication. This server will manage OAuth login and logout with Kinde, store user sessions, and automatically create or update users in your Neon database whenever they sign in.</p>
<p><strong>4.1: Dependencies and Setup</strong></p>
<p>Start by importing the packages you’ll need:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> session <span class="hljs-keyword">from</span> <span class="hljs-string">'express-session'</span>;
<span class="hljs-keyword">import</span> { createKindeServerClient, GrantType, SessionManager } <span class="hljs-keyword">from</span> <span class="hljs-string">'@kinde-oss/kinde-typescript-sdk'</span>;
<span class="hljs-keyword">import</span> jwt <span class="hljs-keyword">from</span> <span class="hljs-string">'jsonwebtoken'</span>;
<span class="hljs-keyword">import</span> dotenv <span class="hljs-keyword">from</span> <span class="hljs-string">'dotenv'</span>;
<span class="hljs-keyword">import</span> { neon } <span class="hljs-keyword">from</span> <span class="hljs-string">'@neondatabase/serverless'</span>;
</code></pre>
<p>The <code>express</code> import powers the web server that will handle authentication routes. <code>express-session</code> manages user sessions so you can persist login state between requests. The <code>@kinde-oss/kinde-typescript-sdk</code> package is the official Kinde SDK, which handles OAuth flows and user authentication.</p>
<p>You’ll use <code>jsonwebtoken</code> to decode and verify user tokens, while <code>dotenv</code> loads environment variables from your <code>.env</code> file. Finally, <code>@neondatabase/serverless</code> connects your server to the Neon Postgres database where user data will be stored.</p>
<p><strong>4.2: Connect to Your Database</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> sql = neon(process.env.DATABASE_URL!);
</code></pre>
<p>This initializes a type-safe SQL client using your <code>DATABASE_URL</code>.</p>
<p><strong>4.3: Extend login Session</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">declare</span> <span class="hljs-keyword">module</span> 'express-session' {
  <span class="hljs-keyword">interface</span> SessionData {
    accessToken?: <span class="hljs-built_in">string</span>;
    idToken?: <span class="hljs-built_in">string</span>;
    userInfo?: <span class="hljs-built_in">any</span>;
    userName?: <span class="hljs-built_in">string</span>;
    userEmail?: <span class="hljs-built_in">string</span>;
  }
}
</code></pre>
<p>This extends <code>express-session</code> types so you can store Kinde tokens and user info directly in the session.</p>
<p><strong>4.4: Configure Sessions</strong></p>
<p>Now, let’s configure session management so users can stay logged in across requests:</p>
<pre><code class="lang-typescript">app.use(session({
  secret: process.env.JWT_SECRET || <span class="hljs-string">'your_jwt_secret_key'</span>,
  resave: <span class="hljs-literal">true</span>,
  saveUninitialized: <span class="hljs-literal">true</span>,
  cookie: { 
    secure: <span class="hljs-literal">false</span>,
    maxAge: <span class="hljs-number">7</span> * <span class="hljs-number">24</span> * <span class="hljs-number">60</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>, <span class="hljs-comment">// 7 days</span>
    httpOnly: <span class="hljs-literal">true</span>,
    sameSite: <span class="hljs-string">'lax'</span>
  }
}));
</code></pre>
<p>The <code>secret</code> value is used to sign and verify session cookies, ensuring that sessions can’t be tampered with. The cookie settings keep users logged in for up to 7 days and make sure session tokens persist even after a browser refresh.</p>
<p>Setting <code>httpOnly</code> helps protect against cross-site scripting (XSS) attacks, while <code>sameSite: 'lax'</code> allows users to log in across different origins without breaking authentication.</p>
<p><strong>4.5: Create a Session Manager Factory</strong></p>
<p>Next, you’ll define a small helper function to manage session data for each request:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> createSessionManager = (req: <span class="hljs-built_in">any</span>): <span class="hljs-function"><span class="hljs-params">SessionManager</span> =&gt;</span> ({
  getSessionItem: <span class="hljs-keyword">async</span> (key: <span class="hljs-built_in">string</span>) =&gt; req.session?.[key],
  setSessionItem: <span class="hljs-keyword">async</span> (key: <span class="hljs-built_in">string</span>, value: <span class="hljs-built_in">any</span>) =&gt; {
    <span class="hljs-keyword">if</span> (!req.session) req.session = {};
    req.session[key] = value;
  },
  removeSessionItem: <span class="hljs-keyword">async</span> (key: <span class="hljs-built_in">string</span>) =&gt; {
    <span class="hljs-keyword">if</span> (req.session) <span class="hljs-keyword">delete</span> req.session[key];
  },
  destroySession: <span class="hljs-keyword">async</span> () =&gt; {
    req.session = {};
  }
});
</code></pre>
<p>This function creates a session manager that’s tied to each request. It provides a consistent way to store, retrieve, and clear session data which is exactly what the Kinde SDK needs to keep track of tokens and user info during authentication.</p>
<p><strong>4.6: Create the Kinde Client</strong></p>
<p>Next, set up the Kinde client that will handle the OAuth login and logout flow:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> kindeClient = createKindeServerClient(GrantType.AUTHORIZATION_CODE, {
  authDomain: process.env.KINDE_ISSUER_URL!,
  clientId: process.env.KINDE_CLIENT_ID!,
  clientSecret: process.env.KINDE_CLIENT_SECRET!,
  redirectURL: <span class="hljs-string">'&lt;http://localhost:3000/callback&gt;'</span>,
  logoutRedirectURL: <span class="hljs-string">'&lt;http://localhost:3000&gt;'</span>,
});
</code></pre>
<p>This connects your app to Kinde using the <strong>Authorization Code</strong> grant type which is the most secure option for server-side applications like this one.</p>
<p>The <code>redirectURL</code> and <code>logoutRedirectURL</code> define where users should be sent after logging in or out.</p>
<p><strong>4.7: Create the Home Page Route (</strong><code>GET /</code><strong>)</strong></p>
<p>Then you’ll define a basic route for your home page that checks whether a user is logged in:</p>
<pre><code class="lang-typescript">app.get(<span class="hljs-string">'/'</span>, <span class="hljs-function">(<span class="hljs-params">req, res</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> token = req.session?.accessToken;
  <span class="hljs-keyword">const</span> userInfo = req.session?.userInfo;

  <span class="hljs-keyword">if</span> (token) {
    <span class="hljs-comment">// Show logged-in state with tokens</span>
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-comment">// Show login button</span>
  }
});
</code></pre>
<p>This route reads the session to determine if a user is authenticated. If a token exists, you can display their account details or dashboard. Otherwise, you’ll show a login button that directs them to Kinde’s sign-in page.</p>
<p><strong>4.8: Create the Login Route (</strong><code>GET /login</code><strong>)</strong></p>
<p>Now, add a route that starts the OAuth login process:</p>
<pre><code class="lang-typescript">app.get(<span class="hljs-string">'/login'</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> sessionManager = createSessionManager(req);
    <span class="hljs-keyword">const</span> loginUrl = <span class="hljs-keyword">await</span> kindeClient.login(sessionManager);
    res.redirect(loginUrl.toString());
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Login error:'</span>, error);
    res.status(<span class="hljs-number">500</span>).send(<span class="hljs-string">'Login failed'</span>);
  }
});
</code></pre>
<p>When users visit this route, your app uses the Kinde client to generate a secure login URL and redirects them to Kinde’s hosted login page. Once they log in, Kinde will send them back to your callback route with the necessary tokens.</p>
<p><strong>4.9: OAuth Callback Route (</strong><code>GET /callback</code><strong>)</strong></p>
<p>This is where Kinde redirects users back after login. First, you’ll get the authorization <code>code</code> from Kinde for token exchange:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> fullUrl = <span class="hljs-string">`http://<span class="hljs-subst">${req.headers.host}</span><span class="hljs-subst">${req.url}</span>`</span>;
<span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> URL(fullUrl);
<span class="hljs-keyword">const</span> code = url.searchParams.get(<span class="hljs-string">'code'</span>);
</code></pre>
<p>Next, you’ll exchange this code for tokens that will be needed for API calls:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> tokenResponse = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`<span class="hljs-subst">${process.env.KINDE_ISSUER_URL}</span>/oauth2/token`</span>, {
  method: <span class="hljs-string">'POST'</span>,
  headers: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/x-www-form-urlencoded'</span> },
  body: <span class="hljs-keyword">new</span> URLSearchParams({
    grant_type: <span class="hljs-string">'authorization_code'</span>,
    client_id: process.env.KINDE_CLIENT_ID!,
    client_secret: process.env.KINDE_CLIENT_SECRET!,
    code: code,
    redirect_uri: <span class="hljs-string">'http://localhost:3000/callback'</span>,
  }),
});
</code></pre>
<p>Then, you’ll store these tokens in the session for future requests so users don't need to login every time:</p>
<pre><code class="lang-typescript">req.session.accessToken = tokenData.access_token;
req.session.idToken = tokenData.id_token;
req.session.userInfo = tokenData;
</code></pre>
<p>The next thing you’ll do is to extract the user’s information from JWT and then store this in your database</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> user = <span class="hljs-built_in">JSON</span>.parse(Buffer.from(idToken.split(<span class="hljs-string">'.'</span>)[<span class="hljs-number">1</span>], <span class="hljs-string">'base64'</span>).toString());
req.session.userName = user.given_name || user.name || <span class="hljs-string">'User'</span>;
req.session.userEmail = user.email || <span class="hljs-string">'user@example.com'</span>;
</code></pre>
<p>Finally, add this code that will automatically create or update the user in the database so your MCP server can track to-dos and billing:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> existingUser = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`SELECT * FROM users WHERE user_id = <span class="hljs-subst">${userId}</span>`</span>;

<span class="hljs-keyword">if</span> (existingUser.length === <span class="hljs-number">0</span>) {
  <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    INSERT INTO users (user_id, name, email, subscription_status, plan, free_todos_used)
    VALUES (<span class="hljs-subst">${userId}</span>, <span class="hljs-subst">${userName}</span>, <span class="hljs-subst">${userEmail}</span>, 'free', 'free', 0)
  `</span>;
} <span class="hljs-keyword">else</span> {
  <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    UPDATE users 
    SET name = <span class="hljs-subst">${userName}</span>, email = <span class="hljs-subst">${userEmail}</span>
    WHERE user_id = <span class="hljs-subst">${userId}</span>
  `</span>;
}
</code></pre>
<p><strong>4.10: Create the Logout Route (</strong><code>GET /logout</code><strong>)</strong></p>
<pre><code class="lang-typescript">app.get(<span class="hljs-string">'/logout'</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">try</span> {
    req.session.destroy(<span class="hljs-function">(<span class="hljs-params">err</span>) =&gt;</span> {
      <span class="hljs-keyword">if</span> (err) {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Session destroy error:'</span>, err);
      }
      res.redirect(<span class="hljs-string">'/'</span>);
    });
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Logout error:'</span>, error);
    res.status(<span class="hljs-number">500</span>).send(<span class="hljs-string">'Logout failed'</span>);
  }
});
</code></pre>
<p>This route simply destroys the user’s session, removing all stored tokens and user information. Once the session is cleared, it redirects the user back to the home page, effectively logging them out of the app.</p>
<h3 id="heading-5-complete-authentication-flow">5. Complete Authentication Flow</h3>
<pre><code class="lang-markdown"><span class="hljs-bullet">1.</span> User visits / → sees login button.
<span class="hljs-bullet">2.</span> Clicks login → goes to Kinde.
<span class="hljs-bullet">3.</span> Logs in → redirected back to /callback.
<span class="hljs-bullet">4.</span> Callback exchanges code for tokens.
<span class="hljs-bullet">5.</span> Tokens stored in session.
<span class="hljs-bullet">6.</span> User is created/updated in database.
<span class="hljs-bullet">7.</span> User can now access the MCP server.
</code></pre>
<h3 id="heading-6-why-this-matters">6. Why This Matters</h3>
<p>By placing Kinde in front of your MCP server, you get a secure and seamless authentication layer without the extra trouble of handling passwords or tokens manually. Your users can log in safely, and their sessions persist across page refreshes without the need to log in again each time they revisit your app.</p>
<p>Every new user who signs in is automatically added to your Neon database, making it easy to track accounts and usage. This setup also lays the groundwork for more advanced features later on, like enforcing billing limits or managing user-specific to-dos.</p>
<h3 id="heading-7-key-connections">7. Key Connections</h3>
<ul>
<li><p><strong>Session ↔ Database</strong>: Sync user data</p>
</li>
<li><p><strong>Kinde ↔ Session</strong>: Tokens flow from Kinde to session storage</p>
</li>
<li><p><strong>Session ↔ MCP</strong>: Tokens passed into the server for access control</p>
</li>
<li><p><strong>Database ↔ MCP</strong>: User billing + to-dos read from Neon</p>
</li>
</ul>
<h3 id="heading-8-full-kinde-auth-serverts-file">8. Full <code>kinde-auth-server.ts</code> File</h3>
<p>You can view the complete implementation of the <code>kinde-auth-server.ts</code> file in the <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server/blob/main/src/kinde-auth-server.ts">GitHub repo</a> and copy it directly into your project.</p>
<h2 id="heading-mcp-server-implementation-with-billing-system-integration">MCP Server Implementation (with Billing System Integration)</h2>
<p>Now it’s time to create the main file for your MCP server. This file acts as the entry point, wiring up your database, authentication, tool handlers, and overall flow into a single server.</p>
<h3 id="heading-1-create-your-file">1. Create Your File</h3>
<p>Inside your project, create a new file:</p>
<pre><code class="lang-bash">src/server.ts
</code></pre>
<p>This file will contain the full implementation of your MCP server.</p>
<h3 id="heading-2-project-setup-and-imports">2. Project Setup and Imports</h3>
<p>At the top of the file, import the dependencies you’ll need:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { Server } <span class="hljs-keyword">from</span> <span class="hljs-string">'@modelcontextprotocol/sdk/server/index.js'</span>;
<span class="hljs-keyword">import</span> { StdioServerTransport } <span class="hljs-keyword">from</span> <span class="hljs-string">'@modelcontextprotocol/sdk/server/stdio.js'</span>;
<span class="hljs-keyword">import</span> { CallToolRequestSchema, ListToolsRequestSchema } <span class="hljs-keyword">from</span> <span class="hljs-string">'@modelcontextprotocol/sdk/types.js'</span>;
<span class="hljs-keyword">import</span> { neon } <span class="hljs-keyword">from</span> <span class="hljs-string">'@neondatabase/serverless'</span>;
<span class="hljs-keyword">import</span> jwt <span class="hljs-keyword">from</span> <span class="hljs-string">'jsonwebtoken'</span>;
<span class="hljs-keyword">import</span> fs <span class="hljs-keyword">from</span> <span class="hljs-string">'fs'</span>;
<span class="hljs-keyword">import</span> path <span class="hljs-keyword">from</span> <span class="hljs-string">'path'</span>;
</code></pre>
<p>Each import in your MCP server has a specific purpose. The <code>Server</code> class is the foundation that powers your entire implementation. It listens for requests, manages responses, and keeps track of all registered tools. The <code>StdioServerTransport</code> handles communication between your MCP server and other tools through standard input and output, which is exactly how Cursor connects behind the scenes.</p>
<p>The <code>CallToolRequestSchema</code> and <code>ListToolsRequestSchema</code> act as validators, ensuring every incoming request follows the correct structure before it’s processed. This reduces errors and keeps communication between your tools and the MCP client consistent.</p>
<p><code>neon</code> connects your server to the Neon PostgreSQL database, providing a clean way to manage persistent data like users and to-dos. The <code>jsonwebtoken</code> library decodes and verifies tokens from Kinde, letting you identify and authenticate users securely.</p>
<p><code>fs</code> is used to read and write authentication tokens locally, which means users don’t need to log in every time. Finally, <code>path</code> helps manage file paths cleanly across different systems, keeping everything organized and portable.</p>
<p>Together, these imports form the backbone of your server’s logic, handling authentication, database access, and reliable communication with Cursor.</p>
<h3 id="heading-3-database-connection-and-configuration">3. Database Connection and Configuration</h3>
<p>Next, configure your database connection and token storage:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> sql = neon(process.env.DATABASE_URL!);
<span class="hljs-keyword">const</span> TOKEN_FILE = <span class="hljs-string">'.auth-token'</span>;
</code></pre>
<p>The <code>sql</code> constant creates a live connection to your Neon PostgreSQL database using the <code>DATABASE_URL</code> environment variable. Think of it as the bridge that lets your MCP server talk to your database with every query, insert, and update running through this connection.</p>
<p>It’s what allows your server to persist user data, to-dos, and billing information reliably without having to manage complex configurations manually.</p>
<p>The <code>TOKEN_FILE</code> constant, on the other hand, acts as a lightweight local storage system for authentication tokens. Whenever a user logs in, their token is saved here so they don’t have to reauthenticate every time they restart the server.</p>
<p>It’s a simple but effective way to maintain session continuity, especially during local development or testing.</p>
<h3 id="heading-4-authentication-system">4. Authentication System</h3>
<p>To manage tokens, you’ll add three helper functions:</p>
<p><strong>4.1. Get Stored Token</strong></p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getStoredToken</span>(<span class="hljs-params"></span>): <span class="hljs-title">string</span> | <span class="hljs-title">null</span> </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">if</span> (fs.existsSync(TOKEN_FILE)) {
      <span class="hljs-keyword">return</span> fs.readFileSync(TOKEN_FILE, <span class="hljs-string">'utf8'</span>).trim();
    }
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error reading token file:'</span>, error);
  }
  <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
}
</code></pre>
<p>Retrieves a saved JWT token from the local file system.</p>
<p><strong>4.2. Store Token</strong></p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">storeToken</span>(<span class="hljs-params">token: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">void</span> </span>{
  <span class="hljs-keyword">try</span> {
    fs.writeFileSync(TOKEN_FILE, token);
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error storing token:'</span>, error);
  }
}
</code></pre>
<p>Stores a new JWT token locally so authentication persists across server restarts.</p>
<p><strong>4.3. Decode JWT</strong></p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">decodeJWT</span>(<span class="hljs-params">token: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">any</span> </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">return</span> jwt.decode(token);
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error decoding JWT:'</span>, error);
    <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
  }
}
</code></pre>
<p>Decodes JWTs to extract user info such as ID, email, and subscription status.</p>
<h3 id="heading-5-core-helper-functions">5. Core Helper Functions</h3>
<p><strong>5.1. Check Billing Status</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getKindeBillingStatus</span>(<span class="hljs-params">userId: <span class="hljs-built_in">string</span>, accessToken: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">Promise</span>&lt;</span>{ plan: <span class="hljs-built_in">string</span>; features: <span class="hljs-built_in">any</span>; canCreate: <span class="hljs-built_in">boolean</span>; reason?: <span class="hljs-built_in">string</span> }&gt; {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> decoded = jwt.decode(accessToken) <span class="hljs-keyword">as</span> <span class="hljs-built_in">any</span>;
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'🔍 JWT Token data for user:'</span>, userId, <span class="hljs-string">'Decoded:'</span>, decoded);

    <span class="hljs-keyword">const</span> subscription = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
      SELECT * FROM users 
      WHERE user_id = <span class="hljs-subst">${userId}</span>
    `</span>;

    <span class="hljs-keyword">if</span> (subscription.length === <span class="hljs-number">0</span>) {
      <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
        INSERT INTO users (user_id, name, email, subscription_status, plan, free_todos_used)
        VALUES (<span class="hljs-subst">${userId}</span>, <span class="hljs-subst">${decoded.given_name || decoded.name || <span class="hljs-string">'User'</span>}</span>, <span class="hljs-subst">${decoded.email || <span class="hljs-string">'user@example.com'</span>}</span>, 'free', 'free', 0)
      `</span>;
      <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'👤 New user created:'</span>, decoded.given_name || decoded.name, decoded.email);
    }

    <span class="hljs-keyword">const</span> freeTodosUsed = subscription.length &gt; <span class="hljs-number">0</span> ? subscription[<span class="hljs-number">0</span>].free_todos_used : <span class="hljs-number">0</span>;

    <span class="hljs-keyword">if</span> (freeTodosUsed &lt; <span class="hljs-number">1</span>) {
      <span class="hljs-keyword">return</span> {
        plan: <span class="hljs-string">'free'</span>,
        features: { maxTodos: <span class="hljs-number">1</span>, used: freeTodosUsed },
        canCreate: <span class="hljs-literal">true</span>,
        reason: <span class="hljs-string">`Free tier - <span class="hljs-subst">${<span class="hljs-number">1</span> - freeTodosUsed}</span> todo remaining`</span>
      };
    }

    <span class="hljs-keyword">return</span> {
      plan: <span class="hljs-string">'free'</span>,
      features: { maxTodos: <span class="hljs-number">1</span>, used: freeTodosUsed },
      canCreate: <span class="hljs-literal">false</span>,
      reason: <span class="hljs-string">'You have used your free todo. Please upgrade your plan at &lt;https://learnflowai.kinde.com/portal&gt; to create more todos.'</span>
    };
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error checking Kinde billing:'</span>, error);
    <span class="hljs-keyword">return</span> {
      plan: <span class="hljs-string">'free'</span>,
      features: { maxTodos: <span class="hljs-number">1</span> },
      canCreate: <span class="hljs-literal">false</span>,
      reason: <span class="hljs-string">'Error checking billing status'</span>
    };
  }
}
</code></pre>
<p>This checks a user's billing status and enforces a 1-todo free tier. It also auto-creates a user in the database if it doesn’t exist.</p>
<p><strong>5.2. Check To-Do Permission</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">canCreateTodo</span>(<span class="hljs-params">userId: <span class="hljs-built_in">string</span>, accessToken: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">Promise</span>&lt;<span class="hljs-title">boolean</span>&gt; </span>{
  <span class="hljs-keyword">const</span> billingStatus = <span class="hljs-keyword">await</span> getKindeBillingStatus(userId, accessToken);
  <span class="hljs-keyword">return</span> billingStatus.canCreate;
}
</code></pre>
<p>This is a simple wrapper that returns a boolean for permission checks.</p>
<h3 id="heading-6-core-server-implementation">6. Core Server Implementation</h3>
<p>Initialize your MCP server:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> server = <span class="hljs-keyword">new</span> Server(
  {
    name: <span class="hljs-string">'todo-mcp-server'</span>,
    version: <span class="hljs-string">'1.0.0'</span>,
  },
  {
    capabilities: {
      tools: {},
    },
  }
);
</code></pre>
<p>This declares a new MCP server with the capability to register tools.</p>
<h3 id="heading-7-register-tools">7. Register Tools</h3>
<p>Every tool that your Cursor IDE can use needs to be listed so it knows what’s available. In this part of the server setup, you’re registering all the tools that your MCP server will expose. Think of it like giving Cursor a menu of what your backend can do.</p>
<p>Here’s how that looks in code:</p>
<pre><code class="lang-typescript">server.setRequestHandler(ListToolsRequestSchema, <span class="hljs-keyword">async</span> () =&gt; {
  <span class="hljs-keyword">return</span> {
    tools: [
      {
        name: <span class="hljs-string">'login'</span>,
        description: <span class="hljs-string">'Get authentication URL for Kinde login'</span>,
        inputSchema: {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'object'</span>,
          properties: {},
        },
      },
      <span class="hljs-comment">// ... more tools</span>
    ],
  };
});
</code></pre>
<p>Tools you’ll create include:</p>
<ul>
<li><p>Authentication → <code>login</code>, <code>save_token</code>, <code>logout</code></p>
</li>
<li><p>To-Do Management → <code>list_todos</code>, <code>create_todo</code>, <code>update_todo</code>, <code>delete_todo</code></p>
</li>
<li><p>Billing → <code>refresh_billing_status</code></p>
</li>
</ul>
<h3 id="heading-8-tool-handlers">8. Tool Handlers</h3>
<p>Each tool in your MCP server has its own handler. The handler checks if the user is authenticated, talks to the database to perform the request, and returns a clean, structured response that Cursor can display.</p>
<p>This keeps the server organized, secure, and easy to extend later.</p>
<p><strong>8.1. Login Tool</strong></p>
<p>The <code>login</code> tool is responsible for starting the authentication flow with Kinde. When users call it, the server returns a short message explaining how to sign in and store their token.</p>
<p>Once logged in, it lists a few commands that they user can try.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'login'</span>: {
  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-string">`🔐 **Authentication Required**

To use this MCP server, you need to authenticate with Kinde:

1. **Open your browser** and go to: &lt;http://localhost:3000&gt;
2. **Click "Login with Kinde"** to authenticate
3. **Copy your ID Token** from the page
4. **Use the save_token tool** to store it

**Note:** Make sure the Kinde auth server is running:
\\`</span>\\<span class="hljs-string">`\\`</span>bash
npm run auth-server
\\<span class="hljs-string">`\\`</span>\\<span class="hljs-string">`

After authentication, you can use commands like:
- \\`</span>list todos\\<span class="hljs-string">` - List your todos
- \\`</span>create todo\\<span class="hljs-string">` - Create a new todo
- \\`</span>refresh billing status\\<span class="hljs-string">` - Check your plan status`</span>,
      },
    ],
  };
}
</code></pre>
<p><strong>8.2. Save Token Tool</strong></p>
<p>The <code>save_token</code> tool handles storing the user’s authentication token locally so they don’t need to re-authenticate each time they use the MCP server.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'save_token'</span>: {
  <span class="hljs-keyword">const</span> { token } = args <span class="hljs-keyword">as</span> { token: <span class="hljs-built_in">string</span> };
  storeToken(token);
  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-string">'✅ Token saved successfully! You can now use commands like "list todos" and "create todo" without providing the token each time.'</span>,
      },
    ],
  };
}
</code></pre>
<p>When a user runs this command and passes in their token, the server saves it to a local file using the <code>storeToken()</code> function. From then on, every other command (like <code>list todos</code>, <code>create todo</code>, or <code>refresh billing status</code>) can automatically authenticate using that stored token.</p>
<p>This small step makes the development flow smoother and keeps authentication persistent across sessions.</p>
<p><strong>8.3. List To-Dos Tool</strong></p>
<p>The <code>list_todos</code> tool retrieves all to-dos that belong to the currently authenticated user. It first checks for a stored authentication token and decodes it to identify the user. If the token is missing or invalid, it asks the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'list_todos'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please:\\n1. Type "login" to get the authentication URL\\n2. Complete login at &lt;http://localhost:3000&gt;\\n3. Copy your token and use "save_token" to store it\\n4. Then try "list todos" again'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again using the "login" command.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> todos = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    SELECT * FROM todos 
    WHERE user_id = <span class="hljs-subst">${decoded.sub}</span> 
    ORDER BY created_at DESC
  `</span>;

  <span class="hljs-keyword">if</span> (todos.length === <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'📝 No todos found. Create your first todo using "create todo"!'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> todosList = todos.map(<span class="hljs-function">(<span class="hljs-params">todo: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
    <span class="hljs-string">`**<span class="hljs-subst">${todo.id}</span>.** <span class="hljs-subst">${todo.title}</span><span class="hljs-subst">${todo.description ? <span class="hljs-string">` - <span class="hljs-subst">${todo.description}</span>`</span> : <span class="hljs-string">''</span>}</span> <span class="hljs-subst">${todo.completed ? <span class="hljs-string">'✅'</span> : <span class="hljs-string">'⏳'</span>}</span>`</span>
  ).join(<span class="hljs-string">'\\n'</span>);

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-string">`📝 **Your Todos (<span class="hljs-subst">${todos.length}</span>):**\\n\\n<span class="hljs-subst">${todosList}</span>`</span>,
      },
    ],
  };
}
</code></pre>
<p>Results from this tool are formatted for easy reading in the MCP client, showing each task’s title, description, and completion status. If there are no to-dos yet, it simply prompts the user to create one.</p>
<p><strong>8.4. Create To-Do Tool</strong></p>
<p>The <code>create_todo</code> tool lets authenticated users add new to-dos to their list. It starts by verifying that a valid token exists, ensuring only logged-in users can create tasks. If the token is present, it checks billing limits otherwise, it instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'create_todo'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> { title, description, completed } = args <span class="hljs-keyword">as</span> { 
    title: <span class="hljs-built_in">string</span>; 
    description?: <span class="hljs-built_in">string</span>; 
    completed?: <span class="hljs-built_in">boolean</span>; 
  };

  <span class="hljs-comment">// Check billing status before creating todo</span>
  <span class="hljs-keyword">const</span> canCreate = <span class="hljs-keyword">await</span> canCreateTodo(decoded.sub, token);
  <span class="hljs-keyword">if</span> (!canCreate) {
    <span class="hljs-keyword">const</span> billingStatus = <span class="hljs-keyword">await</span> getKindeBillingStatus(decoded.sub, token);
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">`🚫 **Cannot create todo**

<span class="hljs-subst">${billingStatus.reason}</span>

**Upgrade your plan:** &lt;https://learnflowai.kinde.com/portal`</span>&gt;,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    INSERT INTO todos (user_id, title, description, completed)
    VALUES (<span class="hljs-subst">${decoded.sub}</span>, <span class="hljs-subst">${title}</span>, <span class="hljs-subst">${description || <span class="hljs-literal">null</span>}</span>, <span class="hljs-subst">${completed || <span class="hljs-literal">false</span>}</span>)
    RETURNING *
  `</span>;

  <span class="hljs-comment">// Update free todos used count</span>
  <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    UPDATE users 
    SET free_todos_used = free_todos_used + 1 
    WHERE user_id = <span class="hljs-subst">${decoded.sub}</span>
  `</span>;

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          todoId: result[<span class="hljs-number">0</span>].id,
          message: <span class="hljs-string">'Todo created successfully'</span>,
          title: result[<span class="hljs-number">0</span>].title,
          description: result[<span class="hljs-number">0</span>].description,
          completed: result[<span class="hljs-number">0</span>].completed
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>If the user has hit their limit after calling this tool, it returns a clear message explaining why and provides a link to upgrade their plan.</p>
<p><strong>8.5. Update To-Do Tool</strong></p>
<p>The <code>update_todo</code> tool allows an authenticated user to modify an existing to-do’s title, description, or completion status. It first checks for a valid token and decodes it to identify the user. If authentication fails, the tool instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'update_todo'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> { todoId, title, description, completed } = args <span class="hljs-keyword">as</span> { 
    todoId: <span class="hljs-built_in">number</span>; 
    title?: <span class="hljs-built_in">string</span>; 
    description?: <span class="hljs-built_in">string</span>; 
    completed?: <span class="hljs-built_in">boolean</span>; 
  };

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    UPDATE todos 
    SET 
      title = COALESCE(<span class="hljs-subst">${title || <span class="hljs-literal">null</span>}</span>, title),
      description = COALESCE(<span class="hljs-subst">${description || <span class="hljs-literal">null</span>}</span>, description),
      completed = COALESCE(<span class="hljs-subst">${completed !== <span class="hljs-literal">undefined</span> ? completed : <span class="hljs-literal">null</span>}</span>, completed),
      updated_at = CURRENT_TIMESTAMP
    WHERE id = <span class="hljs-subst">${todoId}</span> AND user_id = <span class="hljs-subst">${decoded.sub}</span>
    RETURNING *
  `</span>;

  <span class="hljs-keyword">if</span> (result.length === <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Todo not found or you do not have permission to update it.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          message: <span class="hljs-string">'Todo updated successfully'</span>,
          todo: result[<span class="hljs-number">0</span>]
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>Once the token is verified, the server updates the specified to-do in the database, using <code>COALESCE</code> to leave any fields unchanged if no new value is provided. The <code>updated_at</code> timestamp is refreshed automatically.</p>
<p>If the to-do doesn’t exist or the user doesn’t have permission to modify it, the tool returns an error message. Otherwise, it responds with the updated to-do in a clean JSON format:</p>
<p><strong>8.6. Delete To-Do Tool</strong></p>
<p>The <code>delete_todo</code> tool allows an authenticated user to remove a specific to-do. It first checks for a valid token and decodes it to identify the user. If the token is missing or invalid, the tool instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'delete_todo'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> { todoId } = args <span class="hljs-keyword">as</span> { todoId: <span class="hljs-built_in">number</span> };

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    DELETE FROM todos 
    WHERE id = <span class="hljs-subst">${todoId}</span> AND user_id = <span class="hljs-subst">${decoded.sub}</span>
    RETURNING *
  `</span>;

  <span class="hljs-keyword">if</span> (result.length === <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Todo not found or you do not have permission to delete it.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          message: <span class="hljs-string">'Todo deleted successfully'</span>,
          deletedTodo: result[<span class="hljs-number">0</span>]
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>This ensures users can only delete their own tasks, keeps the response structured for easy consumption in Cursor, and provides immediate confirmation of the deletion.</p>
<p><strong>8.7. Refresh Billing Status Tool</strong></p>
<p>The <code>refresh_billing_status</code> tool allows an authenticated user to force a fresh check of their billing status from Kinde. It first verifies that a valid token exists and decodes it to identify the user. If the token is missing or invalid, the tool instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'refresh_billing_status'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'🔄 Force refreshing billing status for user:'</span>, decoded.sub);
  <span class="hljs-keyword">const</span> billingStatus = <span class="hljs-keyword">await</span> getKindeBillingStatus(decoded.sub, token);

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          message: <span class="hljs-string">'Billing status refreshed successfully!'</span>,
          kindeBilling: {
            plan: billingStatus.plan,
            features: billingStatus.features,
            canCreate: billingStatus.canCreate,
            reason: billingStatus.reason,
            upgradeUrl: <span class="hljs-string">'&lt;https://learnflowai.kinde.com/portal&gt;'</span>,
            selfServicePortal: <span class="hljs-string">'&lt;https://learnflowai.kinde.com/portal&gt;'</span>,
            lastChecked: <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toISOString()
          }
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>This tool ensures that users always have an up-to-date view of their subscription status and usage limits, providing all necessary information to decide whether they need to upgrade their plan.</p>
<p><strong>8.8. Logout Tool</strong></p>
<p>The <code>logout</code> tool lets a user end their session by clearing the locally stored authentication token. When called, it checks if the token file exists and deletes it. If successful, the tool confirms that the user has been logged out.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'logout'</span>: {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">if</span> (fs.existsSync(TOKEN_FILE)) {
      fs.unlinkSync(TOKEN_FILE);
    }
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'✅ Logged out successfully. Authentication token cleared.'</span>,
        },
      ],
    ];
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'⚠️ Logout completed, but there was an issue clearing the token file.'</span>,
        },
      ],
    };
  }
}
</code></pre>
<p>This ensures that the session ends cleanly, preventing further use of the MCP server until the user logs in again. It also provides immediate feedback so the user knows the logout process succeeded or if there were minor issues.</p>
<h3 id="heading-9-full-serverts-file">9. Full <code>server.ts</code> File</h3>
<p>You can view the complete implementation of the <code>server.ts</code> file in the <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server/blob/main/src/server.ts">GitHub repo</a> and copy it directly into your project.</p>
<h3 id="heading-10-data-flow-amp-integration">10. Data Flow &amp; Integration</h3>
<p>Now that you’ve wired up authentication, database persistence, and billing checks, let’s step back and look at how everything fits together.</p>
<p>Authentication Flow:</p>
<ul>
<li><p>User clicks <strong>Login with Kinde</strong>.</p>
</li>
<li><p>They are redirected to the <strong>Kinde Auth URL.</strong></p>
</li>
<li><p>Kinde issues a <strong>JWT token</strong> after successful login.</p>
</li>
<li><p>The user copies this token and runs <em>“save_token: &lt;your-jwt-token&gt;”</em> in the Cursor Chat to store it.</p>
</li>
<li><p>The token is stored in the user’s session.</p>
</li>
<li><p>All future requests include the token for validation.</p>
</li>
</ul>
<p>To-do Flow:</p>
<ul>
<li><p>User sends a request (for example, to <em>“create todo”</em> to create a new todo).</p>
</li>
<li><p>The server checks the session for a valid token.</p>
</li>
<li><p>The server verifies the user’s billing plan and usage limits.</p>
</li>
<li><p>If valid, the request hits the <strong>Neon Postgres database</strong>.</p>
</li>
<li><p>Usage counters are updated and a success response is returned.</p>
</li>
</ul>
<p>Complete Data Flow:</p>
<pre><code class="lang-markdown">User Input 
   → MCP Server 
<span class="hljs-code">      → Authentication Check
         → Billing Check
            → Database Operation
               → Response</span>
</code></pre>
<p>Diagram:</p>
<pre><code class="lang-mermaid">flowchart LR
    A[User Input] --&gt; B[MCP Server]
    B --&gt; C{Authenticated?}
    C -- No --&gt; D[Reject Request]
    C -- Yes --&gt; E{Billing OK?}
    E -- No --&gt; F[Reject Request]
    E -- Yes --&gt; G[Database Operation]
    G --&gt; H[Update Usage + Return Response]
</code></pre>
<p>This overview shows how the different components work together. Each feature you’ve added (authentication, billing, and persistence) acts as a checkpoint in the request flow.</p>
<h3 id="heading-11-error-handling-amp-security">11. Error Handling &amp; Security</h3>
<p>Authentication Security:</p>
<ul>
<li><p>JWT Validation: Every request validates JWT token</p>
</li>
<li><p>User Isolation: Users can only access their own to-dos</p>
</li>
<li><p>Token Storage: Tokens are stored locally, not in your database</p>
</li>
</ul>
<p>Database Security:</p>
<ul>
<li><p>SQL Injection Prevention: Your MCP server uses parameterized queries</p>
</li>
<li><p>User Scoping: All queries are filtered by <code>user_id</code></p>
</li>
<li><p>Permission Checks: Every operation validates user ownership</p>
</li>
</ul>
<p>Error Handling:</p>
<ul>
<li><code>try/catch</code> returns safe error messages</li>
</ul>
<h3 id="heading-12-testing-amp-deployment">12. Testing &amp; Deployment</h3>
<p>Finally, start the MCP server:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">main</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> transport = <span class="hljs-keyword">new</span> StdioServerTransport();
  <span class="hljs-keyword">await</span> server.connect(transport);
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Todo MCP server running on stdio'</span>);
}

main().catch(<span class="hljs-function">(<span class="hljs-params">error</span>) =&gt;</span> {
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Fatal error in main():'</span>, error);
  process.exit(<span class="hljs-number">1</span>);
});
</code></pre>
<p>Test it by running:</p>
<pre><code class="lang-powershell">npm run dev
</code></pre>
<p>In Cursor, you can now try commands like:</p>
<pre><code class="lang-markdown">login
save<span class="hljs-emphasis">_token: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">your-jwt-token</span>&gt;</span></span>
list todos
create todo
refresh billing status
logout</span>
</code></pre>
<h2 id="heading-testing-the-complete-system"><strong>Testing the Complete System</strong></h2>
<h3 id="heading-1-start-the-services">1. Start the Services</h3>
<pre><code class="lang-powershell"><span class="hljs-comment"># Terminal 1: Start MCP Server</span>
npm run dev

<span class="hljs-comment"># Terminal 2: Start Kinde Auth Server</span>
npm run auth<span class="hljs-literal">-server</span>
</code></pre>
<h3 id="heading-2-configure-cursor-mcp">2. Configure Cursor MCP</h3>
<p>In your Cursor project:</p>
<ul>
<li><p>Go to Settings → Tools &amp; MCP → New MCP Server</p>
</li>
<li><p>Edit the <code>~/.cursor/mcp.json</code> and paste this code below</p>
</li>
</ul>
<pre><code class="lang-json">{
  <span class="hljs-attr">"mcpServers"</span>: {
    <span class="hljs-attr">"todo-mcp-server"</span>: {
      <span class="hljs-attr">"command"</span>: <span class="hljs-string">"node"</span>,
      <span class="hljs-attr">"args"</span>: [<span class="hljs-string">"dist/server.js"</span>],
      <span class="hljs-attr">"cwd"</span>: <span class="hljs-string">"/path/to/your/todo-mcp-server"</span>,
      <span class="hljs-attr">"env"</span>: {
        <span class="hljs-attr">"DATABASE_URL"</span>: <span class="hljs-string">"your-neon-connection-string"</span>,
        <span class="hljs-attr">"KINDE_ISSUER_URL"</span>: <span class="hljs-string">"&lt;https://your-domain.kinde.com&gt;"</span>,
        <span class="hljs-attr">"KINDE_CLIENT_ID"</span>: <span class="hljs-string">"your-client-id"</span>,
        <span class="hljs-attr">"KINDE_CLIENT_SECRET"</span>: <span class="hljs-string">"your-client-secret"</span>,
        <span class="hljs-attr">"JWT_SECRET"</span>: <span class="hljs-string">"your-jwt-secret-key"</span>,
        <span class="hljs-attr">"NODE_ENV"</span>: <span class="hljs-string">"development"</span>
      }
    }
  }
}
</code></pre>
<h3 id="heading-3-test-the-complete-flow">3. Test the Complete Flow</h3>
<p>Open your Cursor chat window and test MCP commands:</p>
<ul>
<li><p><em>login</em> → Get authentication URL</p>
</li>
<li><p><em>save_token</em> → Save your token gotten from Kinde</p>
</li>
<li><p><em>list to-dos</em> → List to-dos</p>
</li>
<li><p><em>create to-do</em> - Create a to-do</p>
</li>
<li><p><em>refresh billing status</em> - Check billing</p>
</li>
</ul>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Even with everything set up correctly, you might run into issues. Here are some common problems and how to fix them.</p>
<h3 id="heading-1-mcp-server-not-detected">1. MCP Server Not Detected</h3>
<p>If Cursor can’t see your server:</p>
<ul>
<li><p>Double-check the syntax of your <code>~/.cursor/mcp.json</code> file.</p>
</li>
<li><p>Make sure all file paths in <code>mcp.json</code> are <strong>absolute paths</strong> (not relative).</p>
</li>
<li><p>Restart Cursor after making changes to the config file.</p>
</li>
</ul>
<h3 id="heading-2-database-connection-issues">2. Database Connection Issues</h3>
<p>If your Neon database won’t connect:</p>
<ul>
<li><p>Confirm your <code>DATABASE_URL</code> environment variable is correctly formatted.</p>
</li>
<li><p>Log into the <a target="_blank" href="https://console.neon.tech">Neon dashboard</a> and make sure your database is active and not paused.</p>
</li>
<li><p>If you’re using SSL, verify that the SSL mode matches Neon’s connection settings.</p>
</li>
</ul>
<h3 id="heading-3-kinde-authentication-problems">3. Kinde Authentication Problems</h3>
<p>If login isn’t working as expected:</p>
<ul>
<li><p>In your <a target="_blank" href="https://app.kinde.com/admin">Kinde dashboard</a>, make sure the redirect URLs are set correctly (for example, <a target="_blank" href="http://localhost:3000"><code>http://localhost:3000</code></a>).</p>
</li>
<li><p>Double-check that your client ID and client secret are correct.</p>
</li>
<li><p>Ensure your auth server is running locally on port <code>3000</code> before attempting login.</p>
</li>
</ul>
<h3 id="heading-4-token-errors">4. Token Errors</h3>
<p>If you’re getting token-related errors:</p>
<ul>
<li><p>Confirm the token you’re saving is in JWT format (three dot-separated parts).</p>
</li>
<li><p>Make sure the token hasn’t expired.</p>
</li>
<li><p>Use the ID token provided by Kinde, not the access token.</p>
</li>
</ul>
<p>Following these steps should resolve most issues you’ll run into when setting up your MCP server with Cursor, Neon, and Kinde.</p>
<h2 id="heading-final-mcp-server-architecture">Final MCP Server Architecture</h2>
<pre><code class="lang-markdown">┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Cursor IDE    │    │   MCP Server     │    │  Kinde Auth     │
│                 │◄──►│                  │◄──►│   Server        │
│ - MCP Tools     │    │ - Todo CRUD      │    │ - OAuth Flow    │
│ - Chat Interface│    │ - Billing Check  │    │ - Token Storage │
└─────────────────┘    └──────────────────┘    └─────────────────┘
<span class="hljs-code">                                │
                                ▼
                       ┌─────────────────┐
                       │ Neon PostgreSQL │
                       │                 │
                       │ - Users Table   │
                       │ - Todos Table   │
                       │ - Billing Data  │
                       └─────────────────┘</span>
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You’ve just built a fully functional MCP server with:</p>
<ul>
<li><p><strong>Authentication</strong> → secure logins with Kinde</p>
</li>
<li><p><strong>Data persistence</strong> → to-dos stored in Neon</p>
</li>
<li><p><strong>Billing enforcement</strong> → usage limits + upgrade path</p>
</li>
<li><p><strong>Tool exposure</strong> → MCP tools accessible in Cursor</p>
</li>
</ul>
<p>This foundation is flexible enough to power more advanced apps while keeping the core flow simple and secure.</p>
<h3 id="heading-next-steps">Next Steps</h3>
<p>Here are some ideas to extend what you’ve built:</p>
<ul>
<li><p><strong>Role-based access control (RBAC):</strong> create admin vs normal user permissions (see my <a target="_blank" href="https://dev.to/sholajegede/part-1-master-authentication-and-role-based-access-control-rbac-with-kinde-and-convex-in-a-h3c">two-part RBAC guide</a> for reference).</p>
</li>
<li><p><strong>Billing tiers:</strong> offer free, pro, and enterprise plans with different limits.</p>
</li>
<li><p><strong>Features:</strong> add search, tags, or sharing to to-dos.</p>
</li>
<li><p><strong>Deployment:</strong> run the service on a cloud platform with HTTPS and a production-grade database.</p>
</li>
</ul>
<h3 id="heading-resources">Resources</h3>
<p>You can find the complete source code for this tutorial in <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server">this GitHub repository</a>. If it helped you in any way, consider giving it a star (⭐) to show your support!</p>
<p>Also, if you found this tutorial valuable, feel free to share it with others who might benefit from it. I’d really appreciate your thoughts, you can mention me on X <a target="_blank" href="https://x.com/wani_shola">@wani_shola</a> or <a target="_blank" href="https://www.linkedin.com/in/sholajegede">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Learn MCP Essentials and How to Create Secure Agent Interfaces with FastMCP ]]>
                </title>
                <description>
                    <![CDATA[ MCP is the standard rule set that allows AI agents, like GitHub Copilot and Gemini, to securely and intelligently interact with your databases, functions, and applications. We just published a course on the freeCodeCamp.org YouTube channel that will ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/learn-mcp-essentials-and-how-to-create-secure-agent-interfaces-with-fastmcp/</link>
                <guid isPermaLink="false">68efa0d05ce970770a3adb0d</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Wed, 15 Oct 2025 13:25:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1760534715163/1a67eac0-9857-4b66-b319-0cb945debf88.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>MCP is the standard rule set that allows AI agents, like GitHub Copilot and Gemini, to securely and intelligently interact with your databases, functions, and applications.</p>
<p>We just published a course on the freeCodeCamp.org YouTube channel that will teach you how to create Model-Context Protocol (or MCP) servers using the open source FastMCP Python library. Carlos Leon created this course.  </p>
<p>You'll learn what MCP is and why it matters. Then you'll dive into three hands-on coding projects.  </p>
<p>You'll build calculator apps using Standard I/O (STIO) and HTTP Stream, integrate APIs for dynamic content, and apply testing with GitHub Copilot.  </p>
<p>You’ll finish by deploying your MCP on FastMCP Cloud for a complete end-to-end workflow.</p>
<p>Here are the sections in the course:</p>
<ul>
<li><p>About MCP</p>
</li>
<li><p>Coding Scenario 1</p>
</li>
<li><p>Coding Scenario 2</p>
</li>
<li><p>Coding Scenario 3</p>
</li>
<li><p>Testing MCPs with Github Copilot</p>
</li>
<li><p>Bonus: Deploying a MCP on FastMCP Cloud</p>
</li>
</ul>
<p>Watch the full course on <a target="_blank" href="https://youtu.be/DosHnyq78xY">the freeCodeCamp.org YouTube channel</a> (1-hour watch).</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/DosHnyq78xY" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
