Skip to content

Commit e946415

Browse files
lilyjmaCopilot
andauthored
Add generate_badge and get_website_preview tools, refactor blob access (#52)
* Add generate_badge and get_website_preview tools, refactor blob access - Re-add generate_badge (List[ContentBlock] with SVG + text) - Re-add get_website_preview (List[ContentBlock] with TextContent + ResourceLink) - Refactor get_snippet_with_metadata to use blob input binding instead of SDK - Refactor save_snippet_structured to use blob output binding instead of SDK - Add connection string fallback in batch_save_snippets for local dev - Add aiohttp to requirements.txt - Update README with new tools Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove trailing newlines Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ebcd587 commit e946415

3 files changed

Lines changed: 131 additions & 70 deletions

File tree

src/FunctionsMcpTool/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ This project is a Python Azure Function app that exposes multiple MCP (Model Con
1515
| `get_snippet` | Retrieves a code snippet from blob storage |
1616
| `save_snippet` | Saves a code snippet to blob storage |
1717
| `generate_qr_code` | Generates a QR code image from text |
18-
| `get_snippet_with_metadata` | Retrieves a snippet with structured metadata |
18+
| `generate_badge` | Generates an SVG status badge (List[ContentBlock]) |
19+
| `get_website_preview` | Fetches website metadata and returns a resource link (List[ContentBlock]) |
20+
| `get_snippet_with_metadata` | Retrieves a snippet with structured metadata (CallToolResult) |
1921
| `batch_save_snippets` | Saves multiple snippets at once |
2022
| `save_snippet_structured` | Saves a snippet and returns a structured dataclass |
2123

src/FunctionsMcpTool/function_app.py

Lines changed: 127 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
import base64
33
import json
44
import os
5+
import re
56
from dataclasses import dataclass
67
from datetime import datetime, timezone
7-
from typing import Optional
8+
from typing import Optional, List
89
from io import BytesIO
910

1011
import azure.functions as func
11-
from mcp.types import ImageContent, TextContent, CallToolResult
12+
from mcp.types import ImageContent, TextContent, ContentBlock, ResourceLink, CallToolResult
1213
from azure.storage.blob import BlobServiceClient
1314

1415
app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)
@@ -93,6 +94,87 @@ def generate_qr_code(text: str) -> ImageContent:
9394
)
9495

9596

