Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

KU Parking Assistant via Cloud Run MCP Endpoint

University of Kansas School of Business

In the previous notebook (KU_Parking_Assistant.ipynb), we built a parking assistant where every tool -- distance math, building lookup, color legend -- lived as a plain Python function inside the notebook. That works beautifully for a single-notebook demo, but it creates a problem the moment you want a second client to use the same tools. A Dify workflow, a mobile app, and a Cursor plugin would each need their own copy of the parking data and distance logic, and any update to the data (a new parking lot opens, coordinates get corrected) would require updating every client independently.

Model Context Protocol (MCP) solves this by moving the tools to a server. The server exposes a standard JSON-RPC 2.0 interface: any client can call tools/list to discover what tools are available, then tools/call to execute one. The protocol is transport-agnostic -- it works over HTTP, WebSockets, or stdio -- and it is the same standard that Claude Desktop, Cursor, and Dify use to integrate external tools. In this notebook, our MCP server is a Google Cloud Run container written in TypeScript, deployed to the cloud, and accessible at a single HTTPS URL.

What changes from the inline-tools version:

  • No hardcoded KU_BUILDINGS or KU_PARKING_LOTS dictionaries -- the data lives on the server

  • No local find_parking_near_building function -- we call it remotely over HTTPS

  • The same tool interface and the same agent ReAct loop -- only the transport changes

  • Any MCP-compatible client can call the same endpoint and get identical results

What is JSON-RPC? It is a lightweight remote procedure call protocol that uses JSON for encoding. Each request contains a method (like tools/call), a params object (the tool name and arguments), and an id so the client can match responses to requests. The server returns a JSON object with either a result or an error field. MCP builds on top of JSON-RPC by standardizing the method names (tools/list, tools/call) and the shape of tool descriptions, so any client and any server that speak MCP can interoperate without custom integration code.

KU_Parking_Assistant.ipynbKU_Parking_mcp.ipynb (this one)
Where do tools live?Inline Python functionsCloud Run container (TypeScript)
Where does data live?Hardcoded in the notebookHardcoded in the container
How does the agent call a tool?Direct Python callHTTP POST with JSON-RPC body
Runs where?100% in ColabLogic runs on Cloud Run, notebook is just a client
Can other apps use the tools?NoYes -- Dify, Cursor, web apps, etc.

Estimated run time: ~2 minutes (requires Cloud Run MCP endpoint)

!pip install -q git+https://github.com/KarAnalytics/llm_cascade.git

Imports and LLM Setup

We use llm_cascade for automatic LLM provider fallback and requests to speak JSON-RPC over HTTPS with the Cloud Run MCP endpoint. No local parking data, no distance math library — all of that lives on the server now.

import json as _json
import re as _re
import requests

from llm_cascade import get_cascade

llm = get_cascade()

# The deployed Cloud Run MCP endpoint URL
MCP_URL = 'https://ku-parking-mcp-1098133188018.us-central1.run.app'
LLM Cascade - available providers:
  + Gemini           model=gemini-2.5-flash
  + Ollama           model=kimi-k2.5:cloud
  + Groq             model=llama-3.3-70b-versatile
  + HuggingFace      model=meta-llama/Llama-3.3-70B-Instruct
  + Cohere           model=command-a-03-2025
  + OpenRouter       model=meta-llama/llama-3.3-70b-instruct:free
  + OpenAI           model=gpt-4o-mini
Not configured (skipped):
  - Grok (xAI)       (set XAI_API_KEY)

The MCP Client (a thin wrapper over requests)

One of the most liberating things about MCP is how simple the client side is. There is no SDK to install, no proto files to compile, no complex authentication dance. MCP communication is just JSON-RPC 2.0 over HTTP POST -- you send a JSON object with a method, params, and id, and the server replies with a JSON object containing either a result or an error. The two methods we care about are tools/list (discover what tools the server offers) and tools/call (execute a specific tool with arguments).

The mcp_call function below is the entire MCP client: it builds a JSON-RPC payload, POSTs it to the Cloud Run URL, and returns the parsed response. The mcp_tool convenience wrapper adds one more step -- it extracts the text content from the tool’s response, so callers get a clean string instead of the full JSON-RPC envelope. Compare this to the inline-tools notebook, where the “client” was a direct Python function call. The interface to the agent is identical; only the plumbing underneath has changed.

def mcp_call(method, params=None, request_id=1):
    '''Send a JSON-RPC 2.0 request to the MCP server.'''
    payload = {
        'jsonrpc': '2.0',
        'id': request_id,
        'method': method,
        'params': params or {},
    }
    response = requests.post(MCP_URL, json=payload, timeout=30)
    response.raise_for_status()
    return response.json()


