<?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" version="2.0"><channel><title><![CDATA[disha]]></title><description><![CDATA[disha]]></description><link>https://dishaholmukhe.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 10:48:46 GMT</lastBuildDate><atom:link href="https://dishaholmukhe.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I built a data pipeline on real government data (Part 1): from messy API to clean database]]></title><description><![CDATA[A step-by-step walkthrough of pulling India's government crop price data from an API, cleaning it, and storing it in a clean database.]]></description><link>https://dishaholmukhe.hashnode.dev/bharat-data-pulse-part-1</link><guid isPermaLink="true">https://dishaholmukhe.hashnode.dev/bharat-data-pulse-part-1</guid><category><![CDATA[data-engineering]]></category><category><![CDATA[api]]></category><category><![CDATA[Python]]></category><category><![CDATA[MySQL]]></category><category><![CDATA[avien]]></category><category><![CDATA[Beginner Developers]]></category><dc:creator><![CDATA[Disha Holmukhe]]></dc:creator><pubDate>Thu, 05 Nov 2026 06:05:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/666f1cd8616ad3a75c77e0df/9cf207a2-c1cc-423d-a98b-529556174706.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I wanted a project that used <em>real</em> data, not a tutorial dataset someone already cleaned for me.</p>
<p>So I picked India's government crop market prices. This is the walkthrough of how I got from "a messy API" to "a clean database", step by step.</p>
<h2>What I built?</h2>
<p><strong>Bharat Data Pulse</strong> takes daily crop prices from India's open data portal and turns them into something a farmer or trader could actually use.</p>
<p>Part 1 is the first half: <strong>get the data → clean it → store it properly.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/666f1cd8616ad3a75c77e0df/251f8934-3005-4a4b-a5e0-5ebb717ceb3c.png" alt="Screenshot 2026-11-05 104301" style="display:block;margin:0 auto" />

<h2>Step 1: pick a data source that's real and a bit annoying</h2>
<p>I used the "Variety-wise Daily Market Prices" dataset on <a href="http://data.gov.in">data.gov.in</a>. Each row is one price report:</p>
<ul>
<li>state, district, market</li>
<li>commodity (like onion or tomato), variety, grade</li>
<li>minimum, maximum and "modal" price (the most common price that day)</li>
<li>the date</li>
</ul>
<p>I chose it <em>because</em> it's messy. Messy data is where you actually learn.</p>
<h2>Step 2: pull the data (it comes in pages)</h2>
<p>APIs usually don't give you everything at once. They give you data in <strong>pages</strong>.</p>
<p>You tell the API two things:</p>
<ul>
<li><strong>limit:</strong> how many rows per page</li>
<li><strong>offset:</strong> how many rows to skip</li>
</ul>
<p>The API also tells you the <strong>total</strong> number of rows. So the plan is simple:</p>
<ol>
<li>Ask once to find out the total</li>
<li>Work out how many pages that is</li>
<li>Loop through the pages and collect every row</li>
</ol>
<p>I also filtered by date, one day at a time.</p>
<h2>Step 3: clean the data (this is where it got real)</h2>
<p>Two things surprised me.</p>
<p><strong>Surprise 1: prices were sometimes text, sometimes numbers.</strong> The same field showed <code>1700</code> in one year and <code>"1600"</code> in another. If you try to do maths on text, things break quietly.</p>
<p><strong>Surprise 2: dates were day-first.</strong><code>02/01/2026</code> means 2 January here. Many tools read it as 1 February. That's a silent, nasty bug.</p>
<p>My fix was two tiny functions that run on <em>every</em> value, so I never have to guess:</p>
<pre><code class="language-python">def numeric_conversion(value):
    if isinstance(value, (int, float)):
        return round(Decimal(value), 3)
    return round(Decimal(re.findall(r"\d+[.]?\d+", value)[0]), 3)

def date_conversion(text):
    return datetime.strptime(text, "%d/%m/%Y").date()
