Tipo: Ejercicio semi-independiente
Duración: 20 minutos
Nivel: Intermedio
Objetivo: Implementar herramientas MCP con parámetros dinámicos y validación
Al completar este ejercicio, habrás:
- ✅ Implementado el método
tools/listcon definiciones JSON Schema - ✅ Implementado el método
tools/callpara invocar herramientas - ✅ Creado al menos 3 herramientas con parámetros:
search_customers: Búsqueda de clientes por nombre/paísget_order_details: Detalles de pedido por IDcalculate_metrics: Métricas de negocio (total de ventas, promedio de pedido)
- ✅ Validado parámetros de entrada con JSON Schema
- ✅ Probado las herramientas con diferentes combinaciones de parámetros
Antes de comenzar, verifica que:
- Completaste el Ejercicio 1 exitosamente
- Tienes
Exercise1Serverfuncionando en puerto 5001 - Conoces la estructura de JSON-RPC 2.0
- Entiendes los conceptos de recursos vs herramientas
src/McpWorkshop.Servers/
└── Exercise2Server/
├── Program.cs # Servidor principal con tools
├── Exercise2Server.csproj # Archivo de proyecto
├── Models/
│ ├── Customer.cs # Reutilizado del Ejercicio 1
│ ├── Product.cs # Reutilizado del Ejercicio 1
│ └── Order.cs # Nuevo: modelo de pedido
└── Tools/
├── SearchCustomersTool.cs # Herramienta de búsqueda
├── GetOrderDetailsTool.cs # Herramienta de detalles
└── CalculateMetricsTool.cs # Herramienta de métricas
| Aspecto | Recursos | Herramientas |
|---|---|---|
| Propósito | Exponer datos estáticos o semi-estáticos | Ejecutar operaciones dinámicas |
| Métodos MCP | resources/list, resources/read |
tools/list, tools/call |
| Parámetros | Opcional (solo URI) | Requeridos (definidos en JSON Schema) |
| Ejemplo | mcp://customers (lista completa) |
search_customers(name="John", country="USA") |
| Uso típico | Catálogos, archivos, documentación | Búsquedas, cálculos, acciones |
cd src/McpWorkshop.Servers
dotnet new web -n Exercise2Server -f net10.0
cd Exercise2Server
# Agregar referencias
dotnet add reference ../../McpWorkshop.Shared/McpWorkshop.Shared.csproj
# Agregar a solución
cd ../../..
dotnet sln add src/McpWorkshop.Servers/Exercise2Server/Exercise2Server.csprojcd src/McpWorkshop.Servers/Exercise2Server
mkdir Models
mkdir ToolsCopy-Item ../Exercise1Server/Models/Customer.cs Models/
Copy-Item ../Exercise1Server/Models/Product.cs Models/namespace en los archivos copiados a Exercise2Server.Models. De lo contrario, el código del Paso 2 no compilará.
Crea Models/Order.cs:
namespace Exercise2Server.Models;
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
public decimal TotalAmount { get; set; }
public string Status { get; set; } = string.Empty;
public DateTime OrderDate { get; set; }
}✅ Checkpoint: Tres modelos creados (Customer, Product, Order).
Crea Tools/SearchCustomersTool.cs:
using System.Text.Json;
using Exercise2Server.Models;
namespace Exercise2Server.Tools;
public static class SearchCustomersTool
{
public static object GetDefinition()
{
return new
{
name = "search_customers",
description = "Busca clientes por nombre parcial y/o país",
inputSchema = new
{
type = "object",
properties = new
{
name = new
{
type = "string",
description = "Nombre parcial del cliente (case-insensitive)"
},
country = new
{
type = "string",
description = "País del cliente (exacto)"
}
},
required = new string[] { } // Ambos parámetros son opcionales
}
};
}
public static object Execute(Dictionary<string, JsonElement> arguments, List<Customer> customers)
{
var query = customers.AsEnumerable();
// Filtrar por nombre si se proporciona
if (arguments.TryGetValue("name", out var nameElement))
{
var name = nameElement.GetString();
if (!string.IsNullOrEmpty(name))
{
query = query.Where(c => c.Name.Contains(name, StringComparison.OrdinalIgnoreCase));
}
}
// Filtrar por país si se proporciona
if (arguments.TryGetValue("country", out var countryElement))
{
var country = countryElement.GetString();
if (!string.IsNullOrEmpty(country))
{
query = query.Where(c => c.Country.Equals(country, StringComparison.OrdinalIgnoreCase));
}
}
var results = query.ToList();
return new
{
content = new[]
{
new
{
type = "text",
text = $"Se encontraron {results.Count} cliente(s):\n" +
JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true })
}
}
};
}
}Crea Tools/GetOrderDetailsTool.cs:
using System.Text.Json;
using Exercise2Server.Models;
namespace Exercise2Server.Tools;
public static class GetOrderDetailsTool
{
public static object GetDefinition()
{
return new
{
name = "get_order_details",
description = "Obtiene detalles completos de un pedido incluyendo cliente y producto",
inputSchema = new
{
type = "object",
properties = new
{
orderId = new
{
type = "integer",
description = "ID del pedido a consultar"
}
},
required = new[] { "orderId" }
}
};
}
public static object Execute(
Dictionary<string, JsonElement> arguments,
List<Order> orders,
List<Customer> customers,
List<Product> products)
{
if (!arguments.TryGetValue("orderId", out var orderIdElement))
{
throw new ArgumentException("El parámetro 'orderId' es requerido");
}
var orderId = orderIdElement.GetInt32();
var order = orders.FirstOrDefault(o => o.Id == orderId);
if (order == null)
{
return new
{
content = new[]
{
new
{
type = "text",
text = $"No se encontró el pedido con ID {orderId}"
}
}
};
}
var customer = customers.FirstOrDefault(c => c.Id == order.CustomerId);
var product = products.FirstOrDefault(p => p.Id == order.ProductId);
var details = new
{
order,
customer = customer != null ? new { customer.Id, customer.Name, customer.Email, customer.Country } : null,
product = product != null ? new { product.Id, product.Name, product.Price, product.Category } : null
};
return new
{
content = new[]
{
new
{
type = "text",
text = $"Detalles del pedido #{orderId}:\n" +
JsonSerializer.Serialize(details, new JsonSerializerOptions { WriteIndented = true })
}
}
};
}
}Crea Tools/CalculateMetricsTool.cs:
using System.Text.Json;
using Exercise2Server.Models;
namespace Exercise2Server.Tools;
public static class CalculateMetricsTool
{
public static object GetDefinition()
{
return new
{
name = "calculate_metrics",
description = "Calcula métricas de negocio: total de ventas, promedio de pedido, productos más vendidos",
inputSchema = new
{
type = "object",
properties = new
{
metricType = new
{
type = "string",
@enum = new[] { "sales", "average", "top_products" },
description = "Tipo de métrica: 'sales' (ventas totales), 'average' (promedio de pedido), 'top_products' (productos más vendidos)"
}
},
required = new[] { "metricType" }
}
};
}
public static object Execute(
Dictionary<string, JsonElement> arguments,
List<Order> orders,
List<Product> products)
{
if (!arguments.TryGetValue("metricType", out var metricTypeElement))
{
throw new ArgumentException("El parámetro 'metricType' es requerido");
}
var metricType = metricTypeElement.GetString();
string resultText;
switch (metricType)
{
case "sales":
var totalSales = orders.Sum(o => o.TotalAmount);
resultText = $"Total de ventas: {totalSales:C}";
break;
case "average":
var averageOrder = orders.Any() ? orders.Average(o => o.TotalAmount) : 0;
resultText = $"Promedio de pedido: {averageOrder:C}";
break;
case "top_products":
var topProducts = orders
.GroupBy(o => o.ProductId)
.Select(g => new
{
ProductId = g.Key,
ProductName = products.FirstOrDefault(p => p.Id == g.Key)?.Name ?? "Unknown",
TotalQuantity = g.Sum(o => o.Quantity),
TotalRevenue = g.Sum(o => o.TotalAmount)
})
.OrderByDescending(p => p.TotalRevenue)
.Take(5)
.ToList();
resultText = "Top 5 productos más vendidos:\n" +
JsonSerializer.Serialize(topProducts, new JsonSerializerOptions { WriteIndented = true });
break;
default:
throw new ArgumentException($"Tipo de métrica no válido: {metricType}");
}
return new
{
content = new[]
{
new
{
type = "text",
text = resultText
}
}
};
}
}✅ Checkpoint: Tres herramientas creadas con definiciones JSON Schema.
Reemplaza todo el contenido de Program.cs:
using System.Text.Json;
using Exercise2Server.Models;
using Exercise2Server.Tools;
using McpWorkshop.Shared.Logging;
using McpWorkshop.Shared.Mcp;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IStructuredLogger, StructuredLogger>();
builder.Services.Configure<McpWorkshop.Shared.Configuration.WorkshopSettings>(options =>
{
options.Server.Name = "Exercise2Server";
options.Server.Version = "1.0.0";
options.Server.ProtocolVersion = "2024-11-05";
options.Server.Port = 5002;
});
var app = builder.Build();
// Variables para almacenar los datos cargados durante initialize
List<Customer>? customers = null;
List<Product>? products = null;
List<Order>? orders = null;
// Health check endpoint
app.MapGet("/", (IOptions<McpWorkshop.Shared.Configuration.WorkshopSettings> settings) => Results.Ok(new
{
status = "healthy",
server = settings.Value.Server.Name,
version = settings.Value.Server.Version,
timestamp = DateTime.UtcNow
}));
// Endpoint MCP
app.MapPost("/mcp", async (
JsonRpcRequest request,
IStructuredLogger logger,
IOptions<McpWorkshop.Shared.Configuration.WorkshopSettings> settings) =>
{
var requestId = request.Id?.ToString() ?? "unknown";
IDictionary<string, object>? paramsDict = null;
if (request.Params != null)
{
paramsDict = JsonSerializer.Deserialize<IDictionary<string, object>>(JsonSerializer.Serialize(request.Params));
}
logger.LogRequest(request.Method, requestId, paramsDict);
try
{
var response = request.Method switch
{
"initialize" => HandleInitialize(request.Id, settings, ref customers, ref products, ref orders),
"tools/list" => HandleToolsList(request.Id),
"tools/call" => HandleToolsCall(request.Id, paramsDict, customers, products, orders),
_ => CreateErrorResponse(-32601, "Method not found", null, request.Id)
};
logger.LogResponse(request.Method, requestId, 200, 0);
return Results.Ok(response);
}
catch (Exception ex)
{
logger.LogError(request.Method, requestId, ex);
return Results.Ok(CreateErrorResponse(-32603, "Internal error", ex.Message, request.Id));
}
});
app.Run("http://localhost:5002");
// Handlers
static JsonRpcResponse HandleInitialize(
object? requestId,
IOptions<McpWorkshop.Shared.Configuration.WorkshopSettings> settings,
ref List<Customer>? customers,
ref List<Product>? products,
ref List<Order>? orders)
{
// Cargar datos durante la inicialización
customers = LoadData<Customer>("../../../data/customers.json");
products = LoadData<Product>("../../../data/products.json");
orders = LoadData<Order>("../../../data/orders.json");
return new JsonRpcResponse
{
JsonRpc = "2.0",
Result = new
{
protocolVersion = "2024-11-05",
capabilities = new { tools = new { } },
serverInfo = new
{
name = settings.Value.Server.Name,
version = settings.Value.Server.Version
}
},
Id = requestId
};
}
static JsonRpcResponse HandleToolsList(object? requestId)
{
return new JsonRpcResponse
{
JsonRpc = "2.0",
Result = new
{
tools = new[]
{
SearchCustomersTool.GetDefinition(),
GetOrderDetailsTool.GetDefinition(),
CalculateMetricsTool.GetDefinition()
}
},
Id = requestId
};
}
static JsonRpcResponse HandleToolsCall(
object? requestId,
IDictionary<string, object>? parameters,
List<Customer>? customers,
List<Product>? products,
List<Order>? orders)
{
// Validar que los datos estén inicializados
if (customers == null || products == null || orders == null)
{
throw new InvalidOperationException("Los datos no han sido inicializados. Debe llamar a 'initialize' primero.");
}
// Parsear el nombre de la herramienta
string? toolName = null;
if (parameters != null && parameters.TryGetValue("name", out var nameValue))
{
if (nameValue is JsonElement nameElement)
{
toolName = nameElement.GetString();
}
else if (nameValue is string strValue)
{
toolName = strValue;
}
}
// Parsear los argumentos
Dictionary<string, JsonElement> arguments;
if (parameters != null && parameters.TryGetValue("arguments", out var argsValue))
{
string argumentsJson;
if (argsValue is JsonElement argsElement)
{
argumentsJson = argsElement.GetRawText();
}
else
{
argumentsJson = JsonSerializer.Serialize(argsValue);
}
arguments = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(argumentsJson)
?? new Dictionary<string, JsonElement>();
}
else
{
arguments = new Dictionary<string, JsonElement>();
}
var result = toolName switch
{
"search_customers" => SearchCustomersTool.Execute(arguments, customers),
"get_order_details" => GetOrderDetailsTool.Execute(arguments, orders, customers, products),
"calculate_metrics" => CalculateMetricsTool.Execute(arguments, orders, products),
_ => throw new ArgumentException($"Unknown tool: {toolName}")
};
return new JsonRpcResponse
{
JsonRpc = "2.0",
Result = result,
Id = requestId
};
}
static JsonRpcResponse CreateErrorResponse(int code, string message, object? data, object? id)
{
return new JsonRpcResponse
{
JsonRpc = "2.0",
Error = new JsonRpcError { Code = code, Message = message, Data = data },
Id = id
};
}
static List<T> LoadData<T>(string path)
{
var json = File.ReadAllText(path);
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
return JsonSerializer.Deserialize<List<T>>(json, options) ?? new List<T>();
}✅ Checkpoint: Compilación sin errores (dotnet build).
cd src/McpWorkshop.Servers/Exercise2Server
dotnet runDeberías ver:
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5002
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: C:\code\workshop-mcp\src\McpWorkshop.Servers\Exercise2Server
Invoke-WebRequest -Uri "http://localhost:5002" -Method GETRespuesta esperada: Status 200 con JSON {"status": "healthy", "server": "Exercise2Server", ...}
Terminal 2:
$body = @{
jsonrpc = "2.0"
method = "tools/list"
params = @{}
id = "list-tools"
} | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:5002/mcp" -Method POST -Body $body -ContentType "application/json"Resultado esperado: Array con 3 herramientas (search_customers, get_order_details, calculate_metrics).
✅ PASS
$body = @{
jsonrpc = "2.0"
method = "tools/call"
params = @{
name = "search_customers"
arguments = @{ name = "Carlos" }
}
id = "call-search"
} | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:5002/mcp" -Method POST -Body $body -ContentType "application/json"Resultado esperado: Clientes con "Carlos" en el nombre.
✅ PASS
$body = @{
jsonrpc = "2.0"
method = "tools/call"
params = @{
name = "get_order_details"
arguments = @{ orderId = 1001 }
}
id = "call-order"
} | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:5002/mcp" -Method POST -Body $body -ContentType "application/json"Resultado esperado: Detalles del pedido 1001 con información del cliente y producto.
✅ PASS
$body = @{
jsonrpc = "2.0"
method = "tools/call"
params = @{
name = "calculate_metrics"
arguments = @{ metricType = "sales" }
}
id = "call-metrics"
} | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:5002/mcp" -Method POST -Body $body -ContentType "application/json"Resultado esperado: "Total de ventas: $XX,XXX.XX".
✅ PASS
Has completado el ejercicio exitosamente si:
- El servidor compila sin errores
-
tools/listdevuelve 3 herramientas -
search_customersfiltra correctamente por nombre y/o país -
get_order_detailsdevuelve información combinada de order + customer + product -
calculate_metricscalcula sales, average y top_products - Los parámetros se validan según JSON Schema (prueba con parámetros inválidos)
Causa: No se envió el parámetro requerido.
Solución: Verifica que el objeto arguments incluye el parámetro:
arguments = @{ orderId = 1 } # Debe estar presenteCausa: El nombre de la herramienta no coincide.
Solución: Verifica que el name en tools/call es exacto:
name = "search_customers" # Debe ser exactamente este stringCausa: El ID no existe en orders.json.
Solución: Lista los IDs disponibles primero:
Get-Content data/orders.json | ConvertFrom-Json | Select-Object -ExpandProperty IdUsa un ID válido en la prueba.
Causa: Los modelos no se copiaron correctamente.
Solución: Verifica que existen los 3 archivos en Models/:
Get-ChildItem src/McpWorkshop.Servers/Exercise2Server/Models/
# Debe mostrar: Customer.cs, Product.cs, Order.csCrea FilterOrdersTool.cs que filtre pedidos por:
- Rango de fechas (
startDate,endDate) - Status (
pending,completed,cancelled) - Monto mínimo (
minAmount)
Agrega validación explícita en las herramientas:
if (arguments.TryGetValue("orderId", out var orderIdElement))
{
if (!orderIdElement.TryGetInt32(out var orderId) || orderId <= 0)
{
throw new ArgumentException("orderId debe ser un entero positivo");
}
}Agrega parámetros page y pageSize a search_customers:
var page = arguments.TryGetValue("page", out var pageElem) ? pageElem.GetInt32() : 1;
var pageSize = arguments.TryGetValue("pageSize", out var sizeElem) ? sizeElem.GetInt32() : 10;
var paginatedResults = query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();inputSchema: Define la estructura de parámetrosproperties: Nombre y tipo de cada parámetrorequired: Array de parámetros obligatoriosenum: Valores permitidos para un parámetro
JsonElement: Tipo dinámico para parámetros desconocidosGetString(),GetInt32(): Conversión de tiposTryGetValue(): Verificación segura de existencia de parámetros
- Recursos: Datos pasivos, expuestos vía URIs
- Herramientas: Operaciones activas, invocadas con parámetros
- Validación: JSON Schema asegura que los parámetros son correctos
- Cada invocación de herramienta se registra con
LogRequest/LogResponse - Útil para auditoría y debugging
Ejercicio 3: Seguridad y Autenticación (20 min)
En el siguiente ejercicio aprenderás a:
- Implementar autenticación con JWT
- Configurar autorización basada en scopes
- Aplicar rate limiting por usuario
- Registrar eventos de seguridad con logging estructurado
- Documentación MCP - Tools: https://modelcontextprotocol.io/specification/2025-06-18
- JSON Schema: https://json-schema.org/understanding-json-schema/
Preparado por: Instructor del taller MCP
Versión: 1.0.0
Última actualización: Febrero 2026