def mcp_tool(name, **arguments):
    '''Call a named MCP tool and return its text content.'''
    result = mcp_call('tools/call', {
        'name': name,
        'arguments': arguments,
    })
    if 'error' in result:
        return f"MCP error: {result['error']['message']}"
    return result['result']['content'][0]['text']


print('MCP client ready.')
MCP client ready.

Discover the Tools (no LLM yet)

Before we build the agent, let’s ask the MCP server what tools it exposes. This demonstrates the tool discovery step of MCP -- a critical feature that sets it apart from ad-hoc HTTP APIs. In a traditional integration, the client must hardcode the endpoint URLs, parameter names, and response shapes. With MCP, the client calls tools/list once, receives a machine-readable schema for every available tool (name, description, parameter types), and uses that schema to construct valid calls dynamically.

This means that if someone adds a new tool to the server (say, find_parking_by_color), existing clients discover it automatically on their next tools/list call -- no code changes, no redeployment, no coordination between teams. It is the same principle that made REST APIs with self-describing schemas (like OpenAPI/Swagger) so successful, applied specifically to the problem of giving LLM agents access to external tools.

tools_response = mcp_call('tools/list')
tools = tools_response['result']['tools']

print(f'Discovered {len(tools)} tools on the MCP server:' + chr(10))
for t in tools:
    print(f"  - {t['name']}")
    desc = t.get('description', '').strip().split(chr(10))[0]
    print(f"      {desc}")
Discovered 3 tools on the MCP server:

  - list_ku_buildings
      List all KU buildings available for parking lookup. Returns a newline-separated list of building names.
  - find_parking_near_building
      Find the nearest parking lots within a given radius of a KU building. Approximate matching is supported -- informal names, abbreviations, and partial phrases all resolve to the right building. Examples: 'business school' -> Capitol Federal Hall (Business School); 'ambler', 'ambler gym', 'student gym', or 'fitness center' -> Ambler Student Recreation Fitness Center; 'the union' or 'student union' -> Kansas Union; 'the phog' -> Allen Fieldhouse. Returns a ranked list where each lot includes all applicable permit colors (slash-separated for multi-zone lots), a location hint, distance in miles, walk time, and a Google Maps pin URL.
  - get_parking_colors_legend
      Return the KU parking color legend, grouped by faculty/staff tiered permits, student housing permits, and everyone-else options (Visitor, Park & Ride, Garage). Sourced from parking.ku.edu.

Test One Tool Directly

Before wiring up the LLM agent, let’s call find_parking_near_building manually to verify the server works and returns the expected format. This is the same debugging practice we used in the inline-tools notebook -- always test your tools in isolation before letting an LLM loose on them. If the raw tool output looks wrong, no amount of prompt engineering will produce a correct final answer. Notice that the response format (lot name, color, distance, walk time, Google Maps URL) is identical to what the local Python function returned in the previous notebook, because the MCP server implements the same logic in TypeScript on the server side.

result = mcp_tool(
    'find_parking_near_building',
    building_name='business school',
    max_distance_miles=2.0,
    top_k=5,
)
print(result)
Parking lots within 2 miles of Capitol Federal Hall (Business School) (top 5, ranked by distance):

  1. Lot 118 (Blue) near E. Capitol Federal Hall - 0 mi (0 min walk) -> https://www.google.com/maps?q=38.953505,-95.24974
  2. Lot 90 (Blue/Red/Yellow) near Rec Center (Ambler SRFC) - 0.119 mi (2 min walk) -> https://www.google.com/maps?q=38.952512,-95.247929
  3. Lot 71 (Yellow) near S. Allen Fieldhouse - 0.143 mi (3 min walk) -> https://www.google.com/maps?q=38.953666,-95.252394
  4. Lot 70 (Red) near S. Allen Fieldhouse - 0.145 mi (3 min walk) -> https://www.google.com/maps?q=38.953906,-95.252394
  5. Lot 117 (Blue/Red/Yellow) near E. Watkins Health Center - 0.151 mi (3 min walk) -> https://www.google.com/maps?q=38.954,-95.247

The Agent (LLM that calls MCP tools)

Here is where the architectural elegance of MCP becomes visible. The ReAct-style agent loop below is structurally identical to the one in KU_Parking_Assistant.ipynb -- the LLM reads the question, decides which tool to call, outputs a JSON action, and we parse and execute it. The only difference is that instead of calling TOOLS[tool_name](tool_input) (a local Python function), we call mcp_tool(tool_name, building_name=tool_input) (an HTTPS request to Cloud Run). The LLM does not know or care where the tools live -- it sees the same tool descriptions in its system prompt and produces the same JSON actions. That separation of concerns is the whole point of a protocol: it decouples the agent’s reasoning from the tool’s implementation.