</code></pre>
<p><strong>Rule I follow now:</strong> convert everything the same way, every time, instead of only fixing the rows that look broken.</p>
<h2>Step 4: design the database</h2>
<p>I split the data into <strong>three tables</strong>, instead of one giant table.</p>
<ul>
<li><strong>dim_location:</strong> one row per market (market, district, state)</li>
<li><strong>dim_commodity:</strong> one row per commodity (name, code)</li>
<li><strong>fact_daily_price:</strong> one row per price report (the prices, grade, variety, date, plus the IDs of the market and commodity it belongs to)</li>
</ul>
<p><strong>Why split it?</strong> Without this, "Lasalgaon APMC, Nashik, Maharashtra" would be copied into thousands of rows. Now it's stored once, and every price row just points to it with a number.</p>
<p>Tiny example:</p>
<table>
<thead>
<tr>
<th>price_id</th>
<th>location_id</th>
<th>commodity_id</th>
<th>modal_price</th>
<th>arrival_date</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>1</td>
<td>1</td>
<td>1300</td>
<td>2026-01-01</td>
</tr>
<tr>
<td>2</td>
<td>2</td>
<td>1</td>
<td>1550</td>
<td>2026-01-01</td>
</tr>
<tr>
<td>3</td>
<td>1</td>
<td>1</td>
<td>1200</td>
<td>2026-01-02</td>
</tr>
</tbody></table>
<p><code>location_id = 1</code> appears twice: same market, two different days.</p>
<p><strong>One thing I got wrong at first:</strong> I assumed market + commodity + date would be a unique key. It isn't. A market can report several different prices for the same product on the same day. So each price row gets its own ID.</p>
<p><strong>Markets and commodities are stored once. Every price row just points to them.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/666f1cd8616ad3a75c77e0df/be73bbfe-de11-498a-b30a-4e9fdc7e6ab3.png" alt="deepseek_mermaid_20261105_cd9059" style="display:block;margin:0 auto" />

