Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 69 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# matplotlib-azurefunction

Azure Functions (Python) app that renders matplotlib charts and returns a public URL to the PNG. Exposes the same renderer through **two** surfaces:
Azure Functions (Python) app that renders matplotlib charts and returns a URL to the PNG (time-limited SAS link by default; unsigned public URL in legacy shared-key mode). Exposes the same renderer through **two** surfaces:

- **HTTP**: `POST /api/chart` — usable from Copilot Studio, Power Automate, curl, anything.
- **MCP**: a single tool `generate_chart` on an MCP server named `MatplotlibChartGenerator`, hosted at `/runtime/webhooks/mcp`.
Expand All @@ -17,13 +17,13 @@ The MCP tool is designed to be reasoning-driven: it requires the caller to artic
- **Six annotation types**: `point`, `hline`, `vline`, `hspan`, `vspan`, `text`
- **Styling**: matplotlib themes (`ggplot`, `seaborn-v0_8`, `dark_background`, `fivethirtyeight`, `bmh`, `grayscale`, default), custom fonts, figsize, dpi, grid

Rendered PNGs are uploaded to Azure Blob Storage and the public URL is returned.
Rendered PNGs are uploaded to Azure Blob Storage and a URL to the image is returned.

## Architecture

```
client ──► /api/chart ─┐
├─► render with matplotlib ─► upload PNG to Blob ─► return public URL
├─► render with matplotlib ─► upload PNG to Blob ─► return URL (SAS or public)
MCP client ──► generate_chart ─┘
```

