From 3d0124fcd3747e4e6b182bfe601ff67fa2de3c7b Mon Sep 17 00:00:00 2001
From: KoichiAwano <81341281+KoichiAwano@users.noreply.github.com>
Date: Mon, 30 Mar 2026 09:25:05 +0000
Subject: [PATCH 1/5] Fix detail page delete flow and relative URL handling
---
src/fastapi_app/app.py | 70 +++++++++++++++++++++++++++++---------
src/templates/details.html | 6 ++++
2 files changed, 60 insertions(+), 16 deletions(-)
diff --git a/src/fastapi_app/app.py b/src/fastapi_app/app.py
index 7e5ff3f..1aad185 100644
--- a/src/fastapi_app/app.py
+++ b/src/fastapi_app/app.py
@@ -7,10 +7,11 @@
from fastapi import Depends, FastAPI, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
-from fastapi.templating import Jinja2Templates
+from jinja2 import Environment, FileSystemLoader, select_autoescape
from sqlalchemy.sql import func
from sqlmodel import Session, select
+from .mcp_server import mcp, mcp_lifespan
from .models import Restaurant, Review, engine
# Setup logger and Azure Monitor:
@@ -21,13 +22,19 @@
# Setup FastAPI app:
-app = FastAPI()
+
parent_path = pathlib.Path(__file__).parent.parent
-app.mount("/mount", StaticFiles(directory=parent_path / "static"), name="static")
-templates = Jinja2Templates(directory=parent_path / "templates")
-templates.env.globals["prod"] = os.environ.get("RUNNING_IN_PRODUCTION", False)
-# Use relative path for url_for, so that it works behind a proxy like Codespaces
-templates.env.globals["url_for"] = app.url_path_for
+app = FastAPI(lifespan=mcp_lifespan)
+app.mount("/mcp", mcp.streamable_http_app())
+app.mount("/static", StaticFiles(directory=parent_path / "static"), name="static")
+
+# Create Jinja2 environment with caching disabled to avoid issues
+jinja_env = Environment(
+ loader=FileSystemLoader(parent_path / "templates"),
+ autoescape=select_autoescape(["html", "xml"]),
+ cache_size=0, # Disable caching to avoid 'unhashable type' error
+)
+jinja_env.globals["prod"] = bool(os.environ.get("RUNNING_IN_PRODUCTION", False))
# Dependency to get the database session
@@ -48,25 +55,34 @@ async def index(request: Request, session: Session = Depends(get_db_session)):
restaurants = []
for restaurant, avg_rating, review_count in results:
- restaurant_dict = restaurant.dict()
+ restaurant_dict = restaurant.model_dump()
restaurant_dict["avg_rating"] = avg_rating
restaurant_dict["review_count"] = review_count
restaurant_dict["stars_percent"] = round((float(avg_rating) / 5.0) * 100) if review_count > 0 else 0
restaurants.append(restaurant_dict)
- return templates.TemplateResponse("index.html", {"request": request, "restaurants": restaurants})
+ template = jinja_env.get_template("index.html")
+ context = {"request": request, "restaurants": restaurants, "url_for": app.url_path_for}
+ html_content = template.render(context)
+ return HTMLResponse(content=html_content)
@app.get("/create", response_class=HTMLResponse)
async def create_restaurant(request: Request):
logger.info("Request for add restaurant page received")
- return templates.TemplateResponse("create_restaurant.html", {"request": request})
+ template = jinja_env.get_template("create_restaurant.html")
+ context = {"request": request, "url_for": app.url_path_for}
+ html_content = template.render(context)
+ return HTMLResponse(content=html_content)
@app.post("/add", response_class=RedirectResponse)
async def add_restaurant(
- request: Request, restaurant_name: str = Form(...), street_address: str = Form(...), description: str = Form(...),
- session: Session = Depends(get_db_session)
+ request: Request,
+ restaurant_name: str = Form(...),
+ street_address: str = Form(...),
+ description: str = Form(...),
+ session: Session = Depends(get_db_session),
):
logger.info("name: %s address: %s description: %s", restaurant_name, street_address, description)
restaurant = Restaurant()
@@ -91,14 +107,15 @@ async def details(request: Request, id: int, session: Session = Depends(get_db_s
if review_count > 0:
avg_rating = sum(review.rating for review in reviews if review.rating is not None) / review_count
- restaurant_dict = restaurant.dict()
+ restaurant_dict = restaurant.model_dump()
restaurant_dict["avg_rating"] = avg_rating
restaurant_dict["review_count"] = review_count
restaurant_dict["stars_percent"] = round((float(avg_rating) / 5.0) * 100) if review_count > 0 else 0
- return templates.TemplateResponse(
- "details.html", {"request": request, "restaurant": restaurant_dict, "reviews": reviews}
- )
+ template = jinja_env.get_template("details.html")
+ context = {"request": request, "restaurant": restaurant_dict, "reviews": reviews, "url_for": app.url_path_for}
+ html_content = template.render(context)
+ return HTMLResponse(content=html_content)
@app.post("/review/{id}", response_class=RedirectResponse)
@@ -120,3 +137,24 @@ async def add_review(
session.commit()
return RedirectResponse(url=app.url_path_for("details", id=id), status_code=status.HTTP_303_SEE_OTHER)
+
+
+@app.post("/delete/{id}", response_class=RedirectResponse)
+async def delete_restaurant(
+ request: Request,
+ id: int,
+ session: Session = Depends(get_db_session),
+):
+ restaurant = session.exec(select(Restaurant).where(Restaurant.id == id)).first()
+ if restaurant is None:
+ return RedirectResponse(url=app.url_path_for("index"), status_code=status.HTTP_303_SEE_OTHER)
+
+ reviews = session.exec(select(Review).where(Review.restaurant == id)).all()
+ for review in reviews:
+ session.delete(review)
+ session.commit()
+
+ session.delete(restaurant)
+ session.commit()
+
+ return RedirectResponse(url=app.url_path_for("index"), status_code=status.HTTP_303_SEE_OTHER)
diff --git a/src/templates/details.html b/src/templates/details.html
index 6ab7cc9..7dc2518 100644
--- a/src/templates/details.html
+++ b/src/templates/details.html
@@ -112,4 +112,10 @@
Add Review
+
+
+
+
{% endblock %}
From b554ad3c7a3970a8b1bcb7a1feb8885639ac2a54 Mon Sep 17 00:00:00 2001
From: KoichiAwano <81341281+KoichiAwano@users.noreply.github.com>
Date: Mon, 30 Mar 2026 09:33:27 +0000
Subject: [PATCH 2/5] Add MCP server module and dependency
---
src/fastapi_app/mcp_server.py | 103 ++++++++++++++++++++++++++++++++++
src/pyproject.toml | 3 +-
2 files changed, 105 insertions(+), 1 deletion(-)
create mode 100644 src/fastapi_app/mcp_server.py
diff --git a/src/fastapi_app/mcp_server.py b/src/fastapi_app/mcp_server.py
new file mode 100644
index 0000000..3df5a60
--- /dev/null
+++ b/src/fastapi_app/mcp_server.py
@@ -0,0 +1,103 @@
+import asyncio
+import contextlib
+from contextlib import asynccontextmanager
+
+from mcp.server.fastmcp import FastMCP
+from sqlalchemy.sql import func
+from sqlmodel import Session, select
+
+from .models import Restaurant, Review, engine
+
+# Create a FastMCP server. Use stateless_http=True for simple mounting. Default path is .../mcp
+mcp = FastMCP("RestaurantReviewsMCP", stateless_http=True)
+
+# Lifespan context manager to start/stop the MCP session manager with the FastAPI app
+@asynccontextmanager
+async def mcp_lifespan(app):
+ async with contextlib.AsyncExitStack() as stack:
+ await stack.enter_async_context(mcp.session_manager.run())
+ yield
+
+# MCP tool: List all restaurants with their average rating and review count
+@mcp.tool()
+async def list_restaurants_mcp() -> list[dict]:
+ """List restaurants with their average rating and review count."""
+
+ def sync():
+ with Session(engine) as session:
+ statement = (
+ select(
+ Restaurant,
+ func.avg(Review.rating).label("avg_rating"),
+ func.count(Review.id).label("review_count"),
+ )
+ .outerjoin(Review, Review.restaurant == Restaurant.id)
+ .group_by(Restaurant.id)
+ )
+ results = session.exec(statement).all()
+ rows = []
+ for restaurant, avg_rating, review_count in results:
+ r = restaurant.dict()
+ r["avg_rating"] = float(avg_rating) if avg_rating is not None else None
+ r["review_count"] = review_count
+ r["stars_percent"] = (
+ round((float(avg_rating) / 5.0) * 100) if review_count > 0 and avg_rating is not None else 0
+ )
+ rows.append(r)
+ return rows
+
+ return await asyncio.to_thread(sync)
+
+# MCP tool: Get a restaurant and all its reviews by restaurant_id
+@mcp.tool()
+async def get_details_mcp(restaurant_id: int) -> dict:
+ """Return the restaurant and its related reviews as objects."""
+
+ def sync():
+ with Session(engine) as session:
+ restaurant = session.exec(select(Restaurant).where(Restaurant.id == restaurant_id)).first()
+ if restaurant is None:
+ return None
+ reviews = session.exec(select(Review).where(Review.restaurant == restaurant_id)).all()
+ return {"restaurant": restaurant.dict(), "reviews": [r.dict() for r in reviews]}
+
+ return await asyncio.to_thread(sync)
+
+# MCP tool: Create a new review for a restaurant
+@mcp.tool()
+async def create_review_mcp(restaurant_id: int, user_name: str, rating: int, review_text: str) -> dict:
+ """Create a new review for a restaurant and return the created review dict."""
+
+ def sync():
+ with Session(engine) as session:
+ review = Review()
+ review.restaurant = restaurant_id
+ review.review_date = __import__("datetime").datetime.now()
+ review.user_name = user_name
+ review.rating = int(rating)
+ review.review_text = review_text
+ session.add(review)
+ session.commit()
+ session.refresh(review)
+ return review.dict()
+
+ return await asyncio.to_thread(sync)
+
+# MCP tool: Create a new restaurant
+@mcp.tool()
+async def create_restaurant_mcp(restaurant_name: str, street_address: str, description: str) -> dict:
+ """Create a new restaurant and return the created restaurant dict."""
+
+ def sync():
+ with Session(engine) as session:
+ restaurant = Restaurant()
+ restaurant.name = restaurant_name
+ restaurant.street_address = street_address
+ restaurant.description = description
+ session.add(restaurant)
+ session.commit()
+ session.refresh(restaurant)
+ return restaurant.dict()
+
+ return await asyncio.to_thread(sync)
+
\ No newline at end of file
diff --git a/src/pyproject.toml b/src/pyproject.toml
index 3cc2094..7b97e9e 100644
--- a/src/pyproject.toml
+++ b/src/pyproject.toml
@@ -9,8 +9,9 @@ dependencies = [
"uvicorn[standard]",
"uvicorn-worker",
"python-multipart",
- "psycopg2",
+ "psycopg2-binary",
"sqlmodel",
+ "mcp[cli]",
]
[build-system]
From 1e11c2c7ea927f73a840cd2cba6ebab79998abeb Mon Sep 17 00:00:00 2001
From: KoichiAwano <81341281+KoichiAwano@users.noreply.github.com>
Date: Mon, 30 Mar 2026 09:37:55 +0000
Subject: [PATCH 3/5] Update SQLModel type hints and review string output
---
src/fastapi_app/models.py | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/src/fastapi_app/models.py b/src/fastapi_app/models.py
index fcbaa53..0e865d2 100644
--- a/src/fastapi_app/models.py
+++ b/src/fastapi_app/models.py
@@ -1,6 +1,5 @@
import logging
import os
-import typing
from datetime import datetime
from urllib.parse import quote_plus
@@ -18,7 +17,7 @@
logger.info("Missing environment variable AZURE_POSTGRESQL_CONNECTIONSTRING")
else:
# Parse the connection string
- details = dict(item.split('=') for item in env_connection_string.split())
+ details = dict(item.split("=") for item in env_connection_string.split())
# Properly format the URL for SQLAlchemy
sql_url = (
@@ -35,7 +34,9 @@
POSTGRES_DATABASE = os.environ.get("DBNAME")
POSTGRES_PORT = os.environ.get("DBPORT", 5432)
- sql_url = f"postgresql://{POSTGRES_USERNAME}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DATABASE}"
+ sql_url = (
+ f"postgresql://{POSTGRES_USERNAME}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DATABASE}"
+ )
engine = create_engine(sql_url)
@@ -43,8 +44,9 @@
def create_db_and_tables():
return SQLModel.metadata.create_all(engine)
+
class Restaurant(SQLModel, table=True):
- id: typing.Optional[int] = Field(default=None, primary_key=True)
+ id: int | None = Field(default=None, primary_key=True)
name: str = Field(max_length=50)
street_address: str = Field(max_length=50)
description: str = Field(max_length=250)
@@ -52,13 +54,14 @@ class Restaurant(SQLModel, table=True):
def __str__(self):
return f"{self.name}"
+
class Review(SQLModel, table=True):
- id: typing.Optional[int] = Field(default=None, primary_key=True)
+ id: int | None = Field(default=None, primary_key=True)
restaurant: int = Field(foreign_key="restaurant.id")
user_name: str = Field(max_length=50)
- rating: typing.Optional[int]
+ rating: int | None
review_text: str = Field(max_length=500)
review_date: datetime
def __str__(self):
- return f"{self.name}"
+ return f"{self.user_name}"
From b851da62e008b97de1c6cda37d010a64f2999968 Mon Sep 17 00:00:00 2001
From: KoichiAwano <81341281+KoichiAwano@users.noreply.github.com>
Date: Mon, 30 Mar 2026 09:46:08 +0000
Subject: [PATCH 4/5] Update devcontainer and dependency setup
---
.devcontainer/Dockerfile | 3 ++-
.env | 4 ----
requirements.txt | 2 +-
3 files changed, 3 insertions(+), 6 deletions(-)
delete mode 100644 .env
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index ba2f3d5..9c13468 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,5 +1,6 @@
FROM mcr.microsoft.com/devcontainers/python:3.12-bullseye
-RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
+RUN rm -f /etc/apt/sources.list.d/yarn.list \
+ && apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends postgresql-client \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
\ No newline at end of file
diff --git a/.env b/.env
deleted file mode 100644
index 94618f1..0000000
--- a/.env
+++ /dev/null
@@ -1,4 +0,0 @@
-DBNAME=app
-DBHOST=localhost
-DBUSER=app_user
-DBPASS=app_password
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 6339a07..e2ffd98 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,6 +2,6 @@
gunicorn==22.0.0
uvicorn==0.23.2
fastapi==0.111.1
-psycopg2
+psycopg2-binary
SQLAlchemy==2.0.31
sqlmodel==0.0.20
From 14ce159050ee83b41b6d3f018e948c745b7daa79 Mon Sep 17 00:00:00 2001
From: KoichiAwano
Date: Tue, 31 Mar 2026 17:50:50 +0900
Subject: [PATCH 5/5] Fix MCP mount path, lifespan, bicep naming, and update
gitignore
---
.gitignore | 18 ++++++++++++++++++
infra/main.bicep | 5 +++--
infra/resources.bicep | 2 +-
src/fastapi_app/app.py | 2 +-
src/my_uvicorn_worker.py | 2 +-
5 files changed, 24 insertions(+), 5 deletions(-)
diff --git a/.gitignore b/.gitignore
index d95f270..df13bc2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -243,3 +243,21 @@ Thumbs.db #thumbnail cache on Windows
# Azurite stuff
__azurite_*.json
+
+# Work/debug artifacts (not for version control)
+*.zip
+azd-deploy-output*.txt
+chat_*.json
+chat-log*.md
+chat_log*.md
+setup_db.py
+entrypoint.sh
+entrypoint_azure.sh
+app-error-logs/
+app-logs/
+app-logs-mcp/
+deploy-flat/
+deploy-logs-extracted/
+latest-logs/
+webapp_logs/
+app-deploy-fixed-temp/
diff --git a/infra/main.bicep b/infra/main.bicep
index 6c865da..dd0756e 100644
--- a/infra/main.bicep
+++ b/infra/main.bicep
@@ -18,10 +18,11 @@ param databasePassword string
param secretKey string
var resourceToken = toLower(uniqueString(subscription().id, name, location))
+var safeName = toLower(replace(name, '_', '-'))
var tags = { 'azd-env-name': name }
resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = {
- name: '${name}-rg'
+ name: '${safeName}-rg'
location: location
tags: tags
}
@@ -44,4 +45,4 @@ output APPLICATIONINSIGHTS_CONNECTION_STRING string = resources.outputs.APPLICAT
output WEB_URI string = resources.outputs.WEB_URI
output WEB_APP_LOG_STREAM string = resources.outputs.WEB_APP_LOG_STREAM
output WEB_APP_SSH string = resources.outputs.WEB_APP_SSH
-output WEB_APP_CONFIG string = resources.outputs.WEB_APP_CONFIG
\ No newline at end of file
+output WEB_APP_CONFIG string = resources.outputs.WEB_APP_CONFIG
diff --git a/infra/resources.bicep b/infra/resources.bicep
index 849c302..4ae39ef 100644
--- a/infra/resources.bicep
+++ b/infra/resources.bicep
@@ -7,7 +7,7 @@ param databasePassword string
@secure()
param secretKey string
-var prefix = '${name}-${resourceToken}'
+var prefix = toLower(replace('${name}-${resourceToken}', '_', '-'))
var pgServerName = '${prefix}-postgres-server'
var databaseSubnetName = 'database-subnet'
diff --git a/src/fastapi_app/app.py b/src/fastapi_app/app.py
index 1aad185..55862b2 100644
--- a/src/fastapi_app/app.py
+++ b/src/fastapi_app/app.py
@@ -25,7 +25,7 @@
parent_path = pathlib.Path(__file__).parent.parent
app = FastAPI(lifespan=mcp_lifespan)
-app.mount("/mcp", mcp.streamable_http_app())
+app.mount("/api", mcp.streamable_http_app())
app.mount("/static", StaticFiles(directory=parent_path / "static"), name="static")
# Create Jinja2 environment with caching disabled to avoid issues
diff --git a/src/my_uvicorn_worker.py b/src/my_uvicorn_worker.py
index 18409d9..937c30f 100644
--- a/src/my_uvicorn_worker.py
+++ b/src/my_uvicorn_worker.py
@@ -45,6 +45,6 @@ class MyUvicornWorker(UvicornWorker):
CONFIG_KWARGS = {
"loop": "asyncio",
"http": "auto",
- "lifespan": "off",
+ "lifespan": "on",
"log_config": logconfig_dict,
}