<h2>Step 5: load the data without making a mess</h2>
<p>Loading happens in a specific order, because price rows need the market and commodity IDs to exist first.</p>
<ol>
<li><strong>Find-or-create each market.</strong> Is it already in the table? Use its ID. If not, add it and use the new ID.</li>
<li><strong>Find-or-create each commodity.</strong> Same idea.</li>
<li><strong>Insert the price rows.</strong> Skip any row that's already in the table.</li>
</ol>
<p>That "skip if already there" rule matters. It means I can run the whole thing twice and get <strong>zero duplicates</strong>. (If you're building something that runs daily, you want this.)</p>
<p>For big files, I insert rows in batches instead of one at a time. My full load approximately went from 3 hours to 25 minutes.</p>
<h2>Step 6: check your work</h2>
<p>I don't trust "it ran without errors." I check:</p>
<ul>
<li><strong>Row counts:</strong> does the number of rows in the database match the number in my source file?</li>
<li><strong>Run it twice:</strong> does the count stay the same?</li>
<li><strong>Look at real rows:</strong> do the prices and dates look right?</li>
</ul>
<p>If you do only one thing from this article, do this. It caught problems I would have missed.</p>
<h2>What I'd do differently</h2>
<ul>
<li>Look at the raw data for an hour <em>before</em> designing anything</li>
<li>Test on a big file sooner</li>
<li>Write the checks as I go, not after</li>
</ul>
<p><strong>Next:</strong> Part 2 is about making it run by itself every day, turning the data into answers, and putting a live dashboard on the internet.</p>
<p><strong>Repo:</strong> <a href="https://github.com/Kiwi-520/bharat_data_pulse.git">Github</a>· <strong>Live dashboard:</strong> <a href="https://bharatdatapulse-dishaholmukhe.streamlit.app/">Demo link</a></p>
]]></content:encoded></item><item><title><![CDATA[How I Built a Local MCP Server to Control Your Jupyter Notebooks]]></title><description><![CDATA[A step-by-step guide to building a Flask-based MCP (Model Context Protocol) server that runs locally to safely edit any notebook on your system.]]></description><link>https://dishaholmukhe.hashnode.dev/how-i-built-a-local-mcp-server-to-control-your-jupyter-notebooks</link><guid isPermaLink="true">https://dishaholmukhe.hashnode.dev/how-i-built-a-local-mcp-server-to-control-your-jupyter-notebooks</guid><category><![CDATA[Python]]></category><category><![CDATA[Jupyter Notebook ]]></category><category><![CDATA[APIs]]></category><category><![CDATA[Flask Framework]]></category><category><![CDATA[mcp]]></category><category><![CDATA[System Design]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Disha Holmukhe]]></dc:creator><pubDate>Tue, 04 Nov 2025 07:00:45 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-introduction-the-problem">Introduction: The Problem</h2>
<p>As a data scientist, your Jupyter Notebook is your castle. But what happens when you want an <em>external</em> program to control it?</p>
<p>Maybe you want an AI agent (like Gemini or Claude) to add new cells, or a script to automatically clear all outputs before a commit. You're usually stuck manually copying and pasting, or trying to parse the messy JSON of the <code>.ipynb</code> file yourself.</p>
<p><a target="_blank" href="https://github.com/Kiwi-520/mcp-jupyter-server.git">You can find the full, ready-to-use code for this project on my GitHub.</a></p>
<p>In this guide, we'll solve this by building a robust <strong>MCP (Model Context Protocol) Server</strong>. This is a server that speaks a standard "language" (defined by an OpenAPI schema) that AIs and other tools can use. And we'll make one key design choice: we'll run it <em>locally</em>.</p>
<h2 id="heading-the-why-a-local-mcp-server">The "Why": A Local MCP Server</h2>
<p>An MCP server is just a standard way for a model (like Gemini) to talk to a tool (like our server). My first idea was to deploy this as a public server on a platform like Render. But I hit a major security wall: <strong>a public web server <em>cannot</em> access your local files</strong> (like <code>C:\Users\YourName\Documents</code>). And for good reason!</p>
<p>The crucial moment was realizing the <em>architecture</em> is the key. We will build a <strong>true MCP server</strong>, but we will run it <strong>locally</strong>.</p>
<p>A local server (at <code>127.0.0.1</code>) <em>does</em> have permission to access your local files. This makes it the perfect, secure architecture for a developer tool. As we'll see, you can still connect a cloud-based AI to it using a secure "tunnel" (like <code>ngrok</code>), which is a very impressive system to build.</p>
<h2 id="heading-the-step-by-step-guide">The Step-by-Step Guide</h2>
<p>We'll build this server from the ground up. Our API will have endpoints to <code>create</code>, <code>edit</code>, <code>delete</code>, <code>move</code>, and <code>clear</code> cells.</p>
<h3 id="heading-step-1-the-smart-helper-functions">Step 1: The "Smart" Helper Functions</h3>
<p>All the MCP server code will be written in the <code>server.py</code> file.</p>
<p>Before we build the API, we need two powerful helper functions.</p>
<p>First, a function to safely write our notebook. We'll use an "atomic write" (writing to a temporary file, then renaming) to make sure our notebook <em>never</em> gets corrupted, even if the script fails mid-save.</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> flask <span class="hljs-keyword">import</span> Flask, jsonify, request
<span class="hljs-keyword">import</span> nbformat
<span class="hljs-keyword">import</span> os
<span class="hljs-keyword">import</span> tempfile

app = Flask(__name__)

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">safe_write_notebook</span>(<span class="hljs-params">nb, notebook_name</span>):</span>
    <span class="hljs-string">"""Atomically writes a notebook to the specified file."""</span>
    temp_path = <span class="hljs-string">""</span>
    <span class="hljs-keyword">try</span>:
        <span class="hljs-keyword">with</span> tempfile.NamedTemporaryFile(mode=<span class="hljs-string">'w'</span>, delete=<span class="hljs-literal">False</span>, dir=os.path.dirname(notebook_name) <span class="hljs-keyword">or</span> <span class="hljs-string">'.'</span>) <span class="hljs-keyword">as</span> f:
            nbformat.write(nb, f)
            temp_path = f.name
       <span class="hljs-comment"># This rename is "atomic" - it happens instantly</span>
        os.rename(temp_path, notebook_name)
    <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
        <span class="hljs-comment"># If any error occurs, clean up the temp file</span>
        <span class="hljs-keyword">if</span> temp_path <span class="hljs-keyword">and</span> os.path.exists(temp_path):
            os.remove(temp_path)
        <span class="hljs-keyword">raise</span> e