tool_block = chr(10).join(f"- {t['name']}: {t['description']}" for t in tools)

AGENT_SYSTEM = (
    'You are a helpful KU parking assistant. Tools available via MCP:' + chr(10)
    + tool_block + chr(10) + chr(10)
    + 'To use a tool, reply with ONLY this JSON (nothing else):' + chr(10)
    + '{"tool": "tool_name", "input": "the input string"}' + chr(10) + chr(10)
    + 'After a tool result, write a helpful final answer for the user that includes '
    + 'the ranked parking list, color meanings, and clickable Google Maps links. '
    + 'Pass the user\'s phrase through as-is to find_parking_near_building — the server '
    + 'handles approximate matching. Only call list_ku_buildings if the tool reports '
    + 'that no building matched.'
)


def run_parking_agent(question, verbose=True):
    '''ReAct loop: LLM picks a tool -> MCP executes it -> LLM formats the final answer.'''
    messages = [
        {'role': 'system', 'content': AGENT_SYSTEM},
        {'role': 'user', 'content': question},
    ]

    def flatten(msgs):
        return chr(10).join(f"{m['role'].upper()}: {m['content']}" for m in msgs)

    for step in range(5):
        prompt = flatten(messages[1:])  # everything except system
        response = llm.generate(prompt, system_prompt=AGENT_SYSTEM)
        reply = response.text.strip()

        # Try to parse a tool call JSON from the reply
        tool_call = None
        m = _re.search(r'\{[^{}]+\}', reply)
        if m:
            try:
                parsed = _json.loads(m.group())
                if 'tool' in parsed and 'input' in parsed:
                    tool_call = parsed
            except _json.JSONDecodeError:
                pass

        if tool_call is None:
            # No tool call -- this is the final answer
            if verbose:
                print(f'  [Step {step+1}] Final answer' + chr(10))
            return reply

        # Dispatch the tool call to the MCP server
        tool_name = tool_call['tool']
        tool_input = tool_call['input']
        if verbose:
            print(f'  [Step {step+1}] MCP call: {tool_name}({tool_input!r})')

        # Each tool takes a different argument name -- map them
        if tool_name == 'find_parking_near_building':
            result = mcp_tool(tool_name, building_name=tool_input)
        else:
            result = mcp_tool(tool_name)

        if verbose:
            preview = str(result).replace(chr(10), ' | ')[:150]
            print(f'              Result: {preview}...')

        messages.append({'role': 'assistant', 'content': reply})
        messages.append({'role': 'user', 'content': f'Tool result: {result}'})

    return 'Agent exceeded maximum steps.'


print('Parking agent ready (tools backed by Cloud Run MCP endpoint).')
Parking agent ready (tools backed by Cloud Run MCP endpoint).

Ask the Agent Some Questions

Each question runs through the agent loop. Notice the output shows MCP call: ... at each step -- that is the agent dispatching tool calls to the Cloud Run endpoint over HTTPS. The rest of the notebook never touches the parking data directly. If you compare the agent’s final answer here with the one from the inline-tools notebook, they should be nearly identical, because both notebooks call the same logical tools with the same data. The difference is entirely in the plumbing: local function calls versus remote JSON-RPC calls. Try uncommenting the additional questions to see how the agent handles different phrasing, visitor-specific queries, and requests for the color legend.

from IPython.display import display, Markdown

questions = [
    #'Where can I park near the business school?',
    # Approximate/informal names all resolve to the right canonical building:
    # 'Where can I park near the Ambler fitness center?',
    # 'I want to go to the student gym, where should I park?',
    # 'parking near Ambler gym',
    # 'parking close to the phog',
    # 'basketball stadium parking',
    # 'football stadium parking',
     'I need parking near the student union. What are my options?',
    # 'What colors of parking are available at KU and what do they mean?',
    # 'I am visiting KU and want to park near Allen Fieldhouse. Where should I go?',
]

for q in questions:
    display(Markdown('---'))
    display(Markdown(f'**Q:** {q}'))
    answer = run_parking_agent(q)
    # Render as Markdown so the Google Maps URLs become clickable links.
    display(Markdown(answer))
Loading...
Loading...
  [Response from Gemini / gemini-2.5-flash]
  [Step 1] MCP call: find_parking_near_building('student union')
              Result: Parking lots within 2 miles of Kansas Union (top 5, ranked by distance): |  |   1. Lot 16 (Gold) near E. Kansas Union - 0.028 mi (1 min walk) -> https...
  [Response from Gemini / gemini-2.5-flash]
  [Step 2] Final answer

