diff --git a/src/FunctionsMcpResources/README.md b/src/FunctionsMcpResources/README.md new file mode 100644 index 0000000..2a1ae86 --- /dev/null +++ b/src/FunctionsMcpResources/README.md @@ -0,0 +1,219 @@ +# FunctionsMcpResources — MCP Resource Templates on Azure Functions (Python) + +This project is a Python Azure Function app that exposes MCP (Model Context Protocol) resource templates as a remote MCP server. Resource templates allow MCP clients to discover and read structured data through URI-based patterns. + +> **Note:** MCP tools are in the [FunctionsMcpTool](../FunctionsMcpTool) project, and prompts are in the [FunctionsMcpPrompts](../FunctionsMcpPrompts) project. + +## Resources included + +| Resource | URI | Description | +|----------|-----|-------------| +| `Snippet` | `snippet://{Name}` | Resource template that reads a code snippet by name from blob storage. Clients discover it via `resources/templates/list` and substitute the `Name` parameter. | +| `ServerInfo` | `info://server` | Static resource that returns server name, version, runtime, and timestamp. | + +## Key concepts + +- **Resource templates** have URI parameters (e.g., `{Name}`) that clients substitute at runtime — they're like parameterized endpoints. +- **Static resources** have fixed URIs and return the same structure every call. +- **Resource metadata** (like cache TTL) can be passed in the `metadata` parameter of the `@app.mcp_resource_trigger` decorator. + +## Prerequisites + +- [Python 3.13+](https://www.python.org/downloads/) +- [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local?pivots=programming-language-python#install-the-azure-functions-core-tools) >= `4.5.0` +- [Docker](https://www.docker.com/) (for the Azurite storage emulator — needed by the snippet resource template) + +## Run locally + +### 1. Start Azurite (required for the snippet resource which uses blob storage) + +```bash +docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 \ + mcr.microsoft.com/azure-storage/azurite +``` + +### 2. Upload sample snippets to Azurite + +Once Azurite is running, you need to upload the sample snippet files to blob storage. You can use [Azure Storage Explorer](https://azure.microsoft.com/features/storage-explorer/) or the Azure CLI: + +```bash +# Using Azure CLI with Azurite connection string +az storage container create --name snippets \ + --connection-string "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;" + +# Upload sample snippets +az storage blob upload-batch --source ../../__queuestorage__/snippets \ + --destination snippets \ + --connection-string "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;" +``` + +### 3. Start the Functions host + +From this directory (`src/FunctionsMcpResources`), start the Functions host: + +```bash +func start +``` + +The MCP endpoint will be available at `http://localhost:7073/runtime/webhooks/mcp`. + +## Deploy to Azure + +From the repository root, use `azd` to deploy: + +```bash +azd env set DEPLOY_SERVICE resources +azd provision +azd deploy --service resources +``` + +## Examining the code + +Resources are defined in [function_app.py](function_app.py). Each resource is a Python function with an `@app.mcp_resource_trigger` decorator: + +### Resource Template (with URI parameter) + +```python +@app.mcp_resource_trigger( + arg_name="context", + uri="snippet://{Name}", + resource_name="Snippet", + description="Reads a code snippet by name from blob storage.", + mime_type="application/json" +) +@app.blob_input( + arg_name="snippet_content", + path="snippets/{mcpresourceargs.Name}.json", + connection="AzureWebJobsStorage" +) +def get_snippet_resource(context, snippet_content: Optional[bytes]) -> str: + # The {mcpresourceargs.Name} binding expression automatically extracts + # the Name parameter from the resource URI and passes it to the blob binding + if snippet_content is None: + return json.dumps({"error": "Snippet not found"}) + return snippet_content.decode('utf-8') +``` + +The `{mcpresourceargs.Name}` binding expression automatically extracts the `Name` parameter from the resource URI and passes it to the blob input binding. + +### Static Resource (no parameters) + +```python +@app.mcp_resource_trigger( + arg_name="context", + uri="info://server", + resource_name="ServerInfo", + description="Returns information about the MCP server.", + mime_type="application/json", + metadata=json.dumps({"cache": {"ttlSeconds": 60}}) +) +def get_server_info(context) -> str: + server_info = { + "name": "FunctionsMcpResources", + "version": "1.0.0", + "runtime": f"Python {platform.python_version()}", + "timestamp": datetime.now(timezone.utc).isoformat() + } + return json.dumps(server_info) +``` + +## Testing the resources + +### Using MCP Inspector + +Install and use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to test your resources: + +```bash +npx @modelcontextprotocol/inspector http://localhost:7073/runtime/webhooks/mcp +``` + +### Using curl + +Test the ServerInfo static resource: + +```bash +curl -X POST http://localhost:7073/runtime/webhooks/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "resources/read", + "params": { + "uri": "info://server" + } + }' +``` + +List available resource templates: + +```bash +curl -X POST http://localhost:7073/runtime/webhooks/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "resources/templates/list" + }' +``` + +Read a specific snippet: + +```bash +curl -X POST http://localhost:7073/runtime/webhooks/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "resources/read", + "params": { + "uri": "snippet://HelloWorld" + } + }' +``` + +## Architecture + +``` +┌─────────────────┐ +│ MCP Client │ +│ (e.g., Agent) │ +└────────┬────────┘ + │ + │ HTTP + │ +┌────────▼────────────────────────┐ +│ Azure Functions (Python) │ +│ ┌──────────────────────────┐ │ +│ │ get_snippet_resource │ │ +│ │ (Resource Template) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ │ Blob Binding │ +│ │ │ +│ ┌──────────▼───────────────┐ │ +│ │ Azure Blob Storage │ │ +│ │ (snippets container) │ │ +│ └──────────────────────────┘ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ get_server_info │ │ +│ │ (Static Resource) │ │ +│ └──────────────────────────┘ │ +└─────────────────────────────────┘ +``` + +## Sample snippets + +Three sample snippets are included in `__queuestorage__/snippets/`: + +1. **HelloWorld.json** - A simple Hello World function +2. **QuickSort.json** - QuickSort algorithm implementation +3. **FibonacciSequence.json** - Fibonacci sequence generator + +You can add more snippets by creating JSON files in the same format and uploading them to the blob storage container. + +## Related documentation + +- [Model Context Protocol Specification](https://spec.modelcontextprotocol.io/) +- [Azure Functions Python Developer Guide](https://learn.microsoft.com/azure/azure-functions/functions-reference-python) +- [Azure Functions Blob Storage Bindings](https://learn.microsoft.com/azure/azure-functions/functions-bindings-storage-blob) diff --git a/src/FunctionsMcpResources/function_app.py b/src/FunctionsMcpResources/function_app.py new file mode 100644 index 0000000..1493747 --- /dev/null +++ b/src/FunctionsMcpResources/function_app.py @@ -0,0 +1,151 @@ +""" +FunctionsMcpResources - MCP Resource Templates on Azure Functions (Python) + +This module demonstrates both resource templates and static resources using Azure Functions. +- Resource templates have URI parameters (e.g., {Name}) that clients substitute at runtime +- Static resources have fixed URIs and return consistent data structures +""" + +import json +import logging +import os +import platform +import re +from datetime import datetime, timezone + +import azure.functions as func +from azure.storage.blob import BlobServiceClient + +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) + +# ============================================================================ +# Resource URI and Metadata Constants +# ============================================================================ + +# Snippet Resource Template (has URI parameter {Name}) +SNIPPET_RESOURCE_URI = "snippet://{Name}" +SNIPPET_RESOURCE_NAME = "Snippet" +SNIPPET_RESOURCE_DESCRIPTION = "Reads a code snippet by name from blob storage." +SNIPPET_MIME_TYPE = "application/json" + +# Server Info Static Resource (fixed URI, no parameters) +SERVER_INFO_RESOURCE_URI = "info://server" +SERVER_INFO_RESOURCE_NAME = "ServerInfo" +SERVER_INFO_RESOURCE_DESCRIPTION = "Returns information about the MCP server, including version and runtime." +SERVER_INFO_MIME_TYPE = "application/json" + +# Metadata for ServerInfo resource (cache TTL) +SERVER_INFO_METADATA = json.dumps({"cache": {"ttlSeconds": 60}}) + +# ============================================================================ +# Resource Template: Snippet +# ============================================================================ + +@app.mcp_resource_trigger( + arg_name="context", + uri=SNIPPET_RESOURCE_URI, + resource_name=SNIPPET_RESOURCE_NAME, + description=SNIPPET_RESOURCE_DESCRIPTION, + mime_type=SNIPPET_MIME_TYPE +) +def get_snippet_resource(context) -> str: + """ + Resource template that exposes snippets by name. + + The {Name} parameter in the URI makes this a resource template rather than + a static resource — clients can discover it via resources/templates/list + and read specific snippets by substituting the Name parameter. + + This implementation manually extracts the Name parameter from the URI + and uses the Azure Blob Storage SDK to read the corresponding blob. + + Args: + context: MCP resource invocation context + + Returns: + JSON string containing the snippet content or an error message + """ + logging.info(f"Snippet resource template invoked: {context.uri}") + + try: + # Extract the Name parameter from the URI (e.g., "snippet://HelloWorld" -> "HelloWorld") + # The URI pattern is "snippet://{Name}" + match = re.match(r"snippet://(.+)", context.uri) + if not match: + error_response = { + "error": "Invalid URI", + "message": f"URI does not match expected pattern 'snippet://{{Name}}'" + } + return json.dumps(error_response) + + snippet_name = match.group(1) + logging.info(f"Extracted snippet name: {snippet_name}") + + # Get the blob storage connection string + connection_string = os.environ.get("AzureWebJobsStorage") + if not connection_string: + error_response = { + "error": "Configuration error", + "message": "AzureWebJobsStorage connection string not found" + } + return json.dumps(error_response) + + # Create blob service client and read the blob + blob_service_client = BlobServiceClient.from_connection_string(connection_string) + container_client = blob_service_client.get_container_client("snippets") + blob_client = container_client.get_blob_client(f"{snippet_name}.json") + + # Download the blob content + blob_data = blob_client.download_blob() + snippet_content = blob_data.readall().decode('utf-8') + + return snippet_content + + except Exception as e: + logging.error(f"Error reading snippet: {e}") + error_response = { + "error": "Snippet not found", + "message": f"No snippet found for the requested name. Error: {str(e)}" + } + return json.dumps(error_response) + +# ============================================================================ +# Static Resource: Server Info +# ============================================================================ + +@app.mcp_resource_trigger( + arg_name="context", + uri=SERVER_INFO_RESOURCE_URI, + resource_name=SERVER_INFO_RESOURCE_NAME, + description=SERVER_INFO_RESOURCE_DESCRIPTION, + mime_type=SERVER_INFO_MIME_TYPE, + metadata=SERVER_INFO_METADATA +) +def get_server_info(context) -> str: + """ + Static resource (no URI parameters) that returns server information. + + Demonstrates the difference between a static resource and a resource template. + This resource has a fixed URI with no parameters and returns dynamic server + metadata each time it's invoked. + + The cache metadata (ttlSeconds: 60) hints to clients that they can cache + this resource for 60 seconds. + + Args: + context: MCP resource invocation context + + Returns: + JSON string containing server information + """ + logging.info("Server info resource invoked.") + + server_info = { + "name": "FunctionsMcpResources", + "version": "1.0.0", + "runtime": f"Python {platform.python_version()}", + "platform": platform.platform(), + "timestamp": datetime.now(timezone.utc).isoformat() + } + + return json.dumps(server_info, indent=2) diff --git a/src/FunctionsMcpResources/host.json b/src/FunctionsMcpResources/host.json new file mode 100644 index 0000000..cffa037 --- /dev/null +++ b/src/FunctionsMcpResources/host.json @@ -0,0 +1,23 @@ +{ + "version": "2.0", + "extensions": { + "mcp": { + "system": { + "webhookAuthorizationLevel": "Anonymous" + } + } + }, + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/src/FunctionsMcpResources/local.settings.json b/src/FunctionsMcpResources/local.settings.json new file mode 100644 index 0000000..a2ded91 --- /dev/null +++ b/src/FunctionsMcpResources/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/src/FunctionsMcpResources/requirements.txt b/src/FunctionsMcpResources/requirements.txt new file mode 100644 index 0000000..087f49b --- /dev/null +++ b/src/FunctionsMcpResources/requirements.txt @@ -0,0 +1,6 @@ +# Do not include azure-functions-worker in this file +# The Python Worker is managed by the Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +azure-storage-blob diff --git a/src/FunctionsMcpTool/README.md b/src/FunctionsMcpTool/README.md index 0378745..e9754ff 100644 --- a/src/FunctionsMcpTool/README.md +++ b/src/FunctionsMcpTool/README.md @@ -1,16 +1,28 @@ # FunctionsMcpTool - MCP Server Sample -This Azure Functions app implements an MCP server that demonstrates various tool patterns. It includes sample tools for connectivity testing, code snippet management with Azure Blob Storage, and more. Additional tools will be added over time to showcase different MCP capabilities. +This Azure Functions app implements an MCP server that demonstrates various tool patterns, including rich content responses, structured data, batch operations, and Azure Blob Storage integration. It provides comprehensive examples of MCP capabilities on Azure Functions. ## Features -This MCP server currently provides the following tools: +This MCP server provides the following tools organized by category: -- **hello_mcp**: A simple hello world tool for testing connectivity +### Basic Tools + +- **hello_mcp**: Simple hello world tool for testing connectivity - **get_snippet**: Retrieve a saved code snippet by name from Azure Blob Storage - **save_snippet**: Save a code snippet with a name to Azure Blob Storage -More tools will be added to demonstrate additional MCP patterns and Azure Functions bindings. +### Rich Content Tools + +- **generate_qr_code**: Generates a QR code PNG from text and returns it as base64-encoded `ImageContent` (demonstrates single image response) +- **generate_badge**: Creates an SVG status badge and returns it with a text description (demonstrates multiple `ContentBlock` responses) +- **get_website_preview**: Fetches a website's title/description and returns it with a `ResourceLink` (demonstrates `TextContent` + `ResourceLinkBlock`) + +### Advanced Snippet Tools + +- **get_snippet_with_metadata**: Returns snippet content plus structured JSON metadata (demonstrates content blocks with structured data) +- **batch_save_snippets**: Saves multiple snippets in a single operation (demonstrates batch/array tool inputs) +- **save_snippet_structured**: Saves a snippet and returns a structured `Snippet` object (demonstrates POCO/dataclass pattern) ## Prerequisites @@ -60,9 +72,20 @@ func start 1. Open [.vscode/mcp.json](../../.vscode/mcp.json) 2. Find the server called `local-mcp-function` and click **Start**. The server uses the endpoint: `http://localhost:7071/runtime/webhooks/mcp` 3. In Copilot chat agent mode, try these prompts: + + **Basic Tools:** - "Say Hello" - "Save this snippet as snippet1" (with code selected) - "Retrieve snippet1 and apply to newFile.py" + + **Rich Content Tools:** + - "Generate a QR code for https://example.com" + - "Create a badge with label 'build' and value 'passing'" + - "Get a preview of https://github.com" + + **Advanced Tools:** + - "Get snippet1 with metadata" + - "Batch save these snippets: [{'name': 'test1', 'content': 'code1'}, {'name': 'test2', 'content': 'code2'}]" ### Connect from MCP Inspector @@ -97,31 +120,123 @@ az storage blob list --container-name snippets --connection-string "DefaultEndpo The function uses Azure Functions' first-class MCP decorators to expose tools: +### Basic Tool Example + ```python @app.mcp_tool() def hello_mcp() -> str: """Hello world.""" return "Hello I am MCPTool!" +``` + +### Blob Storage Integration +```python @app.mcp_tool() @app.mcp_tool_property(arg_name="snippetname", description="The name of the snippet.") @app.blob_input(arg_name="file", connection="AzureWebJobsStorage", path=_BLOB_PATH) def get_snippet(file: func.InputStream, snippetname: str) -> str: """Retrieve a snippet by name from Azure Blob Storage.""" - # ... implementation + snippet_content = file.read().decode("utf-8") + return snippet_content +``` +### Rich Content Response - Single Image + +```python @app.mcp_tool() -@app.mcp_tool_property(arg_name="snippetname", description="The name of the snippet.") -@app.mcp_tool_property(arg_name="snippet", description="The content of the snippet.") -@app.blob_output(arg_name="file", connection="AzureWebJobsStorage", path=_BLOB_PATH) -def save_snippet(file: func.Out[str], snippetname: str, snippet: str) -> str: - """Save a snippet with a name to Azure Blob Storage.""" - # ... implementation +@app.mcp_tool_property(arg_name="text", description="The text to encode in the QR code.", required=True) +def generate_qr_code(text: str) -> ImageContent: + """Generates a QR code PNG and returns it as a base64-encoded image.""" + # Generate QR code... + return ImageContent( + type="image", + data=base64.b64encode(png_bytes).decode('utf-8'), + mimeType="image/png" + ) +``` + +### Rich Content Response - Multiple Content Blocks + +```python +@app.mcp_tool() +@app.mcp_tool_property(arg_name="label", description="The label text for the badge.", required=True) +@app.mcp_tool_property(arg_name="value", description="The value text for the badge.", required=True) +def generate_badge(label: str, value: str, color: str = "#4CAF50") -> List[ContentBlock]: + """Generates an SVG badge and returns it alongside a text description.""" + return [ + TextContent(type="text", text=f"Badge: {label} — {value}"), + ImageContent( + type="image", + data=base64.b64encode(svg.encode('utf-8')).decode('utf-8'), + mimeType="image/svg+xml" + ) + ] +``` + +### Structured Content with Metadata + +```python +@app.mcp_tool() +@app.mcp_tool_property(arg_name="snippetname", description="The name of the snippet.", required=True) +def get_snippet_with_metadata(snippetname: str) -> Dict[str, Any]: + """Returns both content blocks and structured metadata.""" + metadata = { + "name": snippetname, + "found": snippet_content is not None, + "character_count": len(snippet_content) if snippet_content else 0, + "retrieved_at": datetime.now(timezone.utc).isoformat() + } + + return { + "content": [ + {"type": "text", "text": snippet_content or "Not found"}, + {"type": "text", "text": json.dumps(metadata, indent=2)} + ], + "structured_content": metadata + } +``` + +### Batch Operations + +```python +@app.mcp_tool() +@app.mcp_tool_property( + arg_name="snippet_items", + description="Array of snippet objects with 'name' and 'content' properties", + required=True +) +async def batch_save_snippets(snippet_items: List[Dict[str, str]]) -> str: + """Saves multiple snippets in a single operation.""" + # Save each snippet to blob storage... + return json.dumps({ + "message": f"Successfully saved {len(saved_snippets)} snippets", + "snippets": saved_snippets + }) +``` + +### Structured Data Class (POCO Pattern) + +```python +@dataclass +class Snippet: + """Snippet model for structured content.""" + name: str + content: Optional[str] = None + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="name", description="The name of the snippet", required=True) +@app.mcp_tool_property(arg_name="content", description="The code snippet content", required=True) +def save_snippet_structured(name: str, content: str) -> Snippet: + """Returns a structured dataclass instance.""" + # Save to storage... + return Snippet(name=name, content=content) ``` The MCP decorators automatically: - Infer tool properties from function signatures and type hints -- Handle JSON serialization +- Handle JSON serialization for rich content types +- Support batch operations with array/object inputs - Expose the functions as MCP tools without manual configuration ## Deployment to Azure diff --git a/src/FunctionsMcpTool/function_app.py b/src/FunctionsMcpTool/function_app.py index e5617a9..b15f346 100644 --- a/src/FunctionsMcpTool/function_app.py +++ b/src/FunctionsMcpTool/function_app.py @@ -1,6 +1,16 @@ import logging +import base64 +import json +import os +import re +from dataclasses import dataclass, asdict +from datetime import datetime, timezone +from typing import List, Dict, Any, Optional +from io import BytesIO import azure.functions as func +from mcp.types import ImageContent, TextContent, ContentBlock, ResourceLink, CallToolResult +from azure.storage.blob import BlobServiceClient app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) @@ -40,3 +50,325 @@ def save_snippet(file: func.Out[str], snippetname: str, snippet: str) -> str: file.set(snippet) logging.info(f"Saved snippet: {snippet}") return f"Snippet '{snippet}' saved successfully" + + +# ============================================================================ +# Rich Content Tools +# ============================================================================ + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="text", description="The text to encode in the QR code.", is_required=True) +def generate_qr_code(text: str) -> ImageContent: + """Demonstrates returning a single ImageContentBlock. Generates a QR code PNG and returns it as a base64-encoded image.""" + logging.info(f"Generating QR code for text of length {len(text)}") + + try: + import qrcode + from qrcode.image.pil import PilImage + except ImportError: + logging.error("qrcode library not installed") + raise Exception("qrcode library is required. Install with: pip install qrcode[pil]") + + # Generate QR code + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_Q, + box_size=10, + border=4, + ) + qr.add_data(text) + qr.make(fit=True) + + # Create image + img = qr.make_image(fill_color="black", back_color="white") + + # Convert to bytes + buffer = BytesIO() + img.save(buffer, format="PNG") + png_bytes = buffer.getvalue() + + return ImageContent( + type="image", + data=base64.b64encode(png_bytes).decode('utf-8'), + mimeType="image/png" + ) + + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="label", description="The label text for the badge.", is_required=True) +@app.mcp_tool_property(arg_name="value", description="The value text for the badge.", is_required=True) +@app.mcp_tool_property(arg_name="color", description="The hex color for the value section (e.g., '#4CAF50').", is_required=False) +def generate_badge(label: str, value: str, color: str = "#4CAF50") -> List[ContentBlock]: + """Demonstrates returning multiple content blocks (List[ContentBlock]). Generates an SVG status badge and returns it alongside a text description.""" + logging.info(f"Generating badge: {label} | {value}") + + label_width = len(label) * 7 + 12 + value_width = len(value) * 7 + 12 + total_width = label_width + value_width + + svg = f"""""" + + return [ + TextContent(type="text", text=f"Badge: {label} — {value}"), + ImageContent( + type="image", + data=base64.b64encode(svg.encode('utf-8')).decode('utf-8'), + mimeType="image/svg+xml" + ) + ] + + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="url", description="The URL of the website to preview.", is_required=True) +async def get_website_preview(url: str) -> List[ContentBlock]: + """Demonstrates returning TextContentBlock and ResourceLinkBlock together. Fetches basic metadata from a URL and returns it with a resource link.""" + import aiohttp + import html + + logging.info(f"Fetching website preview for {url}") + + # Ensure URL has a protocol + if not url.startswith(('http://', 'https://')): + url = f'https://{url}' + logging.info(f"Added https:// protocol to URL: {url}") + + title = url + description = "No description available." + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=10), + headers={"User-Agent": "MCPTool/1.0"}) as response: + html_content = await response.text() + + # Extract title + title_match = re.search(r'