</code></pre>
<p>Second, a smart function to load our notebook. What if the file doesn't exist? What if it's empty or corrupted? This function handles all of that.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_or_create_notebook</span>(<span class="hljs-params">notebook_name</span>):</span>
    <span class="hljs-string">"""Reads a notebook file. If it doesn't exist, creates a valid, empty one."""</span>
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> os.path.exists(notebook_name):
        print(<span class="hljs-string">f"Notebook '<span class="hljs-subst">{notebook_name}</span>' not found. Creating a new one."</span>)
        <span class="hljs-comment"># Create a new, empty notebook in memory</span>
        nb = nbformat.v4.new_notebook()
        <span class="hljs-keyword">with</span> open(notebook_name, <span class="hljs-string">'w'</span>) <span class="hljs-keyword">as</span> f:
            nbformat.write(nb, f)

    <span class="hljs-keyword">with</span> open(notebook_name, <span class="hljs-string">'r'</span>) <span class="hljs-keyword">as</span> f:
        <span class="hljs-keyword">try</span>:
            <span class="hljs-keyword">return</span> nbformat.read(f, as_version=<span class="hljs-number">4</span>)
        <span class="hljs-keyword">except</span> nbformat.reader.NotJSONError:
        <span class="hljs-comment"># If the file is empty or corrupt, return a new in-memory notebook</span>
            print(<span class="hljs-string">f"Warning: '<span class="hljs-subst">{notebook_name}</span>' is empty or corrupted. Initializing a new notebook."</span>)
            nb = nbformat.v4.new_notebook()
            <span class="hljs-keyword">return</span> nb
</code></pre>
<h3 id="heading-step-2-building-the-flask-server-endpoints">Step 2: Building the Flask Server Endpoints</h3>
<p>Now that we have our helpers, building the API is clean and simple. We just need to import Flask and set up our routes.</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> flask <span class="hljs-keyword">import</span> Flask, jsonify, request
<span class="hljs-keyword">from</span> nbformat.v4 <span class="hljs-keyword">import</span> new_code_cell, new_markdown_cell
</code></pre>
<p>Let's build the <code>/create_cell</code> endpoint. It will:</p>
<ol>
<li><p>Get the <code>notebook_name</code>, <code>content</code>, and <code>cell_type</code> from the JSON request.</p>
</li>
<li><p>Call our <code>get_or_create_notebook</code> helper.</p>
</li>
<li><p>Create and append the new cell.</p>
</li>
<li><p>Call our <code>safe_write_notebook</code> helper to save.</p>
</li>
</ol>
<pre><code class="lang-python"><span class="hljs-meta">@app.route('/create_cell', methods=['POST'])</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">create_cell</span>():</span>
    <span class="hljs-keyword">try</span>:
        data = request.get_json()
        <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> data <span class="hljs-keyword">or</span> <span class="hljs-string">'notebook_name'</span> <span class="hljs-keyword">not</span> <span class="hljs-keyword">in</span> data <span class="hljs-keyword">or</span> <span class="hljs-string">'cell_type'</span> <span class="hljs-keyword">not</span> <span class="hljs-keyword">in</span> data <span class="hljs-keyword">or</span> <span class="hljs-string">'content'</span> <span class="hljs-keyword">not</span> <span class="hljs-keyword">in</span> data:
            <span class="hljs-keyword">return</span> jsonify({<span class="hljs-string">'error'</span>: <span class="hljs-string">'Missing notebook_name, cell_type, or content'</span>}), <span class="hljs-number">400</span>

        notebook_name = data[<span class="hljs-string">'notebook_name'</span>]
        cell_type = data[<span class="hljs-string">'cell_type'</span>]
        content = data[<span class="hljs-string">'content'</span>]

       <span class="hljs-comment"># Load the notebook, or create it if it doesn't exist</span>
        nb = get_or_create_notebook(notebook_name)

        <span class="hljs-comment"># Create the new cell object based on type</span>
        <span class="hljs-keyword">if</span> cell_type == <span class="hljs-string">'code'</span>:
            new_cell = nbformat.v4.new_code_cell(content)
        <span class="hljs-keyword">elif</span> cell_type == <span class="hljs-string">'markdown'</span>:
            new_cell = nbformat.v4.new_markdown_cell(content)
        <span class="hljs-keyword">else</span>:
            <span class="hljs-keyword">return</span> jsonify({<span class="hljs-string">'error'</span>: <span class="hljs-string">'Invalid cell_type. Must be "code" or "markdown"'</span>}), <span class="hljs-number">400</span>

       <span class="hljs-comment"># Add the new cell to the notebook</span>
        nb.cells.append(new_cell)

        <span class="hljs-comment"># Save the changes back to the file</span>
        safe_write_notebook(nb, notebook_name)
        <span class="hljs-keyword">return</span> jsonify({<span class="hljs-string">'message'</span>: <span class="hljs-string">f'Cell created successfully in <span class="hljs-subst">{notebook_name}</span>'</span>, <span class="hljs-string">'total_cells'</span>: len(nb.cells)}), <span class="hljs-number">200</span>

    <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
        <span class="hljs-keyword">return</span> jsonify({<span class="hljs-string">'error'</span>: str(e)}), <span class="hljs-number">500</span>
