En las partes anteriores construimos un servidor MCP completo con tools, resources y prompts. Para cerrar la guía volvemos al transporte **HTTP**, añadimos **autenticación** con tokens Bearer y claves RSA y, por fin, creamos un **cliente MCP** que se conecta al servidor.
⚠️ Este capítulo continúa el código de las partes anteriores (Parte 1 · Parte 2 · Parte 3).
📚 **Esta entrada es parte de la serie _MCP con FastMCP_**, dividida en cuatro capítulos que se leen en orden:
> * Parte 1: Primer servidor y tools
* Parte 2: Transporte, contexto y resources
* Parte 3: Resources avanzados y prompts
* 👉 **Parte 4: HTTP, autenticación y cliente**
Vuelta a HTTP
Volvemos a configurar http como capa de transporte para las dos últimas cosas que vamos a ver
Servidor MCP
InputPython%%writefile gitHub_MCP_server/github_server.pyimport httpxfrom fastmcp import FastMCP, Contextfrom github import GITHUB_TOKEN, create_github_headersimport datetimeUSER_ID = 1234567890# Create FastMCP servermcp = FastMCP(name="GitHubMCP",instructions="This server provides tools, resources and prompts to interact with the GitHub API.",include_tags={"public"},exclude_tags={"first_issue"})sub_mcp = FastMCP(name="SubMCP",)@mcp.tool(tags={"public", "production"},exclude_args=["user_id"], # user_id has to be injected by server, not provided by LLM)async def list_repository_issues(owner: str, repo_name: str, ctx: Context, user_id: int = USER_ID) -> list[dict]:"""Lists open issues for a given GitHub repository.Args:owner: The owner of the repository (e.g., 'modelcontextprotocol')repo_name: The name of the repository (e.g., 'python-sdk')ctx: The context of the requestuser_id: The user ID (automatically injected by the server)Returns:list[dict]: A list of dictionaries, each containing information about an issue"""# Limit to first 10 issues to avoid very long responsesapi_url = f"https://api.github.com/repos/{owner}/{repo_name}/issues?state=open&per_page=10"ctx.info(f"Fetching issues from {api_url}...")async with httpx.AsyncClient() as client:try:response = await client.get(api_url, headers=create_github_headers())response.raise_for_status()issues_data = response.json()if not issues_data:ctx.info("No open issues found for this repository.")return [{"message": "No open issues found for this repository."}]issues_summary = []for issue in issues_data:# Create a more concise summarysummary = f"#{issue.get('number', 'N/A')}: {issue.get('title', 'No title')}"if issue.get('comments', 0) > 0:summary += f" ({issue.get('comments')} comments)"issues_summary.append({"number": issue.get("number"),"title": issue.get("title"),"user": issue.get("user", {}).get("login"),"url": issue.get("html_url"),"comments": issue.get("comments"),"summary": summary})ctx.info(f"Found {len(issues_summary)} open issues.")# Add context informationresult = {"total_found": len(issues_summary),"repository": f"{owner}/{repo_name}","note": "Showing first 10 open issues" if len(issues_summary) == 10 else f"Showing all {len(issues_summary)} open issues","issues": issues_summary,"requested_by_user_id": user_id}return [result]except httpx.HTTPStatusError as e:error_message = e.response.json().get("message", "No additional message from API.")if e.response.status_code == 403 and GITHUB_TOKEN:error_message += " (Rate limit with token or token lacks permissions?)"elif e.response.status_code == 403 and not GITHUB_TOKEN:error_message += " (Rate limit without token. Consider creating a .env file with GITHUB_TOKEN.)"ctx.error(f"GitHub API error: {e.response.status_code}. {error_message}")return [{"error": f"GitHub API error: {e.response.status_code}","message": error_message}]except Exception as e:ctx.error(f"An unexpected error occurred: {str(e)}")return [{"error": f"An unexpected error occurred: {str(e)}"}]@mcp.tool(tags={"private", "development"})async def list_more_repository_issues(owner: str, repo_name: str) -> list[dict]:"""Lists open issues for a given GitHub repository.Args:owner: The owner of the repository (e.g., 'modelcontextprotocol')repo_name: The name of the repository (e.g., 'python-sdk')Returns:list[dict]: A list of dictionaries, each containing information about an issue"""# Limit to first 100 issues to avoid very long responsesapi_url = f"https://api.github.com/repos/{owner}/{repo_name}/issues?state=open&per_page=100"print(f"Fetching issues from {api_url}...")async with httpx.AsyncClient() as client:try:response = await client.get(api_url, headers=create_github_headers())response.raise_for_status()issues_data = response.json()if not issues_data:print("No open issues found for this repository.")return [{"message": "No open issues found for this repository."}]issues_summary = []for issue in issues_data:# Create a more concise summarysummary = f"#{issue.get('number', 'N/A')}: {issue.get('title', 'No title')}"if issue.get('comments', 0) > 0:summary += f" ({issue.get('comments')} comments)"issues_summary.append({"number": issue.get("number"),"title": issue.get("title"),"user": issue.get("user", {}).get("login"),"url": issue.get("html_url"),"comments": issue.get("comments"),"summary": summary})print(f"Found {len(issues_summary)} open issues.")# Add context informationresult = {"total_found": len(issues_summary),"repository": f"{owner}/{repo_name}","note": "Showing first 10 open issues" if len(issues_summary) == 10 else f"Showing all {len(issues_summary)} open issues","issues": issues_summary}return [result]except httpx.HTTPStatusError as e:error_message = e.response.json().get("message", "No additional message from API.")if e.response.status_code == 403 and GITHUB_TOKEN:error_message += " (Rate limit with token or token lacks permissions?)"elif e.response.status_code == 403 and not GITHUB_TOKEN:error_message += " (Rate limit without token. Consider creating a .env file with GITHUB_TOKEN.)"print(f"GitHub API error: {e.response.status_code}. {error_message}")return [{"error": f"GitHub API error: {e.response.status_code}","message": error_message}]except Exception as e:print(f"An unexpected error occurred: {str(e)}")return [{"error": f"An unexpected error occurred: {str(e)}"}]@mcp.tool(tags={"public", "first_issue"})async def first_repository_issue(owner: str, repo_name: str) -> list[dict]:"""Gets the first issue ever created in a GitHub repository.Args:owner: The owner of the repository (e.g., 'modelcontextprotocol')repo_name: The name of the repository (e.g., 'python-sdk')Returns:list[dict]: A list containing information about the first issue created"""# Get the first issue by sorting by creation date in ascending orderapi_url = f"https://api.github.com/repos/{owner}/{repo_name}/issues?state=all&sort=created&direction=asc&per_page=1"print(f"Fetching first issue from {api_url}...")async with httpx.AsyncClient() as client:try:response = await client.get(api_url, headers=create_github_headers())response.raise_for_status()issues_data = response.json()if not issues_data:print("No issues found for this repository.")return [{"message": "No issues found for this repository."}]first_issue = issues_data[0]# Create a detailed summary of the first issuesummary = f"#{first_issue.get('number', 'N/A')}: {first_issue.get('title', 'No title')}"if first_issue.get('comments', 0) > 0:summary += f" ({first_issue.get('comments')} comments)"issue_info = {"number": first_issue.get("number"),"title": first_issue.get("title"),"user": first_issue.get("user", {}).get("login"),"url": first_issue.get("html_url"),"state": first_issue.get("state"),"comments": first_issue.get("comments"),"created_at": first_issue.get("created_at"),"updated_at": first_issue.get("updated_at"),"body": first_issue.get("body", "")[:500] + "..." if len(first_issue.get("body", "")) > 500 else first_issue.get("body", ""),"summary": summary}print(f"Found first issue: #{first_issue.get('number')} created on {first_issue.get('created_at')}")# Add context informationresult = {"repository": f"{owner}/{repo_name}","note": "This is the very first issue created in this repository","first_issue": issue_info}return [result]except httpx.HTTPStatusError as e:error_message = e.response.json().get("message", "No additional message from API.")if e.response.status_code == 403 and GITHUB_TOKEN:error_message += " (Rate limit with token or token lacks permissions?)"elif e.response.status_code == 403 and not GITHUB_TOKEN:error_message += " (Rate limit without token. Consider creating a .env file with GITHUB_TOKEN.)"print(f"GitHub API error: {e.response.status_code}. {error_message}")return [{"error": f"GitHub API error: {e.response.status_code}","message": error_message}]except Exception as e:print(f"An unexpected error occurred: {str(e)}")return [{"error": f"An unexpected error occurred: {str(e)}"}]@mcp.resource("resource://server_info", tags={"public"})def server_info(ctx: Context) -> str:"""Returns information about the server."""return {"info": "This is the MCP GitHub server development for MaximoFN blog post","requested_id": ctx.request_id}# Use: ¿Puedes leer el resource github://repo/facebook/react para obtener información detallada del repositorio?@mcp.resource("github://repo/{owner}/{repo_name}", tags={"public"})async def repository_info(owner: str, repo_name: str, ctx: Context) -> dict:"""Returns detailed information about a GitHub repository.Args:owner: The owner of the repository (e.g., 'modelcontextprotocol')repo_name: The name of the repository (e.g., 'python-sdk')ctx: The context of the requestReturns:dict: Repository information including name, description, stats, etc."""api_url = f"https://api.github.com/repos/{owner}/{repo_name}"ctx.info(f"Fetching repository information from {api_url}...")async with httpx.AsyncClient() as client:try:response = await client.get(api_url, headers=create_github_headers())response.raise_for_status()repo_data = response.json()# Extract relevant repository informationrepo_info = {"name": repo_data.get("name"),"full_name": repo_data.get("full_name"),"description": repo_data.get("description"),"owner": {"login": repo_data.get("owner", {}).get("login"),"type": repo_data.get("owner", {}).get("type")},"html_url": repo_data.get("html_url"),"clone_url": repo_data.get("clone_url"),"ssh_url": repo_data.get("ssh_url"),"language": repo_data.get("language"),"size": repo_data.get("size"), # Size in KB"stargazers_count": repo_data.get("stargazers_count"),"watchers_count": repo_data.get("watchers_count"),"forks_count": repo_data.get("forks_count"),"open_issues_count": repo_data.get("open_issues_count"),"default_branch": repo_data.get("default_branch"),"created_at": repo_data.get("created_at"),"updated_at": repo_data.get("updated_at"),"pushed_at": repo_data.get("pushed_at"),"is_private": repo_data.get("private"),"is_fork": repo_data.get("fork"),"is_archived": repo_data.get("archived"),"has_issues": repo_data.get("has_issues"),"has_projects": repo_data.get("has_projects"),"has_wiki": repo_data.get("has_wiki"),"license": repo_data.get("license", {}).get("name") if repo_data.get("license") else None,"topics": repo_data.get("topics", [])}ctx.info(f"Successfully retrieved information for repository {owner}/{repo_name}")return repo_infoexcept httpx.HTTPStatusError as e:error_message = e.response.json().get("message", "No additional message from API.")if e.response.status_code == 404:error_message = f"Repository {owner}/{repo_name} not found or is private."elif e.response.status_code == 403 and GITHUB_TOKEN:error_message += " (Rate limit with token or token lacks permissions?)"elif e.response.status_code == 403 and not GITHUB_TOKEN:error_message += " (Rate limit without token. Consider creating a .env file with GITHUB_TOKEN.)"ctx.error(f"GitHub API error: {e.response.status_code}. {error_message}")return {"error": f"GitHub API error: {e.response.status_code}","message": error_message,"repository": f"{owner}/{repo_name}"}except Exception as e:ctx.error(f"An unexpected error occurred: {str(e)}")return {"error": f"An unexpected error occurred: {str(e)}","repository": f"{owner}/{repo_name}"}@mcp.prompt(name="generate_issues_prompt",description="Generates a structured prompt for asking about GitHub repository issues. Use this when users want to formulate questions about repository issues, or need help creating prompts for issue analysis.",tags={"public"})def generate_issues_prompt(owner: str, repo_name: str) -> str:"""Generates a structured prompt for asking about GitHub repository issues.This prompt template helps users formulate clear questions about repository issuesand can be used as a starting point for issue analysis or research.Args:owner: Repository owner (e.g., 'huggingface', 'microsoft')repo_name: Repository name (e.g., 'transformers', 'vscode')Returns:A formatted prompt asking about repository issues"""return f"""Please provide information about the open issues in the repository {owner}/{repo_name}.I'm interested in:- Current open issues and their status- Recent issue trends and patterns- Common issue categories or topics- Any critical or high-priority issuesRepository: {owner}/{repo_name}"""@sub_mcp.tool(tags={"public"})def hello_world() -> str:"""Returns a simple greeting."""return "Hello, world!"mcp.mount("sub_mcp", sub_mcp)if __name__ == "__main__":print("DEBUG: Starting FastMCP GitHub server...")print(f"DEBUG: Server name: {mcp.name}")# Initialize and run the server, run with uv run client.py http://localhost:8000/mcpmcp.run(transport="streamable-http",host="0.0.0.0",port=8000,)Copied
Overwriting gitHub_MCP_server/github_server.py
Cliente MCP
InputPython%%writefile client_MCP/client.pyimport sysimport asynciofrom contextlib import AsyncExitStackfrom anthropic import Anthropicfrom dotenv import load_dotenvfrom fastmcp import Client# Load environment variables from .env fileload_dotenv()class FastMCPClient:"""FastMCP client that integrates with Claude to process user queriesand use tools and resources exposed by a FastMCP server."""def __init__(self):"""Initialize the FastMCP client with Anthropic and resource management."""self.exit_stack = AsyncExitStack()self.anthropic = Anthropic()self.client = Noneasync def connect_to_server(self, server_url: str):"""Connect to the specified FastMCP server via HTTP.Args:server_url: URL of the HTTP server (e.g., "http://localhost:8000/mcp")"""print(f"🔗 Connecting to FastMCP HTTP server: {server_url}")# Create FastMCP client for HTTP connection using SSE transportself.client = Client(server_url)# Note: FastMCP Client automatically detects HTTP URLs and uses SSE transportprint("✅ Client created successfully")async def list_available_tools(self):"""List available tools in the FastMCP server."""try:# Get list of tools from the server using FastMCP contextasync with self.client as client:tools = await client.list_tools()if tools:print(f" 🛠️ Available tools ({len(tools)}):")print("=" * 50)for tool in tools:print(f"📋 {tool.name}")if tool.description:print(f" Description: {tool.description}")# Show parameters if availableif hasattr(tool, 'inputSchema') and tool.inputSchema:if 'properties' in tool.inputSchema:params = list(tool.inputSchema['properties'].keys())print(f" Parameters: {', '.join(params)}")print()else:print("⚠️ No tools found in the server")except Exception as e:print(f"❌ Error listing tools: {str(e)}")async def list_available_resources(self):"""List available resources in the FastMCP server."""try:# Get list of resources from the server using FastMCP contextasync with self.client as client:resources = await client.list_resources()if resources:print(f" 📚 Available resources ({len(resources)}):")print("=" * 50)for resource in resources:print(f"📄 {resource.uri}")if resource.name:print(f" Name: {resource.name}")if resource.description:print(f" Description: {resource.description}")if resource.mimeType:print(f" MIME Type: {resource.mimeType}")print()else:print("⚠️ No resources found in the server")except Exception as e:print(f"❌ Error listing resources: {str(e)}")async def read_resource(self, resource_uri: str):"""Read a specific resource from the server.Args:resource_uri: URI of the resource to readReturns:str: Resource content"""try:async with self.client as client:result = await client.read_resource(resource_uri)return resultexcept Exception as e:print(f"❌ Error reading resource {resource_uri}: {str(e)}")return Noneasync def process_query(self, query: str) -> str:"""Process a user query, interacting with Claude and FastMCP tools and resources.Args:query: User queryReturns:str: Final processed response"""try:# Use FastMCP context for all operationsasync with self.client as client:# Get available tools and resourcestools_list = await client.list_tools()resources_list = await client.list_resources()# Prepare tools for Claude in correct formatclaude_tools = []for tool in tools_list:claude_tool = {"name": tool.name,"description": tool.description or f"Tool {tool.name}","input_schema": tool.inputSchema or {"type": "object", "properties": {}}}claude_tools.append(claude_tool)# Add a special tool for reading resources (including template resources)resource_description = "Read a resource from the MCP server. "if resources_list:# Convert URIs to strings to avoid AnyUrl object issuesresource_uris = [str(r.uri) for r in resources_list]resource_description += f"Available static resources: {', '.join(resource_uris)}. "resource_description += "Also supports template resources like github://repo/owner/repo_name for GitHub repository information."claude_tools.append({"name": "read_mcp_resource","description": resource_description,"input_schema": {"type": "object","properties": {"resource_uri": {"type": "string","description": "URI of the resource to read. Can be static (like resource://server_info) or template-based (like github://repo/facebook/react)"}},"required": ["resource_uri"]}})# Create initial message for Claudemessages = [{"role": "user","content": query}]# First call to Clauderesponse = self.anthropic.messages.create(model="claude-3-5-sonnet-20241022",max_tokens=6000,messages=messages,tools=claude_tools if claude_tools else None)# Process Claude's responseresponse_text = ""for content_block in response.content:if content_block.type == "text":response_text += content_block.textelif content_block.type == "tool_use":# Claude wants to use a tooltool_name = content_block.nametool_args = content_block.inputtool_call_id = content_block.idprint(f"🔧 Claude wants to use: {tool_name}")print(f"📝 Arguments: {tool_args}")try:if tool_name == "read_mcp_resource":# Handle resource readingresource_uri = tool_args.get("resource_uri")if resource_uri:tool_result = await client.read_resource(resource_uri)print(f"📖 Resource read successfully: {resource_uri}")# Better handling of resource resultif hasattr(tool_result, 'content'):# If it's a resource response object, extract contentif hasattr(tool_result.content, 'text'):result_content = tool_result.content.textelse:result_content = str(tool_result.content)else:# If it's already a string or simple objectresult_content = str(tool_result)else:tool_result = "Error: No resource URI provided"result_content = tool_resultelse:# Execute regular tool on the FastMCP servertool_result = await client.call_tool(tool_name, tool_args)print(f"✅ Tool executed successfully")result_content = str(tool_result)# Add tool result to the conversationmessages.append({"role": "assistant","content": response.content})# Format result for Claudeif tool_result:messages.append({"role": "user","content": [{"type": "tool_result","tool_use_id": tool_call_id,"content": f"Tool result: {result_content}"}]})else:messages.append({"role": "user","content": [{"type": "tool_result","tool_use_id": tool_call_id,"content": "Tool executed without response content"}]})# Second call to Claude with the tool resultfinal_response = self.anthropic.messages.create(model="claude-3-5-sonnet-20241022",max_tokens=6000,messages=messages,tools=claude_tools if claude_tools else None)# Extract text from the final responsefor final_content in final_response.content:if final_content.type == "text":response_text += final_content.textexcept Exception as e:error_msg = f"❌ Error executing {tool_name}: {str(e)}"print(error_msg)response_text += f" {error_msg}"return response_textexcept Exception as e:error_msg = f"❌ Error processing query: {str(e)}"print(error_msg)return error_msgasync def chat_loop(self):"""Main chat loop with user interaction."""print(" 🤖 FastMCP HTTP client started. Write 'quit', 'q', 'exit', 'salir' to exit.")print("💬 You can ask questions about GitHub repositories!")print("📚 The client can use tools and resources from the FastMCP server")print("-" * 60)while True:try:# Request user inputuser_input = input(" 👤 You: ").strip()if user_input.lower() in ['quit', 'q', 'exit', 'salir']:print("👋 Bye!")breakif not user_input:continueprint(" 🤔 Claude is thinking...")# Process queryresponse = await self.process_query(user_input)# Show responseprint(f" 🤖 Claude: {response}")except KeyboardInterrupt:print(" 👋 Disconnecting...")breakexcept Exception as e:print(f" ❌ Error in chat: {str(e)}")continueasync def cleanup(self):"""Clean up resources and close connections."""print("🧹 Cleaning up resources...")# FastMCP Client cleanup is handled automatically by context managerawait self.exit_stack.aclose()print("✅ Resources released")async def main():"""Main function that initializes and runs the FastMCP client."""# Verify command line argumentsif len(sys.argv) != 2:print("❌ Usage: python client.py <http_server_url>")print("📝 Example: python client.py http://localhost:8000/mcp")print("📝 Note: Now connects to HTTP server instead of executing script")sys.exit(1)server_url = sys.argv[1]# Validate URL formatif not server_url.startswith(('http://', 'https://')):print("❌ Error: Server URL must start with http:// or https://")print("📝 Example: python client.py http://localhost:8000")sys.exit(1)# Create and run clientclient = FastMCPClient()try:# Connect to the serverawait client.connect_to_server(server_url)# List available tools and resources after connectionawait client.list_available_tools()await client.list_available_resources()# Start chat loopawait client.chat_loop()except Exception as e:print(f"❌ Fatal error: {str(e)}")finally:# Ensure resources are cleaned upawait client.cleanup()if __name__ == "__main__":# Entry point of the scriptasyncio.run(main())Copied
Overwriting client_MCP/client.py
Autenticación
Si queremos crear un servidor MCP al que solo se pueda conectar determinados clientes, podemos añadir autenticación
Servidor MCP
Creamos el servidor con autenticación
InputPython%%writefile gitHub_MCP_server/github_server.pyimport httpxfrom fastmcp import FastMCP, Contextfrom fastmcp.server.auth import BearerAuthProviderfrom fastmcp.server.auth.providers.bearer import RSAKeyPairfrom fastmcp.server.dependencies import get_access_token, AccessTokenfrom github import GITHUB_TOKEN, create_github_headersimport datetimeUSER_ID = 1234567890# Generate RSA key pair for development and testingprint("🔐 Generating RSA key pair for authentication...")key_pair = RSAKeyPair.generate()# Configure Bearer authentication providerauth_provider = BearerAuthProvider(public_key=key_pair.public_key,issuer="https://github-mcp.maxfn.dev",audience="github-mcp-server",required_scopes=["github:read"] # Global scope required for all requests)# Generate a test token for developmentdevelopment_token = key_pair.create_token(subject="dev-user-maxfn",issuer="https://github-mcp.maxfn.dev",audience="github-mcp-server",scopes=["github:read", "github:write"],expires_in_seconds=3600 * 24 # Token is valid for 24 hours)print(f"🎫 Development token generated:")print(f" {development_token}")print("💡 Use this token in the client to authenticate")print("-" * 60)# Create FastMCP server with authenticationmcp = FastMCP(name="GitHubMCP",instructions="This server provides tools, resources and prompts to interact with the GitHub API.",include_tags={"public"},exclude_tags={"first_issue"},auth=auth_provider # Add authentication to the server)sub_mcp = FastMCP(name="SubMCP",)@mcp.tool(tags={"public", "production"},exclude_args=["user_id"], # user_id has to be injected by server, not provided by LLM)async def list_repository_issues(owner: str, repo_name: str, ctx: Context, user_id: int = USER_ID) -> list[dict]:"""Lists open issues for a given GitHub repository.Args:owner: The owner of the repository (e.g., 'modelcontextprotocol')repo_name: The name of the repository (e.g., 'python-sdk')ctx: The context of the requestuser_id: The user ID (automatically injected by the server)Returns:list[dict]: A list of dictionaries, each containing information about an issue"""# Limit to first 10 issues to avoid very long responsesapi_url = f"https://api.github.com/repos/{owner}/{repo_name}/issues?state=open&per_page=10"ctx.info(f"Fetching issues from {api_url}...")async with httpx.AsyncClient() as client:try:response = await client.get(api_url, headers=create_github_headers())response.raise_for_status()issues_data = response.json()if not issues_data:ctx.info("No open issues found for this repository.")return [{"message": "No open issues found for this repository."}]issues_summary = []for issue in issues_data:# Create a more concise summarysummary = f"#{issue.get('number', 'N/A')}: {issue.get('title', 'No title')}"if issue.get('comments', 0) > 0:summary += f" ({issue.get('comments')} comments)"issues_summary.append({"number": issue.get("number"),"title": issue.get("title"),"user": issue.get("user", {}).get("login"),"url": issue.get("html_url"),"comments": issue.get("comments"),"summary": summary})ctx.info(f"Found {len(issues_summary)} open issues.")# Get authenticated access token informationtry:access_token: AccessToken = get_access_token()authenticated_user = access_token.client_iduser_scopes = access_token.scopesctx.info(f"Request authenticated for user: {authenticated_user} with scopes: {user_scopes}")except Exception as e:authenticated_user = "unknown"user_scopes = []ctx.warning(f"Could not get access token info: {e}")# Add context informationresult = {"total_found": len(issues_summary),"repository": f"{owner}/{repo_name}","note": "Showing first 10 open issues" if len(issues_summary) == 10 else f"Showing all {len(issues_summary)} open issues","issues": issues_summary,"requested_by_user_id": user_id,"authenticated_user": authenticated_user,"user_scopes": user_scopes}return [result]except httpx.HTTPStatusError as e:error_message = e.response.json().get("message", "No additional message from API.")if e.response.status_code == 403 and GITHUB_TOKEN:error_message += " (Rate limit with token or token lacks permissions?)"elif e.response.status_code == 403 and not GITHUB_TOKEN:error_message += " (Rate limit without token. Consider creating a .env file with GITHUB_TOKEN.)"ctx.error(f"GitHub API error: {e.response.status_code}. {error_message}")return [{"error": f"GitHub API error: {e.response.status_code}","message": error_message}]except Exception as e:ctx.error(f"An unexpected error occurred: {str(e)}")return [{"error": f"An unexpected error occurred: {str(e)}"}]@mcp.tool(tags={"private", "development"})async def list_more_repository_issues(owner: str, repo_name: str) -> list[dict]:"""Lists open issues for a given GitHub repository.Args:owner: The owner of the repository (e.g., 'modelcontextprotocol')repo_name: The name of the repository (e.g., 'python-sdk')Returns:list[dict]: A list of dictionaries, each containing information about an issue"""# Limit to first 100 issues to avoid very long responsesapi_url = f"https://api.github.com/repos/{owner}/{repo_name}/issues?state=open&per_page=100"print(f"Fetching issues from {api_url}...")async with httpx.AsyncClient() as client:try:response = await client.get(api_url, headers=create_github_headers())response.raise_for_status()issues_data = response.json()if not issues_data:print("No open issues found for this repository.")return [{"message": "No open issues found for this repository."}]issues_summary = []for issue in issues_data:# Create a more concise summarysummary = f"#{issue.get('number', 'N/A')}: {issue.get('title', 'No title')}"if issue.get('comments', 0) > 0:summary += f" ({issue.get('comments')} comments)"issues_summary.append({"number": issue.get("number"),"title": issue.get("title"),"user": issue.get("user", {}).get("login"),"url": issue.get("html_url"),"comments": issue.get("comments"),"summary": summary})print(f"Found {len(issues_summary)} open issues.")# Add context informationresult = {"total_found": len(issues_summary),"repository": f"{owner}/{repo_name}","note": "Showing first 10 open issues" if len(issues_summary) == 10 else f"Showing all {len(issues_summary)} open issues","issues": issues_summary}return [result]except httpx.HTTPStatusError as e:error_message = e.response.json().get("message", "No additional message from API.")if e.response.status_code == 403 and GITHUB_TOKEN:error_message += " (Rate limit with token or token lacks permissions?)"elif e.response.status_code == 403 and not GITHUB_TOKEN:error_message += " (Rate limit without token. Consider creating a .env file with GITHUB_TOKEN.)"print(f"GitHub API error: {e.response.status_code}. {error_message}")return [{"error": f"GitHub API error: {e.response.status_code}","message": error_message}]except Exception as e:print(f"An unexpected error occurred: {str(e)}")return [{"error": f"An unexpected error occurred: {str(e)}"}]@mcp.tool(tags={"public", "first_issue"})async def first_repository_issue(owner: str, repo_name: str) -> list[dict]:"""Gets the first issue ever created in a GitHub repository.Args:owner: The owner of the repository (e.g., 'modelcontextprotocol')repo_name: The name of the repository (e.g., 'python-sdk')Returns:list[dict]: A list containing information about the first issue created"""# Get the first issue by sorting by creation date in ascending orderapi_url = f"https://api.github.com/repos/{owner}/{repo_name}/issues?state=all&sort=created&direction=asc&per_page=1"print(f"Fetching first issue from {api_url}...")async with httpx.AsyncClient() as client:try:response = await client.get(api_url, headers=create_github_headers())response.raise_for_status()issues_data = response.json()if not issues_data:print("No issues found for this repository.")return [{"message": "No issues found for this repository."}]first_issue = issues_data[0]# Create a detailed summary of the first issuesummary = f"#{first_issue.get('number', 'N/A')}: {first_issue.get('title', 'No title')}"if first_issue.get('comments', 0) > 0:summary += f" ({first_issue.get('comments')} comments)"issue_info = {"number": first_issue.get("number"),"title": first_issue.get("title"),"user": first_issue.get("user", {}).get("login"),"url": first_issue.get("html_url"),"state": first_issue.get("state"),"comments": first_issue.get("comments"),"created_at": first_issue.get("created_at"),"updated_at": first_issue.get("updated_at"),"body": first_issue.get("body", "")[:500] + "..." if len(first_issue.get("body", "")) > 500 else first_issue.get("body", ""),"summary": summary}print(f"Found first issue: #{first_issue.get('number')} created on {first_issue.get('created_at')}")# Add context informationresult = {"repository": f"{owner}/{repo_name}","note": "This is the very first issue created in this repository","first_issue": issue_info}return [result]except httpx.HTTPStatusError as e:error_message = e.response.json().get("message", "No additional message from API.")if e.response.status_code == 403 and GITHUB_TOKEN:error_message += " (Rate limit with token or token lacks permissions?)"elif e.response.status_code == 403 and not GITHUB_TOKEN:error_message += " (Rate limit without token. Consider creating a .env file with GITHUB_TOKEN.)"print(f"GitHub API error: {e.response.status_code}. {error_message}")return [{"error": f"GitHub API error: {e.response.status_code}","message": error_message}]except Exception as e:print(f"An unexpected error occurred: {str(e)}")return [{"error": f"An unexpected error occurred: {str(e)}"}]@mcp.resource("resource://server_info", tags={"public"})def server_info(ctx: Context) -> str:"""Returns information about the server."""return {"info": "This is the MCP GitHub server development for MaximoFN blog post","requested_id": ctx.request_id}@mcp.resource("github://repo/{owner}/{repo_name}", tags={"public"})async def repository_info(owner: str, repo_name: str, ctx: Context) -> dict:"""Returns detailed information about a GitHub repository.Args:owner: The owner of the repository (e.g., 'modelcontextprotocol')repo_name: The name of the repository (e.g., 'python-sdk')ctx: The context of the requestReturns:dict: Repository information including name, description, stats, etc."""api_url = f"https://api.github.com/repos/{owner}/{repo_name}"ctx.info(f"Fetching repository information from {api_url}...")async with httpx.AsyncClient() as client:try:response = await client.get(api_url, headers=create_github_headers())response.raise_for_status()repo_data = response.json()# Extract relevant repository informationrepo_info = {"name": repo_data.get("name"),"full_name": repo_data.get("full_name"),"description": repo_data.get("description"),"owner": {"login": repo_data.get("owner", {}).get("login"),"type": repo_data.get("owner", {}).get("type")},"html_url": repo_data.get("html_url"),"clone_url": repo_data.get("clone_url"),"ssh_url": repo_data.get("ssh_url"),"language": repo_data.get("language"),"size": repo_data.get("size"), # Size in KB"stargazers_count": repo_data.get("stargazers_count"),"watchers_count": repo_data.get("watchers_count"),"forks_count": repo_data.get("forks_count"),"open_issues_count": repo_data.get("open_issues_count"),"default_branch": repo_data.get("default_branch"),"created_at": repo_data.get("created_at"),"updated_at": repo_data.get("updated_at"),"pushed_at": repo_data.get("pushed_at"),"is_private": repo_data.get("private"),"is_fork": repo_data.get("fork"),"is_archived": repo_data.get("archived"),"has_issues": repo_data.get("has_issues"),"has_projects": repo_data.get("has_projects"),"has_wiki": repo_data.get("has_wiki"),"license": repo_data.get("license", {}).get("name") if repo_data.get("license") else None,"topics": repo_data.get("topics", [])}ctx.info(f"Successfully retrieved information for repository {owner}/{repo_name}")return repo_infoexcept httpx.HTTPStatusError as e:error_message = e.response.json().get("message", "No additional message from API.")if e.response.status_code == 404:error_message = f"Repository {owner}/{repo_name} not found or is private."elif e.response.status_code == 403 and GITHUB_TOKEN:error_message += " (Rate limit with token or token lacks permissions?)"elif e.response.status_code == 403 and not GITHUB_TOKEN:error_message += " (Rate limit without token. Consider creating a .env file with GITHUB_TOKEN.)"ctx.error(f"GitHub API error: {e.response.status_code}. {error_message}")return {"error": f"GitHub API error: {e.response.status_code}","message": error_message,"repository": f"{owner}/{repo_name}"}except Exception as e:ctx.error(f"An unexpected error occurred: {str(e)}")return {"error": f"An unexpected error occurred: {str(e)}","repository": f"{owner}/{repo_name}"}@mcp.prompt(name="generate_issues_prompt",description="Generates a structured prompt for asking about GitHub repository issues. Use this when users want to formulate questions about repository issues, or need help creating prompts for issue analysis.",tags={"public"})def generate_issues_prompt(owner: str, repo_name: str) -> str:"""Generates a structured prompt for asking about GitHub repository issues.This prompt template helps users formulate clear questions about repository issuesand can be used as a starting point for issue analysis or research.Args:owner: Repository owner (e.g., 'huggingface', 'microsoft')repo_name: Repository name (e.g., 'transformers', 'vscode')Returns:A formatted prompt asking about repository issues"""return f"""Please provide information about the open issues in the repository {owner}/{repo_name}.I'm interested in:- Current open issues and their status- Recent issue trends and patterns- Common issue categories or topics- Any critical or high-priority issuesRepository: {owner}/{repo_name}"""@sub_mcp.tool(tags={"public"})def hello_world() -> str:"""Returns a simple greeting."""return "Hello, world!"mcp.mount("sub_mcp", sub_mcp)if __name__ == "__main__":print("DEBUG: Starting FastMCP GitHub server...")print(f"DEBUG: Server name: {mcp.name}")# Initialize and run the server, run with uv run client.py http://localhost:8000/mcp# 1. Run server with uv run github_server.py. It gives you a token to use in the client.py# 2. Run client.py with the token you got from the server.py - uv run client.py http://localhost:8000/mcp <your_bearer_token>mcp.run(transport="streamable-http",host="0.0.0.0",port=8000,)Copied
Overwriting gitHub_MCP_server/github_server.py
Creamos un proveedor de autenticación para el servidor y un token de desarrollo temporal
# Generate RSA key pair for development and testing
print("🔐 Generating RSA key pair for authentication...")
key_pair = RSAKeyPair.generate()
# Configure Bearer authentication provider
auth_provider = BearerAuthProvider(
public_key=key_pair.public_key,
issuer="https://github-mcp.maxfn.dev",
audience="github-mcp-server",
required_scopes=["github:read"] # Global scope required for all requests
)
# Generate a test token for development
development_token = key_pair.create_token(
subject="dev-user-maxfn",
issuer="https://github-mcp.maxfn.dev",
audience="github-mcp-server",
scopes=["github:read", "github:write"],
expires_in_seconds=3600 * 24 # Token is valid for 24 hours
)
print(f"🎫 Development token generated:")
print(f" {development_token}")
print("💡 Use this token in the client to authenticate")
print("-" * 60)Lo usamos al crear el servidor MCP
mcp = FastMCP(
name="GitHubMCP",
instructions="This server provides tools, resources and prompts to interact with the GitHub API.",
include_tags={"public"},
exclude_tags={"first_issue"},
auth=auth_provider # Add authentication to the server
)Cliente MCP
Creamos el cliente MCP con autenticación
InputPython%%writefile client_MCP/client.pyimport sysimport asynciofrom contextlib import AsyncExitStackfrom anthropic import Anthropicfrom dotenv import load_dotenvfrom fastmcp import Clientfrom fastmcp.client.auth import BearerAuth# Load environment variables from .env fileload_dotenv()class FastMCPClient:"""FastMCP client that integrates with Claude to process user queriesand use tools and resources exposed by a FastMCP server."""def __init__(self):"""Initialize the FastMCP client with Anthropic and resource management."""self.exit_stack = AsyncExitStack()self.anthropic = Anthropic()self.client = Noneasync def connect_to_server(self, server_url: str, auth_token: str = None):"""Connect to the specified FastMCP server via HTTP with optional authentication.Args:server_url: URL of the HTTP server (e.g., "http://localhost:8000/mcp")auth_token: Bearer token for authentication (optional)"""print(f"🔗 Connecting to FastMCP HTTP server: {server_url}")# Create authentication if token is providedauth = Noneif auth_token:auth = BearerAuth(token=auth_token)print("🔐 Using Bearer token authentication")else:print("⚠️ No authentication token provided - connecting without auth")# Create FastMCP client for HTTP connection using SSE transportself.client = Client(server_url, auth=auth)# Note: FastMCP Client automatically detects HTTP URLs and uses SSE transportprint("✅ Client created successfully")async def list_available_tools(self):"""List available tools in the FastMCP server."""try:# Get list of tools from the server using FastMCP contextasync with self.client as client:tools = await client.list_tools()if tools:print(f" 🛠️ Available tools ({len(tools)}):")print("=" * 50)for tool in tools:print(f"📋 {tool.name}")if tool.description:print(f" Description: {tool.description}")# Show parameters if availableif hasattr(tool, 'inputSchema') and tool.inputSchema:if 'properties' in tool.inputSchema:params = list(tool.inputSchema['properties'].keys())print(f" Parameters: {', '.join(params)}")print()else:print("⚠️ No tools found in the server")except Exception as e:print(f"❌ Error listing tools: {str(e)}")async def list_available_resources(self):"""List available resources in the FastMCP server."""try:# Get list of resources from the server using FastMCP contextasync with self.client as client:resources = await client.list_resources()if resources:print(f" 📚 Available resources ({len(resources)}):")print("=" * 50)for resource in resources:print(f"📄 {resource.uri}")if resource.name:print(f" Name: {resource.name}")if resource.description:print(f" Description: {resource.description}")if resource.mimeType:print(f" MIME Type: {resource.mimeType}")print()else:print("⚠️ No resources found in the server")except Exception as e:print(f"❌ Error listing resources: {str(e)}")async def list_available_prompts(self):"""List available prompts in the FastMCP server."""try:# Get list of prompts from the server using FastMCP contextasync with self.client as client:prompts = await client.list_prompts()if prompts:print(f" 💭 Available prompts ({len(prompts)}):")print("=" * 50)for prompt in prompts:print(f"🎯 {prompt.name}")if prompt.description:print(f" Description: {prompt.description}")# Show parameters if availableif hasattr(prompt, 'arguments') and prompt.arguments:params = []for arg in prompt.arguments:param_info = f"{arg.name}: {arg.description or 'No description'}"if arg.required:param_info += " (required)"params.append(param_info)print(f" Parameters: {', '.join(params)}")print()else:print("⚠️ No prompts found in the server")except Exception as e:print(f"❌ Error listing prompts: {str(e)}")async def read_resource(self, resource_uri: str):"""Read a specific resource from the server.Args:resource_uri: URI of the resource to readReturns:str: Resource content"""try:async with self.client as client:result = await client.read_resource(resource_uri)return resultexcept Exception as e:print(f"❌ Error reading resource {resource_uri}: {str(e)}")return Noneasync def get_prompt(self, prompt_name: str, prompt_args: dict = None):"""Get/call a specific prompt from the server.Args:prompt_name: Name of the prompt to callprompt_args: Arguments for the prompt (if any)Returns:str: Generated prompt content"""try:async with self.client as client:if prompt_args:result = await client.get_prompt(prompt_name, prompt_args)else:result = await client.get_prompt(prompt_name)# Extract the prompt text from the responseif hasattr(result, 'messages') and result.messages:# FastMCP returns prompts as message objectsreturn ' '.join([msg.content.text for msg in result.messages if hasattr(msg.content, 'text')])elif hasattr(result, 'content'):return str(result.content)else:return str(result)except Exception as e:print(f"❌ Error getting prompt {prompt_name}: {str(e)}")return Noneasync def process_query(self, query: str) -> str:"""Process a user query, interacting with Claude and FastMCP tools and resources.Args:query: User queryReturns:str: Final processed response"""try:# Use FastMCP context for all operationsasync with self.client as client:# Get available tools and resourcestools_list = await client.list_tools()resources_list = await client.list_resources()# Prepare tools for Claude in correct formatclaude_tools = []for tool in tools_list:claude_tool = {"name": tool.name,"description": tool.description or f"Tool {tool.name}","input_schema": tool.inputSchema or {"type": "object", "properties": {}}}claude_tools.append(claude_tool)# Add a special tool for reading resources (including template resources)resource_description = "Read a resource from the MCP server. "if resources_list:# Convert URIs to strings to avoid AnyUrl object issuesresource_uris = [str(r.uri) for r in resources_list]resource_description += f"Available static resources: {', '.join(resource_uris)}. "resource_description += "Also supports template resources like github://repo/owner/repo_name for GitHub repository information."claude_tools.append({"name": "read_mcp_resource","description": resource_description,"input_schema": {"type": "object","properties": {"resource_uri": {"type": "string","description": "URI of the resource to read. Can be static (like resource://server_info) or template-based (like github://repo/facebook/react)"}},"required": ["resource_uri"]}})# Add a special tool for using promptsprompt_description = "Generate specialized prompts from the MCP server. Use this when users want to: "prompt_description += "- Create well-structured questions about repositories "prompt_description += "- Get help formulating prompts for specific tasks "prompt_description += "- Generate template questions for analysis "if prompts_list:prompt_names = [p.name for p in prompts_list]prompt_description += f" Available prompts: {', '.join(prompt_names)} "prompt_description += "- generate_issues_prompt: Creates structured questions about GitHub repository issues"prompt_description += " IMPORTANT: Use prompts when users explicitly ask for help creating questions or prompts, or when they want to formulate better questions about repositories."claude_tools.append({"name": "use_mcp_prompt","description": prompt_description,"input_schema": {"type": "object","properties": {"prompt_name": {"type": "string","description": "Name of the prompt to use. Available: 'generate_issues_prompt'"},"prompt_args": {"type": "object","description": "Arguments for the prompt. For generate_issues_prompt: {'owner': 'repo-owner', 'repo_name': 'repo-name'}","properties": {"owner": {"type": "string","description": "Repository owner (e.g., 'huggingface', 'microsoft')"},"repo_name": {"type": "string","description": "Repository name (e.g., 'transformers', 'vscode')"}}}},"required": ["prompt_name"]}})# Create initial message for Claudemessages = [{"role": "user","content": query}]# First call to Clauderesponse = self.anthropic.messages.create(model="claude-3-5-sonnet-20241022",max_tokens=6000,messages=messages,tools=claude_tools if claude_tools else None)# Process Claude's responseresponse_text = ""for content_block in response.content:if content_block.type == "text":response_text += content_block.textelif content_block.type == "tool_use":# Claude wants to use a tooltool_name = content_block.nametool_args = content_block.inputtool_call_id = content_block.idprint(f"🔧 Claude wants to use: {tool_name}")print(f"📝 Arguments: {tool_args}")try:if tool_name == "read_mcp_resource":# Handle resource readingresource_uri = tool_args.get("resource_uri")if resource_uri:tool_result = await client.read_resource(resource_uri)print(f"📖 Resource read successfully: {resource_uri}")# Better handling of resource resultif hasattr(tool_result, 'content'):# If it's a resource response object, extract contentif hasattr(tool_result.content, 'text'):result_content = tool_result.content.textelse:result_content = str(tool_result.content)else:# If it's already a string or simple objectresult_content = str(tool_result)else:tool_result = "Error: No resource URI provided"result_content = tool_resultelif tool_name == "use_mcp_prompt":# Handle prompt usageprompt_name = tool_args.get("prompt_name")prompt_args = tool_args.get("prompt_args", {})if prompt_name:tool_result = await self.get_prompt(prompt_name, prompt_args)print(f"💭 Prompt '{prompt_name}' generated successfully")result_content = str(tool_result) if tool_result else "Error generating prompt"else:tool_result = "Error: No prompt name provided"result_content = tool_resultelse:# Execute regular tool on the FastMCP servertool_result = await client.call_tool(tool_name, tool_args)print(f"✅ Tool executed successfully")result_content = str(tool_result)# Add tool result to the conversationmessages.append({"role": "assistant","content": response.content})# Format result for Claudeif tool_result:messages.append({"role": "user","content": [{"type": "tool_result","tool_use_id": tool_call_id,"content": f"Tool result: {result_content}"}]})else:messages.append({"role": "user","content": [{"type": "tool_result","tool_use_id": tool_call_id,"content": "Tool executed without response content"}]})# Second call to Claude with the tool resultfinal_response = self.anthropic.messages.create(model="claude-3-5-sonnet-20241022",max_tokens=6000,messages=messages,tools=claude_tools if claude_tools else None)# Extract text from the final responsefor final_content in final_response.content:if final_content.type == "text":response_text += final_content.textexcept Exception as e:error_msg = f"❌ Error executing {tool_name}: {str(e)}"print(error_msg)response_text += f" {error_msg}"return response_textexcept Exception as e:error_msg = f"❌ Error processing query: {str(e)}"print(error_msg)return error_msgasync def chat_loop(self):"""Main chat loop with user interaction."""print(" 🤖 FastMCP HTTP client started. Write 'quit', 'q', 'exit', 'salir' to exit.")print("💬 You can ask questions about GitHub repositories!")print("📚 The client can use tools, resources, and prompts from the FastMCP server")print()print("💭 PROMPT Examples:")print(" • 'Generate a prompt for asking about issues in facebook/react'")print(" • 'Help me create a good question about microsoft/vscode issues'")print(" • 'I need a structured prompt for analyzing tensorflow/tensorflow'")print()print("🔧 DIRECT Examples:")print(" • 'Show me the issues in huggingface/transformers'")print(" • 'Get repository info for github://repo/google/chrome'")print("-" * 60)while True:try:# Request user inputuser_input = input(" 👤 You: ").strip()if user_input.lower() in ['quit', 'q', 'exit', 'salir']:print("👋 Bye!")breakif not user_input:continueprint(" 🤔 Claude is thinking...")# Process queryresponse = await self.process_query(user_input)# Show responseprint(f" 🤖 Claude: {response}")except KeyboardInterrupt:print(" 👋 Disconnecting...")breakexcept Exception as e:print(f" ❌ Error in chat: {str(e)}")continueasync def cleanup(self):"""Clean up resources and close connections."""print("🧹 Cleaning up resources...")# FastMCP Client cleanup is handled automatically by context managerawait self.exit_stack.aclose()print("✅ Resources released")async def main():"""Main function that initializes and runs the FastMCP client."""# Verify command line argumentsif len(sys.argv) < 2 or len(sys.argv) > 3:print("❌ Usage: python client.py <http_server_url> [auth_token]")print("📝 Example: python client.py http://localhost:8000/mcp")print("📝 Example with auth: python client.py http://localhost:8000/mcp <your_bearer_token>")print("📝 Note: Now connects to HTTP server instead of executing script")sys.exit(1)server_url = sys.argv[1]auth_token = sys.argv[2] if len(sys.argv) == 3 else None# Validate URL formatif not server_url.startswith(('http://', 'https://')):print("❌ Error: Server URL must start with http:// or https://")print("📝 Example: python client.py http://localhost:8000")sys.exit(1)# Create and run clientclient = FastMCPClient()try:# Connect to the serverawait client.connect_to_server(server_url, auth_token)# List available tools, resources, and prompts after connectionawait client.list_available_tools()await client.list_available_resources()await client.list_available_prompts()# Start chat loopawait client.chat_loop()except Exception as e:print(f"❌ Fatal error: {str(e)}")finally:# Ensure resources are cleaned upawait client.cleanup()if __name__ == "__main__":# Entry point of the scriptasyncio.run(main())Copied
Overwriting client_MCP/client.py
Se crea el token de autenticación a partir del token dado por el usuario al iniciar el cliente
# Create authentication if token is provided
auth = None
if auth_token:
auth = BearerAuth(token=auth_token)
print("🔐 Using Bearer token authentication")
else:
print("⚠️ No authentication token provided - connecting without auth")Se crea el cliente con el token de autenticación, que será enviado al servidor
# Create FastMCP client for HTTP connection using SSE transport
self.client = Client(server_url, auth=auth)Se conecta con el servidor enviando el token
# Connect to the server
await client.connect_to_server(server_url, auth_token)Prueba del MCP con autenticación
Como hemos vuelto al http, primero tenemos que levantar el servidor
InputPython!cd gitHub_MCP_server && source .venv/bin/activate && uv run github_server.pyCopied
🔐 Generating RSA key pair for authentication...🎫 Development token generated:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2dpdGh1Yi1tY3AubWF4Zm4uZGV2Iiwic3ViIjoiZGV2LXVzZXItbWF4Zm4iLCJpYXQiOjE3NTExMDgzMDAsImV4cCI6MTc1MTE5NDcwMCwiYXVkIjoiZ2l0aHViLW1jcC1zZXJ2ZXIiLCJzY29wZSI6ImdpdGh1YjpyZWFkIGdpdGh1Yjp3cml0ZSJ9.PX6BtUhNCv9YVq1ZCh2teAU_LsdGMJx-W2jntTvVgdXv3aDyiOeMuZE9fIcqRy9zcXT1pjexqQQDiRhy8WlRL-mdKooEbIc_ffBVX9LPVaxKAzfzZTnx2lYTt6DgnebjjdNk_OsXF3ujH5s0xmGtY892j-k9P8dJLLrTrqXLhWG2NX_jqHB_kMalFd0LT83D6uXjPako_DKHjYKLc67WvZU_JglVS5eI9YCmmhMlhPHyO4FUlD9xb0DpbOgz8bO1ZExBrB_W2YKomGI_u8R56ItM8bS3eEwybPgEHfHhDNI6PNqsJ3DB1Grmc7KOmGX4LJCfPyB6mpl_bQmChKzcdg💡 Use this token in the client to authenticate------------------------------------------------------------/Users/macm1/Documents/web/portafolio/posts/gitHub_MCP_server/github_server.py:412: DeprecationWarning: Mount prefixes are now optional and the first positional argument should be the server you want to mount.mcp.mount("sub_mcp", sub_mcp)DEBUG: Starting FastMCP GitHub server...DEBUG: Server name: GitHubMCP[06/28/25 12:58:20] INFO Starting MCP server 'GitHubMCP' with ]8;id=190590;file:///Users/macm1/Documents/web/portafolio/posts/gitHub_MCP_server/.venv/lib/python3.11/site-packages/fastmcp/server/server.py\server.py]8;;\:]8;id=102439;file:///Users/macm1/Documents/web/portafolio/posts/gitHub_MCP_server/.venv/lib/python3.11/site-packages/fastmcp/server/server.py#1297\1297]8;;\transport 'streamable-http' onhttp://0.0.0.0:8000/mcp/INFO: Started server process [27262]INFO: Waiting for application startup.INFO: Application startup complete.INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
Como vemos nos ha generado el token de autenticación eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2dpdGh1Yi1tY3AubWF4Zm4uZGV2Iiwic3ViIjoiZGV2LXVzZXItbWF4Zm4iLCJpYXQiOjE3NTExMDgzMDAsImV4cCI6MTc1MTE5NDcwMCwiYXVkIjoiZ2l0aHViLW1jcC1zZXJ2ZXIiLCJzY29wZSI6ImdpdGh1YjpyZWFkIGdpdGh1Yjp3cml0ZSJ9.PX6BtUhNCv9YVq1ZCh2teAU_LsdGMJx-W2jntTvVgdXv3aDyiOeMuZE9fIcqRy9zcXT1pjexqQQDiRhy8WlRL-mdKooEbIc_ffBVX9LPVaxKAzfzZTnx2lYTt6DgnebjjdNk_OsXF3ujH5s0xmGtY892j-k9P8dJLLrTrqXLhWG2NX_jqHB_kMalFd0LT83D6uXjPako_DKHjYKLc67WvZU_JglVS5eI9YCmmhMlhPHyO4FUlD9xb0DpbOgz8bO1ZExBrB_W2YKomGI_u8R56ItM8bS3eEwybPgEHfHhDNI6PNqsJ3DB1Grmc7KOmGX4LJCfPyB6mpl_bQmChKzcdg, hay que usarlo a la hora de ejecutar el cliente
Y ahora ejecutamos el cliente con el token de autenticación que nos ha generado el servidor.
InputPython!cd client_MCP && source .venv/bin/activate && uv run client.py http://localhost:8000/mcp eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2dpdGh1Yi1tY3AubWF4Zm4uZGV2Iiwic3ViIjoiZGV2LXVzZXItbWF4Zm4iLCJpYXQiOjE3NTExMDgzMDAsImV4cCI6MTc1MTE5NDcwMCwiYXVkIjoiZ2l0aHViLW1jcC1zZXJ2ZXIiLCJzY29wZSI6ImdpdGh1YjpyZWFkIGdpdGh1Yjp3cml0ZSJ9.PX6BtUhNCv9YVq1ZCh2teAU_LsdGMJx-W2jntTvVgdXv3aDyiOeMuZE9fIcqRy9zcXT1pjexqQQDiRhy8WlRL-mdKooEbIc_ffBVX9LPVaxKAzfzZTnx2lYTt6DgnebjjdNk_OsXF3ujH5s0xmGtY892j-k9P8dJLLrTrqXLhWG2NX_jqHB_kMalFd0LT83D6uXjPako_DKHjYKLc67WvZU_JglVS5eI9YCmmhMlhPHyO4FUlD9xb0DpbOgz8bO1ZExBrB_W2YKomGI_u8R56ItM8bS3eEwybPgEHfHhDNI6PNqsJ3DB1Grmc7KOmGX4LJCfPyB6mpl_bQmChKzcdgCopied
🔗 Connecting to FastMCP HTTP server: http://localhost:8000/mcp🔐 Using Bearer token authentication✅ Client created successfully🛠️ Available tools (2):==================================================📋 sub_mcp_hello_worldDescription: Returns a simple greeting.Parameters:📋 list_repository_issuesDescription: Lists open issues for a given GitHub repository.Args:owner: The owner of the repository (e.g., 'modelcontextprotocol')repo_name: The name of the repository (e.g., 'python-sdk')ctx: The context of the requestuser_id: The user ID (automatically injected by the server)Returns:list[dict]: A list of dictionaries, each containing information about an issueParameters: owner, repo_name📚 Available resources (1):==================================================...📚 The client can use tools, resources, and prompts from the FastMCP server💭 PROMPT Examples:• 'Generate a prompt for asking about issues in facebook/react'• 'Help me create a good question about microsoft/vscode issues'• 'I need a structured prompt for analyzing tensorflow/tensorflow'🔧 DIRECT Examples:• 'Show me the issues in huggingface/transformers'• 'Get repository info for github://repo/google/chrome'------------------------------------------------------------👤 You:
Como vemos, el cliente se conecta con el servidor y nos da una lista de los tools, resources y prompts disponibles.
Ping del cliente al servidor
Cuando ejecutamos el MCP con http como capa de transporte, lo normal es que el cliente y el servidor no estén en el mismo ordenador. Por lo que, cuando ejecutamos el cliente, no podemos saber si el servidor está funcionando, así que podemos desarrollar un ping para comprobar que el servidor está funcionando.
Cliente MCP
Vamos a añadir un ping al cliente MCP
InputPython%%writefile client_MCP/client.pyimport sysimport asynciofrom contextlib import AsyncExitStackfrom anthropic import Anthropicfrom dotenv import load_dotenvfrom fastmcp import Clientfrom fastmcp.client.auth import BearerAuth# Load environment variables from .env fileload_dotenv()class FastMCPClient:"""FastMCP client that integrates with Claude to process user queriesand use tools and resources exposed by a FastMCP server."""def __init__(self):"""Initialize the FastMCP client with Anthropic and resource management."""self.exit_stack = AsyncExitStack()self.anthropic = Anthropic()self.client = Noneasync def connect_to_server(self, server_url: str, auth_token: str = None):"""Connect to the specified FastMCP server via HTTP with optional authentication.Args:server_url: URL of the HTTP server (e.g., "http://localhost:8000/mcp")auth_token: Bearer token for authentication (optional)"""print(f"🔗 Connecting to FastMCP HTTP server: {server_url}")# Create authentication if token is providedauth = Noneif auth_token:auth = BearerAuth(token=auth_token)print("🔐 Using Bearer token authentication")else:print("⚠️ No authentication token provided - connecting without auth")# Create FastMCP client for HTTP connection using SSE transportself.client = Client(server_url, auth=auth)# Note: FastMCP Client automatically detects HTTP URLs and uses SSE transportprint("✅ Client created successfully")# Ping to server to check if it's aliveasync with self.client as client:response = await client.ping()print(f"🏓 Server ping response: {response}")async def list_available_tools(self):"""List available tools in the FastMCP server."""try:# Get list of tools from the server using FastMCP contextasync with self.client as client:tools = await client.list_tools()if tools:print(f" 🛠️ Available tools ({len(tools)}):")print("=" * 50)for tool in tools:print(f"📋 {tool.name}")if tool.description:print(f" Description: {tool.description}")# Show parameters if availableif hasattr(tool, 'inputSchema') and tool.inputSchema:if 'properties' in tool.inputSchema:params = list(tool.inputSchema['properties'].keys())print(f" Parameters: {', '.join(params)}")print()else:print("⚠️ No tools found in the server")except Exception as e:print(f"❌ Error listing tools: {str(e)}")async def list_available_resources(self):"""List available resources in the FastMCP server."""try:# Get list of resources from the server using FastMCP contextasync with self.client as client:resources = await client.list_resources()if resources:print(f" 📚 Available resources ({len(resources)}):")print("=" * 50)for resource in resources:print(f"📄 {resource.uri}")if resource.name:print(f" Name: {resource.name}")if resource.description:print(f" Description: {resource.description}")if resource.mimeType:print(f" MIME Type: {resource.mimeType}")print()else:print("⚠️ No resources found in the server")except Exception as e:print(f"❌ Error listing resources: {str(e)}")async def list_available_prompts(self):"""List available prompts in the FastMCP server."""try:# Get list of prompts from the server using FastMCP contextasync with self.client as client:prompts = await client.list_prompts()if prompts:print(f" 💭 Available prompts ({len(prompts)}):")print("=" * 50)for prompt in prompts:print(f"🎯 {prompt.name}")if prompt.description:print(f" Description: {prompt.description}")# Show parameters if availableif hasattr(prompt, 'arguments') and prompt.arguments:params = []for arg in prompt.arguments:param_info = f"{arg.name}: {arg.description or 'No description'}"if arg.required:param_info += " (required)"params.append(param_info)print(f" Parameters: {', '.join(params)}")print()else:print("⚠️ No prompts found in the server")except Exception as e:print(f"❌ Error listing prompts: {str(e)}")async def read_resource(self, resource_uri: str):"""Read a specific resource from the server.Args:resource_uri: URI of the resource to readReturns:str: Resource content"""try:async with self.client as client:result = await client.read_resource(resource_uri)return resultexcept Exception as e:print(f"❌ Error reading resource {resource_uri}: {str(e)}")return Noneasync def get_prompt(self, prompt_name: str, prompt_args: dict = None):"""Get/call a specific prompt from the server.Args:prompt_name: Name of the prompt to callprompt_args: Arguments for the prompt (if any)Returns:str: Generated prompt content"""try:async with self.client as client:if prompt_args:result = await client.get_prompt(prompt_name, prompt_args)else:result = await client.get_prompt(prompt_name)# Extract the prompt text from the responseif hasattr(result, 'messages') and result.messages:# FastMCP returns prompts as message objectsreturn ' '.join([msg.content.text for msg in result.messages if hasattr(msg.content, 'text')])elif hasattr(result, 'content'):return str(result.content)else:return str(result)except Exception as e:print(f"❌ Error getting prompt {prompt_name}: {str(e)}")return Noneasync def process_query(self, query: str) -> str:"""Process a user query, interacting with Claude and FastMCP tools and resources.Args:query: User queryReturns:str: Final processed response"""try:# Use FastMCP context for all operationsasync with self.client as client:# Get available tools and resourcestools_list = await client.list_tools()resources_list = await client.list_resources()# Prepare tools for Claude in correct formatclaude_tools = []for tool in tools_list:claude_tool = {"name": tool.name,"description": tool.description or f"Tool {tool.name}","input_schema": tool.inputSchema or {"type": "object", "properties": {}}}claude_tools.append(claude_tool)# Add a special tool for reading resources (including template resources)resource_description = "Read a resource from the MCP server. "if resources_list:# Convert URIs to strings to avoid AnyUrl object issuesresource_uris = [str(r.uri) for r in resources_list]resource_description += f"Available static resources: {', '.join(resource_uris)}. "resource_description += "Also supports template resources like github://repo/owner/repo_name for GitHub repository information."claude_tools.append({"name": "read_mcp_resource","description": resource_description,"input_schema": {"type": "object","properties": {"resource_uri": {"type": "string","description": "URI of the resource to read. Can be static (like resource://server_info) or template-based (like github://repo/facebook/react)"}},"required": ["resource_uri"]}})# Add a special tool for using promptsprompt_description = "Generate specialized prompts from the MCP server. Use this when users want to: "prompt_description += "- Create well-structured questions about repositories "prompt_description += "- Get help formulating prompts for specific tasks "prompt_description += "- Generate template questions for analysis "if prompts_list:prompt_names = [p.name for p in prompts_list]prompt_description += f" Available prompts: {', '.join(prompt_names)} "prompt_description += "- generate_issues_prompt: Creates structured questions about GitHub repository issues"prompt_description += " IMPORTANT: Use prompts when users explicitly ask for help creating questions or prompts, or when they want to formulate better questions about repositories."claude_tools.append({"name": "use_mcp_prompt","description": prompt_description,"input_schema": {"type": "object","properties": {"prompt_name": {"type": "string","description": "Name of the prompt to use. Available: 'generate_issues_prompt'"},"prompt_args": {"type": "object","description": "Arguments for the prompt. For generate_issues_prompt: {'owner': 'repo-owner', 'repo_name': 'repo-name'}","properties": {"owner": {"type": "string","description": "Repository owner (e.g., 'huggingface', 'microsoft')"},"repo_name": {"type": "string","description": "Repository name (e.g., 'transformers', 'vscode')"}}}},"required": ["prompt_name"]}})# Create initial message for Claudemessages = [{"role": "user","content": query}]# First call to Clauderesponse = self.anthropic.messages.create(model="claude-3-5-sonnet-20241022",max_tokens=6000,messages=messages,tools=claude_tools if claude_tools else None)# Process Claude's responseresponse_text = ""for content_block in response.content:if content_block.type == "text":response_text += content_block.textelif content_block.type == "tool_use":# Claude wants to use a tooltool_name = content_block.nametool_args = content_block.inputtool_call_id = content_block.idprint(f"🔧 Claude wants to use: {tool_name}")print(f"📝 Arguments: {tool_args}")try:if tool_name == "read_mcp_resource":# Handle resource readingresource_uri = tool_args.get("resource_uri")if resource_uri:tool_result = await client.read_resource(resource_uri)print(f"📖 Resource read successfully: {resource_uri}")# Better handling of resource resultif hasattr(tool_result, 'content'):# If it's a resource response object, extract contentif hasattr(tool_result.content, 'text'):result_content = tool_result.content.textelse:result_content = str(tool_result.content)else:# If it's already a string or simple objectresult_content = str(tool_result)else:tool_result = "Error: No resource URI provided"result_content = tool_resultelif tool_name == "use_mcp_prompt":# Handle prompt usageprompt_name = tool_args.get("prompt_name")prompt_args = tool_args.get("prompt_args", {})if prompt_name:tool_result = await self.get_prompt(prompt_name, prompt_args)print(f"💭 Prompt '{prompt_name}' generated successfully")result_content = str(tool_result) if tool_result else "Error generating prompt"else:tool_result = "Error: No prompt name provided"result_content = tool_resultelse:# Execute regular tool on the FastMCP servertool_result = await client.call_tool(tool_name, tool_args)print(f"✅ Tool executed successfully")result_content = str(tool_result)# Add tool result to the conversationmessages.append({"role": "assistant","content": response.content})# Format result for Claudeif tool_result:messages.append({"role": "user","content": [{"type": "tool_result","tool_use_id": tool_call_id,"content": f"Tool result: {result_content}"}]})else:messages.append({"role": "user","content": [{"type": "tool_result","tool_use_id": tool_call_id,"content": "Tool executed without response content"}]})# Second call to Claude with the tool resultfinal_response = self.anthropic.messages.create(model="claude-3-5-sonnet-20241022",max_tokens=6000,messages=messages,tools=claude_tools if claude_tools else None)# Extract text from the final responsefor final_content in final_response.content:if final_content.type == "text":response_text += final_content.textexcept Exception as e:error_msg = f"❌ Error executing {tool_name}: {str(e)}"print(error_msg)response_text += f" {error_msg}"return response_textexcept Exception as e:error_msg = f"❌ Error processing query: {str(e)}"print(error_msg)return error_msgasync def chat_loop(self):"""Main chat loop with user interaction."""print(" 🤖 FastMCP HTTP client started. Write 'quit', 'q', 'exit', 'salir' to exit.")print("💬 You can ask questions about GitHub repositories!")print("📚 The client can use tools, resources, and prompts from the FastMCP server")print()print("💭 PROMPT Examples:")print(" • 'Generate a prompt for asking about issues in facebook/react'")print(" • 'Help me create a good question about microsoft/vscode issues'")print(" • 'I need a structured prompt for analyzing tensorflow/tensorflow'")print()print("🔧 DIRECT Examples:")print(" • 'Show me the issues in huggingface/transformers'")print(" • 'Get repository info for github://repo/google/chrome'")print("-" * 60)while True:try:# Request user inputuser_input = input(" 👤 You: ").strip()if user_input.lower() in ['quit', 'q', 'exit', 'salir']:print("👋 Bye!")breakif not user_input:continueprint(" 🤔 Claude is thinking...")# Process queryresponse = await self.process_query(user_input)# Show responseprint(f" 🤖 Claude: {response}")except KeyboardInterrupt:print(" 👋 Disconnecting...")breakexcept Exception as e:print(f" ❌ Error in chat: {str(e)}")continueasync def cleanup(self):"""Clean up resources and close connections."""print("🧹 Cleaning up resources...")# FastMCP Client cleanup is handled automatically by context managerawait self.exit_stack.aclose()print("✅ Resources released")async def main():"""Main function that initializes and runs the FastMCP client."""# Verify command line argumentsif len(sys.argv) < 2 or len(sys.argv) > 3:print("❌ Usage: python client.py <http_server_url> [auth_token]")print("📝 Example: python client.py http://localhost:8000/mcp")print("📝 Example with auth: python client.py http://localhost:8000/mcp <your_bearer_token>")print("📝 Note: Now connects to HTTP server instead of executing script")sys.exit(1)server_url = sys.argv[1]auth_token = sys.argv[2] if len(sys.argv) == 3 else None# Validate URL formatif not server_url.startswith(('http://', 'https://')):print("❌ Error: Server URL must start with http:// or https://")print("📝 Example: python client.py http://localhost:8000")sys.exit(1)# Create and run clientclient = FastMCPClient()try:# Connect to the serverawait client.connect_to_server(server_url, auth_token)# List available tools, resources, and prompts after connectionawait client.list_available_tools()await client.list_available_resources()# Start chat loopawait client.chat_loop()except Exception as e:print(f"❌ Fatal error: {str(e)}")finally:# Ensure resources are cleaned upawait client.cleanup()if __name__ == "__main__":# Entry point of the scriptasyncio.run(main())Copied
Overwriting client_MCP/client.py
Añadimos en el método connect_to_server el ping
# Ping to server to check if it's alive
async with self.client as client:
response = await client.ping()
print(f"🏓 Server ping response: {response}")Prueba del ping
Levantamos primero el servidor
InputPython!cd gitHub_MCP_server && source .venv/bin/activate && uv run github_server.pyCopied
🔐 Generating RSA key pair for authentication...🎫 Development token generated:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2dpdGh1Yi1tY3AubWF4Zm4uZGV2Iiwic3ViIjoiZGV2LXVzZXItbWF4Zm4iLCJpYXQiOjE3NTExMDkxMTIsImV4cCI6MTc1MTE5NTUxMiwiYXVkIjoiZ2l0aHViLW1jcC1zZXJ2ZXIiLCJzY29wZSI6ImdpdGh1YjpyZWFkIGdpdGh1Yjp3cml0ZSJ9.N_3QPIHW3BSn1iSSkrcaoelbwA-0D9Z3gelILb8fu1JC2JhCgtnJ0IwNqJrVhAkU0CNcykT36Q3mpCgy0hDhnFKkO9SRGVFgSw71voF5YNOkzzBY14cJERolYy9UDZA6geHxwR0rKyCGYkDH-NAKPuYWC9K7UlGfuOuzh3mp-XQ3Zy4mkyvfhiuwuaJ5_MdR0YtJj6opSRbEsVs1PtFYZETPExx3iBGck2qzLek-LxAJ6mjagPncikWeDwaYShFNPO0Ub3wm2Ok_ak_TChmN3W15MknfBXZrKcIhsNIhCrXJjZkSezp5JX49zoljdK2By9-QH1xmWCQqif_APD-hNQ💡 Use this token in the client to authenticate------------------------------------------------------------/Users/macm1/Documents/web/portafolio/posts/gitHub_MCP_server/github_server.py:412: DeprecationWarning: Mount prefixes are now optional and the first positional argument should be the server you want to mount.mcp.mount("sub_mcp", sub_mcp)DEBUG: Starting FastMCP GitHub server...DEBUG: Server name: GitHubMCP[06/28/25 13:11:52] INFO Starting MCP server 'GitHubMCP' with ]8;id=186381;file:///Users/macm1/Documents/web/portafolio/posts/gitHub_MCP_server/.venv/lib/python3.11/site-packages/fastmcp/server/server.py\server.py]8;;\:]8;id=502881;file:///Users/macm1/Documents/web/portafolio/posts/gitHub_MCP_server/.venv/lib/python3.11/site-packages/fastmcp/server/server.py#1297\1297]8;;\transport 'streamable-http' onhttp://0.0.0.0:8000/mcp/INFO: Started server process [31017]INFO: Waiting for application startup.INFO: Application startup complete.INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
Y ahora ejecutamos el cliente con el token de autenticación
InputPython!cd client_MCP && source .venv/bin/activate && uv run client.py http://localhost:8000/mcp eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2dpdGh1Yi1tY3AubWF4Zm4uZGV2Iiwic3ViIjoiZGV2LXVzZXItbWF4Zm4iLCJpYXQiOjE3NTExMDkxMTIsImV4cCI6MTc1MTE5NTUxMiwiYXVkIjoiZ2l0aHViLW1jcC1zZXJ2ZXIiLCJzY29wZSI6ImdpdGh1YjpyZWFkIGdpdGh1Yjp3cml0ZSJ9.N_3QPIHW3BSn1iSSkrcaoelbwA-0D9Z3gelILb8fu1JC2JhCgtnJ0IwNqJrVhAkU0CNcykT36Q3mpCgy0hDhnFKkO9SRGVFgSw71voF5YNOkzzBY14cJERolYy9UDZA6geHxwR0rKyCGYkDH-NAKPuYWC9K7UlGfuOuzh3mp-XQ3Zy4mkyvfhiuwuaJ5_MdR0YtJj6opSRbEsVs1PtFYZETPExx3iBGck2qzLek-LxAJ6mjagPncikWeDwaYShFNPO0Ub3wm2Ok_ak_TChmN3W15MknfBXZrKcIhsNIhCrXJjZkSezp5JX49zoljdK2By9-QH1xmWCQqif_APD-hNQCopied
🔗 Connecting to FastMCP HTTP server: http://localhost:8000/mcp🔐 Using Bearer token authentication✅ Client created successfully🏓 Server ping response: True🛠️ Available tools (2):==================================================📋 sub_mcp_hello_worldDescription: Returns a simple greeting.Parameters:📋 list_repository_issuesDescription: Lists open issues for a given GitHub repository.Args:owner: The owner of the repository (e.g., 'modelcontextprotocol')repo_name: The name of the repository (e.g., 'python-sdk')ctx: The context of the requestuser_id: The user ID (automatically injected by the server)Returns:list[dict]: A list of dictionaries, each containing information about an issueParameters: owner, repo_name📚 Available resources (1):==================================================📄 resource://server_infoName: server_infoDescription: Returns information about the server.MIME Type: text/plain🤖 FastMCP HTTP client started. Write 'quit', 'q', 'exit', 'salir' to exit.💬 You can ask questions about GitHub repositories!📚 The client can use tools, resources, and prompts from the FastMCP server💭 PROMPT Examples:• 'Generate a prompt for asking about issues in facebook/react'• 'Help me create a good question about microsoft/vscode issues'• 'I need a structured prompt for analyzing tensorflow/tensorflow'🔧 DIRECT Examples:• 'Show me the issues in huggingface/transformers'• 'Get repository info for github://repo/google/chrome'------------------------------------------------------------👤 You: q👋 Bye!🧹 Cleaning up resources...✅ Resources released
Como vemos, el servidor ha respondido al ping
🏓 Server ping response: True---
🎉 **Has completado la guía _MCP con FastMCP_.** Repasa los capítulos: Parte 1 · Parte 2 · Parte 3.