Loading...

Using the Same MCP Server from Dify

The whole point of exposing tools via MCP is that other apps can use them too. This section walks you through connecting the same Cloud Run MCP endpoint (the one we just used from this notebook) to a Dify Workflow. You’ll build a no-code version of the parking assistant that calls the same find_parking_near_building tool.

Approximate building names work here too. The MCP server does fuzzy matching server-side, so whatever a Dify end user types -- “business school”, “Ambler gym”, “student rec center”, “the phog”, “basketball stadium” -- gets resolved to the correct canonical building before distances are computed. Your Dify workflow does not need to normalize or spell-check the user’s input.

What you need

  • A Dify account — sign up at cloud.dify.ai (free tier works)

  • At least one LLM provider configured in Dify (Settings → Model Provider)

  • The Cloud Run MCP URL: https://ku-parking-mcp-1098133188018.us-central1.run.app

Dify offers two ways to connect to the MCP server. Option A works on any Dify version; Option B is cleaner if your instance has the MCP plugin installed.


Option A — HTTP Request node (always works, no plugin needed)

This is the guaranteed path: Dify’s built-in HTTP Request node posts JSON to the Cloud Run URL and we parse the response.

Step 1: Create a Workflow app

  1. In Dify, click Studio → Create App → Workflow (not Chatflow, not Agent)

  2. Name it KU Parking via MCP

Step 2: Configure the Start node

  1. Click the Start node on the canvas

  2. Add an input variable:

    • Variable name: destination

    • Type: Text

    • Label: Which KU building are you visiting? (informal names like "Ambler gym", "student union", "the phog", or "business school" all work)

    • Required: Yes