</code></pre>
<p>The <code>/edit_cell</code> endpoint follows the same pattern. The only difference is the "modify" step, where we add validation. This code checks if the <code>cell_number</code> is within the valid range of the notebook. If it's out of range, it returns an error; otherwise, it edits the cell and saves.</p>
<pre><code class="lang-python"><span class="hljs-meta">@app.route('/edit_cell', methods=['POST'])</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">edit_cell</span>():</span>
    <span class="hljs-keyword">try</span>:
        data = request.get_json()
        <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> data <span class="hljs-keyword">or</span> <span class="hljs-string">'notebook_name'</span> <span class="hljs-keyword">not</span> <span class="hljs-keyword">in</span> data <span class="hljs-keyword">or</span> <span class="hljs-string">'cell_number'</span> <span class="hljs-keyword">not</span> <span class="hljs-keyword">in</span> data <span class="hljs-keyword">or</span> <span class="hljs-string">'content'</span> <span class="hljs-keyword">not</span> <span class="hljs-keyword">in</span> data:
            <span class="hljs-keyword">return</span> jsonify({<span class="hljs-string">'error'</span>: <span class="hljs-string">'Missing notebook_name, cell_number, or content'</span>}), <span class="hljs-number">400</span>

        notebook_name = data[<span class="hljs-string">'notebook_name'</span>]
        cell_number = data[<span class="hljs-string">'cell_number'</span>]
        content = data[<span class="hljs-string">'content'</span>]

        <span class="hljs-comment"># Load the notebook</span>
        nb = get_or_create_notebook(notebook_name)

        <span class="hljs-comment"># --- Cell Index Validation ---</span>
        <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> isinstance(cell_number, int) <span class="hljs-keyword">or</span> <span class="hljs-keyword">not</span> (<span class="hljs-number">0</span> &lt;= cell_number &lt; len(nb.cells)):
            <span class="hljs-keyword">return</span> jsonify({<span class="hljs-string">'error'</span>: <span class="hljs-string">f'Cell number <span class="hljs-subst">{cell_number}</span> is out of range for <span class="hljs-subst">{notebook_name}</span>'</span>}), <span class="hljs-number">400</span>

        <span class="hljs-comment"># Update the cell's source content</span>
        nb.cells[cell_number][<span class="hljs-string">'source'</span>] = content

        <span class="hljs-comment"># Save the modified notebook</span>
        safe_write_notebook(nb, notebook_name)
        <span class="hljs-keyword">return</span> jsonify({<span class="hljs-string">'message'</span>: <span class="hljs-string">f'Cell <span class="hljs-subst">{cell_number}</span> in <span class="hljs-subst">{notebook_name}</span> edited successfully'</span>}), <span class="hljs-number">200</span>

    <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
        <span class="hljs-keyword">return</span> jsonify({<span class="hljs-string">'error'</span>: str(e)}), <span class="hljs-number">500</span>