97+
@app.mcp_tool()
98+
@app.mcp_tool_property(arg_name="label", description="The label text for the badge.", is_required=True)
99+
@app.mcp_tool_property(arg_name="value", description="The value text for the badge.", is_required=True)
100+
@app.mcp_tool_property(arg_name="color", description="The hex color for the value section (e.g., '#4CAF50').", is_required=False)
101+
def generate_badge(label: str, value: str, color: str = "#4CAF50") -> List[ContentBlock]:
102+
"""Demonstrates returning multiple content blocks (List[ContentBlock]). Generates an SVG status badge and returns it alongside a text description."""
103+
logging.info(f"Generating badge: {label} | {value}")
104+
105+
label_width = len(label) * 7 + 12
106+
value_width = len(value) * 7 + 12
107+
total_width = label_width + value_width
108+
109+
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="20">
110+
<rect width="{label_width}" height="20" fill="#555"/>
111+
<rect x="{label_width}" width="{value_width}" height="20" fill="{color}"/>
112+
<text x="{label_width // 2}" y="14" fill="#fff" text-anchor="middle"
113+
font-family="Verdana,sans-serif" font-size="11">{label}</text>
114+
<text x="{label_width + value_width // 2}" y="14" fill="#fff" text-anchor="middle"
115+
font-family="Verdana,sans-serif" font-size="11">{value}</text>
116+
</svg>"""
117+
118+
return [
119+
TextContent(type="text", text=f"Badge: {label}{value}"),
120+
ImageContent(
121+
type="image",
122+
data=base64.b64encode(svg.encode('utf-8')).decode('utf-8'),
123+
mimeType="image/svg+xml"
124+
)
125+
]
126+
127+
128+
@app.mcp_tool()
129+
@app.mcp_tool_property(arg_name="url", description="The URL of the website to preview.", is_required=True)
130+
async def get_website_preview(url: str) -> List[ContentBlock]:
131+
"""Demonstrates returning TextContentBlock and ResourceLinkBlock together. Fetches basic metadata from a URL and returns it with a resource link."""
132+
import aiohttp
133+
import html
134+
135+
logging.info(f"Fetching website preview for {url}")
136+
137+
# Ensure URL has a protocol
138+
if not url.startswith(('http://', 'https://')):
139+
url = f'https://{url}'
140+
logging.info(f"Added https:// protocol to URL: {url}")
141+
142+
title = url
143+
description = "No description available."
144+
145+
try:
146+
async with aiohttp.ClientSession() as session:
147+
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10),
148+
headers={"User-Agent": "MCPTool/1.0"}) as response:
149+
html_content = await response.text()
150+
151+
# Extract title
152+
title_match = re.search(r'<title[^>]*>(.*?)</title>', html_content, re.IGNORECASE)
153+
if title_match:
154+
title = html.unescape(title_match.group(1)).strip()
155+
156+
# Extract description
157+
desc_match = re.search(
158+
r'<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']*)["\'\']',
159+
html_content,
160+
re.IGNORECASE
161+
)
162+
if desc_match:
163+
description = html.unescape(desc_match.group(1)).strip()
164+
165+
except Exception as ex:
166+
logging.warning(f"Failed to fetch metadata for {url}: {ex}")
167+
description = f"Could not fetch metadata: {str(ex)}"
168+
169+
return [
170+
TextContent(type="text", text=f"{title}\n\n{description}"),
171+
ResourceLink(
172+
type="resource_link",
173+
uri=url,
174+
name=title,
175+
description=description
176+
)
177+
]
96178

97179
# ============================================================================
98180
# Snippet Data Class
@@ -121,47 +203,33 @@ class Snippet:
121203

122204
@app.mcp_tool()
123205
@app.mcp_tool_property(arg_name="snippetname", description="The name of the snippet.", is_required=True)
124-
def get_snippet_with_metadata(snippetname: str) -> CallToolResult:
206+
@app.blob_input(arg_name="file", connection="AzureWebJobsStorage", path=_BLOB_PATH)
207+
def get_snippet_with_metadata(file: func.InputStream, snippetname: str) -> CallToolResult:
125208
"""
126209
Demonstrates returning both content blocks and structured metadata via CallToolResult.
127-
210+
128211
Returns a CallToolResult with:
129212
- content: List of ContentBlock objects (for backward compatibility)
130213
- structured_content: JSON metadata (for clients that support it)
131-
214+
132215
This pattern allows clients to choose between simple text content or
133216
richer structured data depending on their capabilities.
134217
"""
135218
logging.info(f"Getting snippet with metadata: {snippetname}")
136-
137-
# Try to read the snippet from blob storage
219+
138220
snippet_content = None
139221
try:
140-
blob_service_uri = os.environ.get("AzureWebJobsStorage__blobServiceUri")
141-
142-
if blob_service_uri:
143-
from azure.identity import DefaultAzureCredential
144-
credential = DefaultAzureCredential(
145-
managed_identity_client_id=os.environ.get("AzureWebJobsStorage__clientId")
146-
)
147-
blob_service_client = BlobServiceClient(blob_service_uri, credential=credential)
148-
container_client = blob_service_client.get_container_client("snippets")
149-
blob_client = container_client.get_blob_client(f"{snippetname}.json")
150-
151-
blob_data = blob_client.download_blob()
152-
snippet_content = blob_data.readall().decode('utf-8')
222+
snippet_content = file.read().decode("utf-8")
153223
except Exception as ex:
154224
logging.warning(f"Could not read snippet '{snippetname}': {ex}")
155-
156-
# Build metadata
225+
157226
metadata = {
158227
"name": snippetname,
159228
"found": snippet_content is not None,
160229
"character_count": len(snippet_content) if snippet_content else 0,
161230
"retrieved_at": datetime.now(timezone.utc).isoformat()
162231
}
163-
164-
# Return CallToolResult with both content blocks and structured metadata
232+
165233
return CallToolResult(
166234
content=[
167235
TextContent(
@@ -186,18 +254,21 @@ def get_snippet_with_metadata(snippetname: str) -> CallToolResult:
186254
def batch_save_snippets(snippet_items) -> str:
187255
"""
188256
Demonstrates batch tool inputs - saving multiple snippets in one operation.
257+
258+
Accepts an array of snippet objects and saves each one to blob storage.
189259
190-
Accepts an array of snippet objects and saves each one to blob storage.
191260
This pattern is useful for bulk operations and reduces the number of
192261
tool invocations needed.
193262
263+
Uses managed identity when deployed to Azure, falls back to connection
264+
string for local development with Azurite.
265+
194266
Args:
195267
snippet_items: List of dicts with 'name' and 'content' keys (or JSON string)
196-
268+
197269
Returns:
198270
JSON string with summary of saved snippets
199271
"""
200-
# Parse snippet_items if it's a string
201272
if isinstance(snippet_items, str):
202273
try:
203274
snippet_items = json.loads(snippet_items)
@@ -206,58 +277,63 @@ def batch_save_snippets(snippet_items) -> str:
206277
return json.dumps({
207278
"error": f"Invalid JSON format: {str(e)}"
208279
})
209-
280+
210281
logging.info(f"Batch saving {len(snippet_items)} snippets")
211-
282+
212283
try:
284+
# Try managed identity first (Azure), fall back to connection string (local)
213285
blob_service_uri = os.environ.get("AzureWebJobsStorage__blobServiceUri")
214-
if not blob_service_uri:
215-
return json.dumps({
216-
"error": "AzureWebJobsStorage__blobServiceUri not configured"
217-
})
218-
219-
from azure.identity import DefaultAzureCredential
220-
credential = DefaultAzureCredential(
221-
managed_identity_client_id=os.environ.get("AzureWebJobsStorage__clientId")
222-
)
223-
blob_service_client = BlobServiceClient(blob_service_uri, credential=credential)
286+
if blob_service_uri:
287+
from azure.identity import DefaultAzureCredential
288+
credential = DefaultAzureCredential(
289+
managed_identity_client_id=os.environ.get("AzureWebJobsStorage__clientId")
290+
)
291+
blob_service_client = BlobServiceClient(blob_service_uri, credential=credential)
292+
else:
293+
connection_string = os.environ.get("AzureWebJobsStorage")
294+
if not connection_string:
295+
return json.dumps({
296+
"error": "No blob storage configuration found"
297+
})
298+
blob_service_client = BlobServiceClient.from_connection_string(connection_string)
299+
224300
container_client = blob_service_client.get_container_client("snippets")
225-
301+
226302
# Create container if it doesn't exist
227303
try:
228304
container_client.create_container()
229305
except:
230306
pass # Container already exists
231-
307+
232308
saved_snippets = []
233-
309+
234310
for item in snippet_items:
235311
try:
236312
name = item.get("name")
237313
content = item.get("content", "")
238-
314+
239315
if not name:
240316
logging.warning("Skipping snippet with no name")
241317
continue
242-
318+
243319
blob_client = container_client.get_blob_client(f"{name}.json")
244320
blob_client.upload_blob(
245321
content,
246322
overwrite=True
247323
)
248324
saved_snippets.append(name)
249325
logging.info(f"Saved snippet: {name}")
250-
326+
251327
except Exception as ex:
252328
logging.error(f"Failed to save snippet {item.get('name', 'unknown')}: {ex}")
253-
329+
254330
result = {
255331
"message": f"Successfully saved {len(saved_snippets)} snippets",
256332
"snippets": saved_snippets
257333
}
258-
334+
259335
return json.dumps(result, indent=2)
260-
336+
261337
except Exception as ex:
262338
logging.error(f"Batch save failed: {ex}")
263339
return json.dumps({
@@ -268,7 +344,8 @@ def batch_save_snippets(snippet_items) -> str:
268344
@app.mcp_tool()
269345
@app.mcp_tool_property(arg_name="name", description="The name of the snippet", is_required=True)
270346
@app.mcp_tool_property(arg_name="content", description="The code snippet content", is_required=True)
271-
def save_snippet_structured(name: str, content: str) -> Snippet:
347+
@app.blob_output(arg_name="file", connection="AzureWebJobsStorage", path="snippets/{mcptoolargs.name}.json")
348+
def save_snippet_structured(file: func.Out[str], name: str, content: str) -> Snippet:
272349
"""
273350
Demonstrates returning a structured data class (Snippet POCO equivalent).
274351
@@ -284,24 +361,5 @@ def save_snippet_structured(name: str, content: str) -> Snippet:
284361
Snippet dataclass instance
285362
"""
286363
logging.info(f"Saving snippet '{name}' as structured content")
287-
288-
# Save to blob storage
289-
try:
290-
blob_service_uri = os.environ.get("AzureWebJobsStorage__blobServiceUri")
291-
if not blob_service_uri:
292-
logging.warning("AzureWebJobsStorage__blobServiceUri not configured")
293-
return Snippet(name=name, content=content)
294-
295-
from azure.identity import DefaultAzureCredential
296-
credential = DefaultAzureCredential(
297-
managed_identity_client_id=os.environ.get("AzureWebJobsStorage__clientId")
298-
)
299-
blob_service_client = BlobServiceClient(blob_service_uri, credential=credential)
300-
container_client = blob_service_client.get_container_client("snippets")
301-
blob_client = container_client.get_blob_client(f"{name}.json")
302-
blob_client.upload_blob(content, overwrite=True)
303-
except Exception as ex:
304-
logging.warning(f"Could not save to blob storage: {ex}")
305-
306-
# Return structured Snippet object
364+
file.set(content)
307365
return Snippet(name=name, content=content)

src/FunctionsMcpTool/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ azure-functions>=2.2.0b3
66
qrcode[pil]
77
azure-storage-blob
88
azure-identity
9+
aiohttp
910
mcp

0 commit comments

Comments
 (0)