Step 3: Add an HTTP Request node

  1. Click + after Start → HTTP Request

  2. Configure it:

    • Method: POST

    • URL: https://ku-parking-mcp-1098133188018.us-central1.run.app

    • Headers: Content-Type: application/json

    • Body type: JSON

    • Body:

      {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
          "name": "find_parking_near_building",
          "arguments": {
            "building_name": "{{#Start.destination#}}",
            "max_distance_miles": 2.0,
            "top_k": 5
          }
        }
      }
    • Note: pass {{#Start.destination#}} through as-is. The MCP server’s approximate matcher handles informal names, abbreviations, and partial phrases, so you do not need to normalize the input on the Dify side.

  3. Rename the node to Call MCP

Step 4: Add a Code node to extract the answer

The HTTP Request returns the full JSON-RPC response. We need to pull out result.content[0].text.

  1. Click + after the HTTP Request → Code

  2. Language: Python 3

  3. Input variable: response_body = Call_MCP.body

  4. Code:

    import json
    
    def main(response_body: str) -> dict:
        data = json.loads(response_body)
        if "result" in data and "content" in data["result"]:
            return {"parking_info": data["result"]["content"][0]["text"]}
        return {"parking_info": "MCP error: " + str(data.get("error", "unknown"))}
  5. Output variable: parking_info (String)

  6. Rename the node to Extract Result

Step 5: Add an LLM node to format the answer

  1. Click + after the Code node → LLM

  2. Model: pick whatever you have configured (gpt-4o-mini, gemini-2.5-flash, etc.)

  3. System prompt:

    You are a helpful KU parking assistant. The user asked about parking near a
    KU building. Below is the raw parking information returned from the KU Parking
    MCP server.
    
    The server does APPROXIMATE matching on building names, so whatever phrase
    the user typed (e.g., "business school", "Ambler gym", "student gym",
    "the phog", "basketball stadium", "student union") will have already been
    resolved to the correct canonical building -- do not second-guess the match.
    The first line of the parking information tells you which canonical building
    was resolved; echo that name back to the user so they can confirm the match.
    
    Present the result in a friendly, well-formatted response that includes the
    ranked parking list with permit color, distance, walk time, and clickable
    Google Maps links.
    
    IMPORTANT guardrails:
    - Do NOT mention "list_ku_buildings" or any tool/command name in your reply.
      Those tools are internal to this workflow and the end user has no way to
      invoke them directly.
    - If the server reports that no building matched, apologize briefly and ask
      the user to rephrase or try a nearby landmark (e.g., "try 'student union'
      or 'Ambler gym'"). Give a few example phrasings but do NOT suggest they
      run a command.
    - The MCP server already accepts informal names like "the phog",
      "basketball stadium", or "student gym". If the user appears to have typed
      the correct canonical name yet you still got a no-match error, suggest
      they try again and mention that approximate names work too.
  4. User prompt:

    User question: Where can I park near {{#Start.destination#}}?
    
    Parking information from the MCP server:
    {{#Extract_Result.parking_info#}}
    
    Please format a helpful response for the user. Remember: never mention
    list_ku_buildings or any other tool name; the user cannot invoke tools here.
  5. Rename the node to Format Answer

Step 6: Configure the End node

  1. Click the End node

  2. Output variable: Format_Answer.text

Step 7: Publish and test

  1. Click Publish (top right) → Update Publishing

  2. Click Run and try a few destinations that exercise the approximate matcher:

    • business school / school of business → Capitol Federal Hall (Business School)

    • Ambler gym, student gym, fitness center, the rec → Ambler Student Recreation Fitness Center

    • the union, student union → Kansas Union

    • the phog, basketball stadium, phog stadium → Allen Fieldhouse

    • football stadium, ku football → Memorial Stadium

    You should see the same ranked parking list you got in this notebook for each of these.


Option B — MCP plugin (cleaner if available)

If your Dify instance has the MCP SSE or MCP HTTP plugin installed (check under Tools → Install Tool and search for “mcp”):

  1. Install the MCP plugin

  2. Go to Tools and click the MCP plugin

  3. Add a new MCP server:

    • Name: ku-parking

    • URL: https://ku-parking-mcp-1098133188018.us-central1.run.app

    • Save

  4. Dify will call tools/list on the server and automatically discover the three tools

  5. In any Workflow, the three MCP tools now appear as native Dify tool nodes you can drag onto the canvas. You skip the HTTP Request + Code node dance entirely.

With Option B, your workflow becomes:

Start -> [MCP Tool: find_parking_near_building] -> LLM (format) -> End

Just three processing nodes, no manual JSON parsing. The MCP tool node accepts the raw user phrase as building_name and the server handles approximate matching -- same behavior as Option A, just a cleaner wiring. The same “do not mention tool names” guardrail still applies to the Format Answer LLM node.


Which option should you try?

  • Start with Option A if you’re not sure whether your Dify instance has the MCP plugin. The HTTP Request + Code node approach always works on cloud Dify and self-hosted Dify alike.

  • Upgrade to Option B once you confirm the plugin is available. It’s cleaner and shows off MCP’s real value — tool discovery happens automatically.

Either way, the same Cloud Run endpoint (the one we called from this notebook) powers the Dify app. Update the data in the container -- or add a new alias to BUILDING_ALIASES so that “b-school” or “the rec” also resolves correctly -- redeploy, and both this notebook and the Dify app see the new behavior immediately.


Key Takeaways

Same agent, different transport. The ReAct agent loop in this notebook is identical to the one in KU_Parking_Assistant.ipynb. The only change is that tool calls now dispatch via mcp_tool() — a tiny HTTPS wrapper — instead of calling local Python functions.

Write the tools once, use them everywhere. The Cloud Run container is the single source of truth for parking data. This notebook, Dify, Claude Desktop, a web app, and a mobile app can all call the same find_parking_near_building tool and get identical results.

Update data in one place. Edit KU_PARKING_LOTS in infra/ku-parking-mcp/cloudrun/index.ts, redeploy from that folder with gcloud run deploy ku-parking-mcp --source . --region us-central1 --allow-unauthenticated, and every client sees the new data on its next request. No client code changes, no notebook reruns needed.

Tool discovery is automatic. Clients don’t hardcode tool names. They call tools/list first, receive the server’s schema, and use that to build their prompts. If you add a new tool to the container, existing clients discover it without code changes.

Exercises

  • Add a new tool to the container (e.g., find_parking_by_color that filters to one permit color). Redeploy and see the new tool appear automatically in tools/list.

  • Update a building coordinate in the container and watch this notebook pick up the new value without any local changes.

  • Build the Dify version following Option A above and compare its output with this notebook’s agent. Do they agree?

  • Add authentication. Drop the --allow-unauthenticated flag when redeploying, then update mcp_call to send an Authorization: Bearer <id-token> header (get the token via gcloud auth print-identity-token). Now the endpoint is no longer public.

  • Add caching. In mcp_call, cache the response of tools/list so the client only discovers tools once per session instead of on every run.


Run the code

To run this notebook, copy the URL below into your browser’s address bar. The link opens the notebook directly in Google Colab. (If your PDF viewer makes the URL clickable and lands on a broken page, copy the full text manually -- the viewer may have truncated the link at a line break.)

https://colab.research.google.com/github/KarAnalytics/code_demos/blob/main/KU_Parking_mcp.ipynb