</code></pre>
<p><em>(You can add the</em> <code>delete_cell</code>, <code>move_cell</code>, and <code>clear_notebook</code> functions here following the same pattern.)</p>
<h3 id="heading-step-3-creating-the-menu-the-openapiyaml-file">Step 3: Creating the "Menu" (The openapi.yaml File)</h3>
<p>We've built the "engine" (server.py), but now we need the "user manual." In the world of MCP, this is an OpenAPI (formerly Swagger) file.</p>
<p>This simple text file, openapi.yaml, describes our API in a standard format that any AI or tool can understand. It tells the AI: "Here are my functions, like /create_cell, and here are the 'ingredients' they need, like notebook_name and content."</p>
<p>Create a new file named openapi.yaml in the same project folder and add this code:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">openapi:</span> <span class="hljs-number">3.0</span><span class="hljs-number">.0</span>
<span class="hljs-attr">info:</span>
  <span class="hljs-attr">title:</span> <span class="hljs-string">Local</span> <span class="hljs-string">Jupyter</span> <span class="hljs-string">Notebook</span> <span class="hljs-string">MCP</span> <span class="hljs-string">Server</span>
  <span class="hljs-attr">version:</span> <span class="hljs-number">2.0</span><span class="hljs-number">.0</span>
  <span class="hljs-attr">description:</span> <span class="hljs-string">An</span> <span class="hljs-string">API</span> <span class="hljs-string">to</span> <span class="hljs-string">programmatically</span> <span class="hljs-string">control</span> <span class="hljs-string">local</span> <span class="hljs-string">Jupyter</span> <span class="hljs-string">Notebook</span> <span class="hljs-string">files.</span>
<span class="hljs-attr">paths:</span>
  <span class="hljs-string">/create_cell:</span>
    <span class="hljs-attr">post:</span>
      <span class="hljs-attr">summary:</span> <span class="hljs-string">Creates</span> <span class="hljs-string">a</span> <span class="hljs-string">new</span> <span class="hljs-string">cell</span> <span class="hljs-string">in</span> <span class="hljs-string">a</span> <span class="hljs-string">specified</span> <span class="hljs-string">notebook.</span>
      <span class="hljs-attr">description:</span> <span class="hljs-string">If</span> <span class="hljs-string">the</span> <span class="hljs-string">notebook</span> <span class="hljs-string">does</span> <span class="hljs-string">not</span> <span class="hljs-string">exist,</span> <span class="hljs-string">it</span> <span class="hljs-string">will</span> <span class="hljs-string">be</span> <span class="hljs-string">created.</span>
      <span class="hljs-attr">requestBody:</span>
        <span class="hljs-attr">required:</span> <span class="hljs-literal">true</span>
        <span class="hljs-attr">content:</span>
          <span class="hljs-attr">application/json:</span>
            <span class="hljs-attr">schema:</span>
              <span class="hljs-attr">type:</span> <span class="hljs-string">object</span>
              <span class="hljs-attr">properties:</span>
                <span class="hljs-attr">notebook_name:</span>
                  <span class="hljs-attr">type:</span> <span class="hljs-string">string</span>
                  <span class="hljs-attr">description:</span> <span class="hljs-string">"The path to the notebook file (e.g., 'my_notebook.ipynb')."</span>
                <span class="hljs-attr">content:</span>
                  <span class="hljs-attr">type:</span> <span class="hljs-string">string</span>
                  <span class="hljs-attr">description:</span> <span class="hljs-string">"The content for the new cell."</span>
                <span class="hljs-attr">cell_type:</span>
                  <span class="hljs-attr">type:</span> <span class="hljs-string">string</span>
                  <span class="hljs-attr">description:</span> <span class="hljs-string">"Type of cell to create ('code' or 'markdown')."</span>
                  <span class="hljs-attr">default:</span> <span class="hljs-string">'code'</span>
              <span class="hljs-attr">required:</span>
                <span class="hljs-bullet">-</span> <span class="hljs-string">notebook_name</span>
      <span class="hljs-attr">responses:</span>
        <span class="hljs-attr">'200':</span>
          <span class="hljs-attr">description:</span> <span class="hljs-string">Cell</span> <span class="hljs-string">created</span> <span class="hljs-string">successfully</span>
