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_BUILDINGSorKU_PARKING_LOTSdictionaries -- the data lives on the serverNo local
find_parking_near_buildingfunction -- we call it remotely over HTTPSThe 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.ipynb | KU_Parking_mcp.ipynb (this one) | |
|---|---|---|
| Where do tools live? | Inline Python functions | Cloud Run container (TypeScript) |
| Where does data live? | Hardcoded in the notebook | Hardcoded in the container |
| How does the agent call a tool? | Direct Python call | HTTP POST with JSON-RPC body |
| Runs where? | 100% in Colab | Logic runs on Cloud Run, notebook is just a client |
| Can other apps use the tools? | No | Yes -- 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)) [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
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¶
In Dify, click Studio → Create App → Workflow (not Chatflow, not Agent)
Name it
KU Parking via MCP
Step 2: Configure the Start node¶
Click the Start node on the canvas
Add an input variable:
Variable name:
destinationType: 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¶
Click + after Start → HTTP Request
Configure it:
Method:
POSTURL:
https://ku-parking-mcp-1098133188018.us-central1.run.appHeaders:
Content-Type: application/jsonBody type:
JSONBody:
{ "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.
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.
Click + after the HTTP Request → Code
Language: Python 3
Input variable:
response_body=Call_MCP.bodyCode:
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"))}Output variable:
parking_info(String)Rename the node to
Extract Result
Step 5: Add an LLM node to format the answer¶
Click + after the Code node → LLM
Model: pick whatever you have configured (gpt-4o-mini, gemini-2.5-flash, etc.)
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.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.Rename the node to
Format Answer
Step 6: Configure the End node¶
Click the End node
Output variable:
Format_Answer.text
Step 7: Publish and test¶
Click Publish (top right) → Update Publishing
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 Centerthe union,student union→ Kansas Unionthe phog,basketball stadium,phog stadium→ Allen Fieldhousefootball 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”):
Install the MCP plugin
Go to Tools and click the MCP plugin
Add a new MCP server:
Name:
ku-parkingURL:
https://ku-parking-mcp-1098133188018.us-central1.run.appSave
Dify will call
tools/liston the server and automatically discover the three toolsIn 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) -> EndJust 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_colorthat filters to one permit color). Redeploy and see the new tool appear automatically intools/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-unauthenticatedflag when redeploying, then updatemcp_callto send anAuthorization: Bearer <id-token>header (get the token viagcloud auth print-identity-token). Now the endpoint is no longer public.Add caching. In
mcp_call, cache the response oftools/listso 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