22import base64
33import json
44import os
5+ import re
56from dataclasses import dataclass
67from datetime import datetime , timezone
7- from typing import Optional
8+ from typing import Optional , List
89from io import BytesIO
910
1011import azure .functions as func
11- from mcp .types import ImageContent , TextContent , CallToolResult
12+ from mcp .types import ImageContent , TextContent , ContentBlock , ResourceLink , CallToolResult
1213from azure .storage .blob import BlobServiceClient
1314
1415app = 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:
186254def 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 )
0 commit comments