</code></pre>
<p>This small snippet clearly defines the function, its inputs (like notebook_name and content), and what to expect as a response. We would create similar definitions for /edit_cell, /delete_cell, and all our other functions.</p>
<p>You can see the complete, final openapi.yaml file in the project's GitHub repository.</p>
<h3 id="heading-step-4-running-and-testing-your-server">Step 4: Running and Testing Your Server</h3>
<p>Finally, we just need to run our server. Add this to the bottom of your <a target="_blank" href="http://server.py"><code>server.py</code></a> file:</p>
<pre><code class="lang-python"><span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">'__main__'</span>:
    <span class="hljs-comment"># This is the corrected line:</span>
    print(<span class="hljs-string">"Starting local notebook server on http://127.0.0.1:5001"</span>)
    app.run(debug=<span class="hljs-literal">True</span>, port=<span class="hljs-number">5001</span>)
</code></pre>
<p>Now, run your server from your terminal:</p>
<pre><code class="lang-python">python server.py
</code></pre>
<p>Open a <strong>second</strong> terminal and send a <code>curl</code> request. This command will create a brand new notebook <code>my_test.ipynb</code> and add a markdown cell to it.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># This is the corrected command:</span>
curl -X POST -H <span class="hljs-string">"Content-Type: application/json"</span> -d <span class="hljs-string">"{\"notebook_name\": \"my_test.ipynb\", \"content\": \"# Hello Server!\", \"cell_type\": \"markdown\"}"</span> http://127.0.0.1:5001/create_cell
</code></pre>
<p>Check your folder. You'll see <code>my_test.ipynb</code> has been created!</p>
<h3 id="heading-step-5-connecting-your-local-server-to-a-cloud-ai-this-is-the-final-most-powerful-step-how-do-we-let-a-cloud-based-ai-like-gemini-talk-to-our-private-local-server">Step 5: Connecting Your Local Server to a Cloud AI This is the final, most powerful step. How do we let a cloud-based AI (like Gemini) talk to our private local server?</h3>
<p>We use a free tool called ngrok to create a secure "tunnel."</p>
<p>Leave your server running in Terminal 1.</p>
<p>Install ngrok[https://ngrok.com/] (it's a single file download) and sign up for a free account.
Follow the insturtcions as per given on the website. </p>
<p>Open a second terminal and run this command:</p>
<pre><code class="lang-bash">ngrok http 5001
</code></pre>
<p>ngrok will give you a public URL, like this:</p>
<pre><code class="lang-bash">Forwarding https://&lt;some-random-name&gt;.ngrok.io -&gt; http://localhost:5001
</code></pre>
<p>This https... URL is now the public address for your local server.</p>
<p>Now, you can go to an AI platform like Google AI Studio or the Anthropic Console:</p>
<p>Find the section for "Tools" or "Tool Use."</p>
<p>Give it your openapi.yaml file (the "menu" for your API).</p>
<p>When it asks for the Server URL or "Base URL," paste in your new ngrok URL.</p>
<p>Now, when you ask the AI, "Hey, use my notebook tool to create a new cell in my_test.ipynb," the AI will send its request to the public ngrok URL, which will forward it to your local server.py, which will then edit your local file.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>We just built a complete, robust local MCP server from scratch. This is more than just a script; it's a real piece of software architecture.</p>
<p>But a server is only as good as the tools that use it.</p>
<p>This project opens up many exciting possibilities. The next logical step would be to build a custom AI agent that uses this exact server to intelligently manage, refactor, and create new notebooks.
If you enjoyed this project and want to see more content on building developer tools and AI agents, be sure to follow me here on Hashnode!</p>
<p>What's the first automation script you'll build with this? Let me know in the comments below!</p>
]]></content:encoded></item></channel></rss>