Expand All @@ -47,12 +47,18 @@ The app reads these environment variables (locally via `local.settings.json`, in

| Variable | Required | Default | Purpose |
|---|---|---|---|
| `AzureWebJobsStorage` | yes | — | Functions runtime storage (can be `UseDevelopmentStorage=true` locally) |
| `AzureWebJobsStorage` *or* `AzureWebJobsStorage__accountName` | yes | — | Functions runtime storage. Use the `__accountName` form (plus `AzureWebJobsStorage__credential=managedidentity`) when shared keys are disabled. Locally, `UseDevelopmentStorage=true` is fine. |
| `FUNCTIONS_WORKER_RUNTIME` | yes | `python` | Functions worker language |
| `CHARTS_BLOB_CONNECTION_STRING` | yes | — | Connection string for the storage account where rendered chart PNGs are uploaded |
| `CHARTS_BLOB_CONTAINER` | no | `matplotlib-charts` | Blob container name (must be publicly readable for the returned URL to work) |
| `CHARTS_BLOB_ACCOUNT_URL` *or* `CHARTS_BLOB_CONNECTION_STRING` | yes | — | Where rendered chart PNGs are uploaded. Pick **one** — see auth modes below. |
| `CHARTS_BLOB_CONTAINER` | no | `matplotlib-charts` | Blob container name |
| `CHARTS_SAS_TTL_DAYS` | no | `30` | TTL of the returned SAS URL (identity mode only) |

> ⚠️ **The blob container needs public-read access** for the returned URL to render in browsers/Copilot. Either set the container's anonymous access level to "Blob", or replace `_upload_to_blob` with a SAS-based variant if you want to keep it private.
### Storage auth modes

The renderer auto-detects which mode to use:

- **Identity mode (recommended).** Set `CHARTS_BLOB_ACCOUNT_URL=https://<acct>.blob.core.windows.net`. Auth uses `DefaultAzureCredential` (Managed Identity in Azure; `az login` / Visual Studio creds locally). The chart container stays private and the returned URL is a User Delegation SAS link with read-only access, expiring in `CHARTS_SAS_TTL_DAYS`. This is **required** on tenants enforcing `StorageAccount_DisableLocalAuth_Modify` or `StorageAccount_BlobAnonymousAccess_Modify` policies — which is most modern enterprise tenants.
- **Legacy shared-key mode.** Set `CHARTS_BLOB_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=…;AccountKey=…;EndpointSuffix=core.windows.net"`. The container is created with anonymous blob-read access and the returned URL is the unsigned blob URL. Only works when the storage account allows shared keys **and** public blob access.

## Local development

Expand Down Expand Up @@ -91,38 +97,74 @@ curl -X POST http://localhost:7071/api/chart \

## Deployment to Azure

Provision once:
> 💡 **Pick Flex Consumption** (not Linux Consumption). Flex is the modern Functions plan, supports identity-based runtime + deployment storage out of the box, and is the reliable path for the MCP extension preview. Linux Consumption may also work but is not exercised here.

### Option A — Identity-based (recommended; required on tenants that disable shared keys)

```bash
# Resource group, storage account, function app (Linux, Python 3.11, Flex Consumption or Consumption)
az group create --name <rg> --location <region>
az storage account create --name <storage> --resource-group <rg> --sku Standard_LRS
RG=<rg>; LOC=<region>; STG=<storage>; APP=<app>
SUB=$(az account show --query id -o tsv)

az group create --name $RG --location $LOC
az storage account create --name $STG --resource-group $RG --sku Standard_LRS

# Function app on Flex Consumption with system-assigned identity AND identity-based deployment storage.
az functionapp create \
--resource-group <rg> \
--name <app> \
--storage-account <storage> \
--resource-group $RG --name $APP --storage-account $STG \
--runtime python --runtime-version 3.11 \
--functions-version 4 \
--os-type Linux --consumption-plan-location <region>
--flexconsumption-location $LOC \
--assign-identity '[system]' \
--deployment-storage-auth-type SystemAssignedIdentity

# Grant the function app's MI the roles AzureWebJobsStorage + the chart container need.
PID=$(az functionapp identity show --resource-group $RG --name $APP --query principalId -o tsv)
SCOPE=$(az storage account show --name $STG --resource-group $RG --query id -o tsv)
for role in "Storage Blob Data Owner" "Storage Queue Data Contributor" "Storage Table Data Contributor"; do
az role assignment create --assignee-object-id $PID --assignee-principal-type ServicePrincipal \
--role "$role" --scope $SCOPE
done

# Replace the runtime connection string with identity-based settings, and add the app's own settings.
az functionapp config appsettings delete --resource-group $RG --name $APP \
--setting-names AzureWebJobsStorage DEPLOYMENT_STORAGE_CONNECTION_STRING
az functionapp config appsettings set --resource-group $RG --name $APP --settings \
AzureWebJobsStorage__accountName=$STG \
AzureWebJobsStorage__credential=managedidentity \
CHARTS_BLOB_ACCOUNT_URL=https://$STG.blob.core.windows.net \
CHARTS_BLOB_CONTAINER=matplotlib-charts \
CHARTS_SAS_TTL_DAYS=30
```

# Configure app settings — the storage connection string the renderer uploads to
az functionapp config appsettings set \
--resource-group <rg> --name <app> \
--settings \
CHARTS_BLOB_CONNECTION_STRING="<connection-string>" \
CHARTS_BLOB_CONTAINER="matplotlib-charts"
### Option B — Legacy shared-key

```bash
az functionapp create \
--resource-group $RG --name $APP --storage-account $STG \
--runtime python --runtime-version 3.11 \
--flexconsumption-location $LOC

az functionapp config appsettings set --resource-group $RG --name $APP --settings \
CHARTS_BLOB_CONNECTION_STRING="<connection-string>" \
CHARTS_BLOB_CONTAINER="matplotlib-charts"
```

Deploy via zip (recommended for this repo — `func azure functionapp publish --build remote` has been observed to silently fail here):
### Deploy the code (both options)

Flex Consumption builds Python dependencies remotely (Oryx), so the deployment package should contain just the source — **do not** pre-install `.python_packages` locally (locally-built wheels can be the wrong CPU architecture, e.g. ARM64 dev → x86_64 Linux).

```bash
# From the project root, with the venv activated
python -m pip install --target .python_packages\lib\site-packages -r requirements.txt
Compress-Archive -Path .\* -DestinationPath deploy.zip -Force
# From the project root — zip just the source, then deploy with remote build.
zip -r deploy.zip . -x ".git/*" ".venv/*" "__pycache__/*" "local.settings.json"
# PowerShell equivalent:
# Compress-Archive -Path .\* -DestinationPath deploy.zip -Force

az functionapp deployment source config-zip \
--resource-group <rg> --name <app> --src deploy.zip
--resource-group $RG --name $APP \
--src deploy.zip --build-remote true
```

> Note: `func azure functionapp publish --build remote` has been observed to silently fail against Linux Consumption for this repo. On Flex Consumption, the `az functionapp deployment source config-zip --build-remote true` command above works reliably.

## HTTP endpoint

`POST /api/chart?code=<function-key>` — request body matches the MCP tool's `chart_type` / `data` / `params` shape. See [`openapi.json`](./openapi.json) for the full schema.
Expand Down
4 changes: 2 additions & 2 deletions copilot-studio-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ POST https://<YOUR-FUNCTION-APP>.azurewebsites.net/api/chart?code=YOUR_API_KEY
## Tool Description (paste into the action description in Copilot Studio)

```
Generates chart images using matplotlib. Returns a public URL to the PNG image. Call this tool when the user asks to create, plot, or visualize a chart or graph from data. Supported types: bar, line, pie, scatter, histogram, area, box, violin, heatmap. Supports single-series and multi-series (except pie and heatmap). After calling, display the URL as an image: ![Chart](url)
Generates chart images using matplotlib. Returns a URL to the PNG image (time-limited SAS link by default, ~30-day TTL; unsigned public URL in legacy shared-key mode). Call this tool when the user asks to create, plot, or visualize a chart or graph from data. Supported types: bar, line, pie, scatter, histogram, area, box, violin, heatmap. Supports single-series and multi-series (except pie and heatmap). After calling, display the URL as an image: ![Chart](url)
```

---
Expand Down Expand Up @@ -62,7 +62,7 @@ Example: {"title":"Sales","xlabel":"Quarter","ylabel":"Revenue","style":"ggplot"

<!-- 95 chars -->
```
Public URL to the generated chart PNG image. Display as image: ![Chart](url)
Public URL to the generated chart PNG image (time-limited SAS link by default, ~30-day TTL). Display as image: ![Chart](url)
```

---
Expand Down
111 changes: 103 additions & 8 deletions function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,92 @@
import json
import io
import uuid
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from azure.storage.blob import BlobServiceClient, ContentSettings
from azure.storage.blob import (
BlobServiceClient,
ContentSettings,
BlobSasPermissions,
generate_blob_sas,
)

app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)

BLOB_CONN_STR = os.environ["CHARTS_BLOB_CONNECTION_STRING"]

# ---------------------------------------------------------------------------
# Blob auth configuration
#
# Two supported modes:
# 1. CHARTS_BLOB_CONNECTION_STRING with AccountKey=...
# Legacy shared-key mode. Container is created with public-blob access
# and the returned URL is the unsigned blob URL.
# 2. CHARTS_BLOB_ACCOUNT_URL (e.g. https://mystorage.blob.core.windows.net)
# Identity mode. Auth via DefaultAzureCredential (Managed Identity in
# Azure, az/dev creds locally). Container stays private; the returned
# URL is a User Delegation SAS expiring in CHARTS_SAS_TTL_DAYS (default 30).
#
# Mode 2 is required on tenants that enforce StorageAccount_DisableLocalAuth
# or StorageAccount_BlobAnonymousAccess Azure Policies.
# ---------------------------------------------------------------------------


def _parse_connection_string(conn: str) -> dict:
return dict(p.split("=", 1) for p in conn.split(";") if "=" in p)


_CONN_STR = os.environ.get("CHARTS_BLOB_CONNECTION_STRING", "")
_ACCOUNT_URL_ENV = os.environ.get("CHARTS_BLOB_ACCOUNT_URL", "").rstrip("/")
_CONN_PARTS = _parse_connection_string(_CONN_STR) if _CONN_STR else {}

USE_IDENTITY = bool(_ACCOUNT_URL_ENV) or "AccountKey" not in _CONN_PARTS

if USE_IDENTITY:
if _ACCOUNT_URL_ENV:
BLOB_ACCOUNT_URL = _ACCOUNT_URL_ENV
else:
name = _CONN_PARTS.get("AccountName")
if not name:
raise RuntimeError(
"Set CHARTS_BLOB_ACCOUNT_URL or a CHARTS_BLOB_CONNECTION_STRING with AccountName."
)
suffix = _CONN_PARTS.get("EndpointSuffix", "core.windows.net")
proto = _CONN_PARTS.get("DefaultEndpointsProtocol", "https")
BLOB_ACCOUNT_URL = f"{proto}://{name}.blob.{suffix}"
BLOB_ACCOUNT_NAME = urlparse(BLOB_ACCOUNT_URL).hostname.split(".")[0]
from azure.identity import DefaultAzureCredential
_credential = DefaultAzureCredential()
_blob_service = BlobServiceClient(account_url=BLOB_ACCOUNT_URL, credential=_credential)
else:
BLOB_ACCOUNT_URL = ""
BLOB_ACCOUNT_NAME = _CONN_PARTS.get("AccountName", "")
_blob_service = BlobServiceClient.from_connection_string(_CONN_STR)

BLOB_CONTAINER = os.environ.get("CHARTS_BLOB_CONTAINER", "matplotlib-charts")
# Legacy alias retained for backward-compat with call sites that pass it.
BLOB_CONN_STR = _CONN_STR or BLOB_ACCOUNT_URL

SAS_TTL_DAYS = int(os.environ.get("CHARTS_SAS_TTL_DAYS", "30"))

_user_delegation_key = None
_user_delegation_key_expiry = datetime.now(timezone.utc)


def _get_user_delegation_key():
"""Cache a user delegation key to avoid one round-trip per upload."""
global _user_delegation_key, _user_delegation_key_expiry
now = datetime.now(timezone.utc)
if _user_delegation_key is None or now >= _user_delegation_key_expiry - timedelta(minutes=10):
start = now - timedelta(minutes=5)
expiry = now + timedelta(hours=24)
_user_delegation_key = _blob_service.get_user_delegation_key(
key_start_time=start, key_expiry_time=expiry
)
_user_delegation_key_expiry = expiry
return _user_delegation_key


SUPPORTED_CHARTS = ["bar", "line", "pie", "scatter", "histogram",
Expand Down Expand Up @@ -410,17 +485,37 @@ def _apply_annotations(ax, params):
# ---------------------------------------------------------------------------

def _get_container_client(connection_string: str, container_name: str):
blob_service = BlobServiceClient.from_connection_string(connection_string)
container_client = blob_service.get_container_client(container_name)
"""Return a container client. The connection_string arg is retained for
call-site compatibility but ignored — the module-level client is used.
In legacy shared-key mode the container is created with public-blob
access; in identity mode it stays private (URLs are SAS-signed)."""
container_client = _blob_service.get_container_client(container_name)
try:
container_client.create_container(public_access="blob")
if USE_IDENTITY:
container_client.create_container()
else:
container_client.create_container(public_access="blob")
except Exception:
pass # already exists
return container_client


def _sas_url_for(blob_name: str) -> str:
udk = _get_user_delegation_key()
sas = generate_blob_sas(
account_name=BLOB_ACCOUNT_NAME,
container_name=BLOB_CONTAINER,
blob_name=blob_name,
user_delegation_key=udk,
permission=BlobSasPermissions(read=True),
expiry=datetime.now(timezone.utc) + timedelta(days=SAS_TTL_DAYS),
)
return f"{BLOB_ACCOUNT_URL}/{BLOB_CONTAINER}/{blob_name}?{sas}"


def _upload_to_blob(image_bytes: bytes, connection_string: str, container_name: str) -> tuple[str, str]:
"""Upload image to blob. Returns (url, blob_id)."""
"""Upload image to blob. Returns (url, blob_id). The URL is a public
blob URL in legacy mode, or a User Delegation SAS link in identity mode."""
container_client = _get_container_client(connection_string, container_name)

blob_id = uuid.uuid4().hex
Expand All @@ -431,7 +526,7 @@ def _upload_to_blob(image_bytes: bytes, connection_string: str, container_name:
overwrite=True,
content_settings=ContentSettings(content_type="image/png"),
)
return blob_client.url, blob_id
return (_sas_url_for(blob_name) if USE_IDENTITY else blob_client.url), blob_id


def _upload_log(blob_id: str, connection_string: str, container_name: str,
Expand Down
2 changes: 1 addition & 1 deletion host.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"mcp": {
"serverName": "MatplotlibChartGenerator",
"serverVersion": "1.0.0",
"instructions": "Generates matplotlib chart images. Call generate_chart with chart_type, data (JSON string), and optional params (JSON string for styling). Returns a public URL to the PNG image.",
"instructions": "Generates matplotlib chart images. Call generate_chart with chart_type, data (JSON string), and optional params (JSON string for styling). Returns a time-limited URL (User Delegation SAS, default 30-day TTL) to the PNG image, or an unsigned public blob URL when configured in legacy shared-key mode.",
"system": {
"webhookAuthorizationLevel": "Anonymous"
}
Expand Down
11 changes: 10 additions & 1 deletion local.settings.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "python",

"//": "Pick ONE of the two storage auth modes below.",

"// MODE 1 — Identity (recommended, required on tenants with disable-local-auth/anonymous-access policies)": "",
"// CHARTS_BLOB_ACCOUNT_URL": "https://<YOUR-STORAGE-ACCOUNT>.blob.core.windows.net",
"// Auth uses DefaultAzureCredential. Locally this means `az login` with a principal that has Storage Blob Data Owner on the account.": "",

"// MODE 2 — Legacy shared key (works only when the account allows shared keys)": "",
"CHARTS_BLOB_CONNECTION_STRING": "DefaultEndpointsProtocol=https;AccountName=<YOUR-STORAGE-ACCOUNT>;AccountKey=<YOUR-KEY>;EndpointSuffix=core.windows.net",
"CHARTS_BLOB_CONTAINER": "matplotlib-charts"

"CHARTS_BLOB_CONTAINER": "matplotlib-charts",
"CHARTS_SAS_TTL_DAYS": "30"
}
}
8 changes: 4 additions & 4 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"swagger": "2.0",
"info": {
"title": "Matplotlib Chart Generator",
"description": "Generates matplotlib charts and returns a public URL to the PNG image hosted in Azure Blob Storage.",
"description": "Generates matplotlib charts and returns a URL to the PNG image hosted in Azure Blob Storage. The URL is a time-limited User Delegation SAS link by default (or an unsigned public blob URL in legacy shared-key mode).",
"version": "1.0.0"
},
"host": "<YOUR-FUNCTION-APP>.azurewebsites.net",
Expand All @@ -15,7 +15,7 @@
"post": {
"operationId": "GenerateChart",
"summary": "Generate a matplotlib chart",
"description": "Accepts a chart type, data, and styling parameters. Returns a public URL to the generated PNG chart image.",
"description": "Accepts a chart type, data, and styling parameters. Returns a URL to the generated PNG chart image (time-limited SAS by default).",
"parameters": [
{
"name": "body",
Expand Down Expand Up @@ -201,7 +201,7 @@
"chart_type": {
"type": "string",
"description": "Type of chart to generate",
"enum": ["bar", "line", "pie", "scatter", "histogram"]
"enum": ["bar", "line", "pie", "scatter", "histogram", "area", "box", "violin", "heatmap"]
},
"data": {
"$ref": "#/definitions/ChartData"
Expand All @@ -216,7 +216,7 @@
"properties": {
"url": {
"type": "string",
"description": "Public URL to the generated chart PNG image"
"description": "URL to the generated chart PNG image. Time-limited SAS link by default; unsigned public URL when running in legacy shared-key mode."
}
}
},
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ azure-functions>=1.24.0
matplotlib
numpy
azure-storage-blob
azure-identity