Módulo 3: Tres Primitivas — Resources, Tools, Prompts

Combinando Primitivas: Cómo Trabajan Juntas en un Server Real

Combinando Primitivas: Cómo Trabajan Juntas en un Server Real

Descripción de la cápsula

Ya conoces las 3 primitivas por separado: Resources para leer datos, Tools para ejecutar acciones, y Prompts para estandarizar interacciones. Pero en un MCP server real, las primitivas no operan en silos — trabajan juntas como un sistema integrado.

Un resource expone los datos de un proyecto. Un tool modifica esos datos. Un prompt guía al usuario para hacer la modificación correcta. Las tres primitivas se complementan, y la clave de un buen MCP server es diseñar cómo interactúan.

En esta cápsula vas a ver patrones reales de combinación, vas a diseñar un server coherente donde las primitivas se refuerzan mutuamente, y vas a entender las decisiones de diseño que separan un MCP server amateur de uno profesional.


El principio de cohesión

Las primitivas como capas

En un MCP server bien diseñado, las primitivas forman capas complementarias:

┌─────────────────────────────────────────────┐
│  Prompts (capa de interacción)              │
│  "Cómo el usuario pide las cosas"           │
│  ├── code-review                            │
│  ├── refactoring-plan                       │
│  └── bug-report                             │
├─────────────────────────────────────────────┤
│  Tools (capa de acción)                     │
│  "Qué acciones puede ejecutar el modelo"    │
│  ├── create_file                            │
│  ├── update_record                          │
│  └── deploy_app                             │
├─────────────────────────────────────────────┤
│  Resources (capa de datos)                  │
│  "Qué datos puede ver el modelo"            │
│  ├── file:///project/structure              │
│  ├── db://users/list                        │
│  └── config://app/settings                  │
└─────────────────────────────────────────────┘

El flujo natural: Los resources dan contexto → los tools actúan sobre ese contexto → los prompts estandarizan cómo se pide.

Ejemplo concreto: MCP Server para gestión de tareas

Imagina un MCP server que conecta con un sistema de tareas:

Resources:
├── tasks://all              → Lista todas las tareas
├── tasks://status/{status}  → Tareas filtradas por estado
└── tasks://stats            → Estadísticas (completadas, pendientes)

Tools:
├── create_task     → Crea una nueva tarea
├── update_task     → Actualiza una tarea existente
├── complete_task   → Marca una tarea como completada
└── delete_task     → Elimina una tarea

Prompts:
├── daily-standup   → "¿Qué hice ayer, qué haré hoy, qué me bloquea?"
├── sprint-review   → Resumen del sprint con tareas completadas
└── task-breakdown  → Descompone una tarea grande en subtareas

¿Ves cómo se complementan?

  1. El usuario usa el prompt daily-standup
  2. El prompt internamente necesita datos → usa el resource tasks://status/in-progress
  3. El modelo genera el standup y sugiere completar tareas terminadas → usa el tool complete_task

Patrones de combinación

Patrón 1: Read-Act-Report

El patrón más común. El modelo lee datos, actúa sobre ellos, y reporta el resultado.

Resource (leer) → Tool (actuar) → Resource (verificar)

Ejemplo:
1. Resource "db://orders/pending" → Lee pedidos pendientes
2. Tool "process_order" → Procesa un pedido
3. Resource "db://orders/123" → Verifica que el pedido se procesó

Implementación TypeScript:

const server = new McpServer({ name: "order-manager", version: "1.0.0" });

// 1. Resource: leer pedidos pendientes
server.resource(
  "pending-orders",
  "db://orders/pending",
  { description: "Lista de pedidos pendientes", mimeType: "application/json" },
  async (uri) => {
    const orders = await db.query("SELECT * FROM orders WHERE status = 'pending'");
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(orders.rows, null, 2),
      }],
    };
  }
);

// 2. Tool: procesar un pedido
server.tool(
  "process_order",
  "Procesa un pedido pendiente cambiando su estado a 'processing'",
  {
    orderId: z.string().describe("ID del pedido a procesar"),
    notes: z.string().optional().describe("Notas del procesamiento"),
  },
  async ({ orderId, notes }) => {
    const result = await db.query(
      "UPDATE orders SET status = 'processing', notes = $1, processed_at = NOW() WHERE id = $2 RETURNING *",
      [notes || "", orderId]
    );

    if (result.rows.length === 0) {
      return {
        content: [{ type: "text", text: `Pedido ${orderId} no encontrado` }],
        isError: true,
      };
    }

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          message: `Pedido ${orderId} procesado exitosamente`,
          order: result.rows[0],
        }, null, 2),
      }],
    };
  }
);

// 3. Resource template: verificar un pedido
server.resource(
  "order-detail",
  new ResourceTemplate("db://orders/{orderId}", { list: undefined }),
  { description: "Detalle de un pedido por ID", mimeType: "application/json" },
  async (uri, params) => {
    const order = await db.query("SELECT * FROM orders WHERE id = $1", [params.orderId]);
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(order.rows[0] || { error: "No encontrado" }, null, 2),
      }],
    };
  }
);

Patrón 2: Prompt-Driven Workflow

El prompt guía un flujo completo que usa resources y tools.

Prompt (guiar) → Resource (contexto) → Tool (acción)

Ejemplo:
1. Prompt "daily-standup" → Genera instrucciones para el standup
2. Resource "tasks://status/in-progress" → Proporciona tareas actuales como contexto
3. Tool "complete_task" → Completa tareas que el usuario confirma terminadas

Implementación TypeScript:

// Prompt que orquesta resources como contexto
server.prompt(
  "daily-standup",
  "Genera un reporte de standup diario basado en las tareas actuales",
  {
    teamMember: z.string().describe("Nombre del miembro del equipo"),
  },
  async ({ teamMember }) => {
    const inProgress = await db.query(
      "SELECT * FROM tasks WHERE assignee = $1 AND status IN ('in-progress', 'completed-today')",
      [teamMember]
    );

    const blockers = await db.query(
      "SELECT * FROM tasks WHERE assignee = $1 AND blocked = true",
      [teamMember]
    );

    return {
      messages: [
        {
          role: "user" as const,
          content: {
            type: "text" as const,
            text: `Genera un reporte de standup para ${teamMember}.

**Tareas en progreso:**
${JSON.stringify(inProgress.rows, null, 2)}

**Bloqueadores:**
${JSON.stringify(blockers.rows, null, 2)}

**Formato del standup:**
1. ✅ **Ayer completé:** [tareas con status 'completed-today']
2. 🔄 **Hoy trabajaré en:** [tareas con status 'in-progress']
3. 🚫 **Bloqueadores:** [tareas con blocked = true]
4. 📝 **Notas:** [observaciones relevantes]

Si hay tareas completadas, sugiere marcarlas como done usando el tool complete_task.`,
          },
        },
      ],
    };
  }
);

Patrón 3: Resource-Enriched Tools

Los tools usan resources internamente para enriquecer sus operaciones.

Tool (ejecutar) → Resource interno (contexto) → Tool (resultado enriquecido)

Ejemplo:
1. Tool "smart_create_file" → Antes de crear, consulta la estructura del proyecto
2. Resource interno → Lee el .editorconfig, .prettierrc, tsconfig
3. Tool → Crea el archivo con el estilo correcto del proyecto

El tool smart_create_file internamente lee .editorconfig y .prettierrc (como si fueran resources) para aplicar las convenciones correctas antes de escribir el archivo. El usuario no sabe que el tool consulta datos — solo ve que el archivo se crea con el estilo correcto.


Decisiones de diseño

¿Cuántas primitivas necesita tu server?

No todo MCP server necesita las 3 primitivas. La decisión depende del caso de uso:

Server de solo lectura (e.g., métricas dashboard):
  ✅ Resources: exponer métricas, logs, estado
  ❌ Tools: no hay acciones que ejecutar
  ⚠️ Prompts: opcional, para estandarizar consultas

Server de acciones (e.g., deployment tool):
  ⚠️ Resources: estado del deployment, logs
  ✅ Tools: deploy, rollback, scale
  ⚠️ Prompts: template de deployment checklist

Server completo (e.g., project management):
  ✅ Resources: tareas, sprints, métricas
  ✅ Tools: CRUD de tareas, asignar, completar
  ✅ Prompts: standup, sprint review, planning

Naming conventions

Mantén consistencia en los nombres:

// Resources: sustantivos, con esquema descriptivo
"db://users/list"
"db://users/{id}"
"config://app/settings"
"metrics://api/latency"

// Tools: verbos en snake_case
"create_user"
"update_task"
"deploy_application"
"search_files"

// Prompts: nombres descriptivos en kebab-case
"code-review"
"daily-standup"
"bug-report"
"feature-spec"

Granularidad: ¿un tool grande o varios pequeños?

// ❌ Un tool que hace todo
server.tool("manage_user", "Crea, actualiza, o elimina usuarios", {
  action: z.enum(["create", "update", "delete"]),
  // ... muchos parámetros condicionales
});

// ✅ Un tool por acción
server.tool("create_user", "Crea un nuevo usuario", { ... });
server.tool("update_user", "Actualiza un usuario existente", { ... });
server.tool("delete_user", "Elimina un usuario por ID", { ... });

Regla: Un tool por acción. El modelo elige mejor entre tools específicos que entre acciones dentro de un tool genérico.


Ejemplo completo: MCP Server de notas

Veamos un server completo que combina las 3 primitivas de forma cohesiva:

TypeScript

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

interface Note {
  id: string;
  title: string;
  content: string;
  tags: string[];
  createdAt: string;
  updatedAt: string;
}

const notes: Map<string, Note> = new Map();
let nextId = 1;

const server = new McpServer({
  name: "notes-server",
  version: "1.0.0",
});

// === RESOURCES ===

server.resource(
  "all-notes",
  "notes://all",
  { description: "Lista de todas las notas", mimeType: "application/json" },
  async (uri) => ({
    contents: [{
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify(Array.from(notes.values()), null, 2),
    }],
  })
);

server.resource(
  "note-by-id",
  new ResourceTemplate("notes://note/{noteId}", {
    list: async () =>
      Array.from(notes.values()).map((n) => ({
        uri: `notes://note/${n.id}`,
        name: n.title,
        description: `Nota: ${n.title}`,
      })),
  }),
  { description: "Una nota específica por ID", mimeType: "application/json" },
  async (uri, params) => {
    const note = notes.get(params.noteId as string);
    if (!note) throw new Error(`Nota ${params.noteId} no encontrada`);
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(note, null, 2),
      }],
    };
  }
);

server.resource(
  "notes-stats",
  "notes://stats",
  { description: "Estadísticas de las notas", mimeType: "application/json" },
  async (uri) => {
    const allNotes = Array.from(notes.values());
    const tagCount: Record<string, number> = {};
    allNotes.forEach((n) => n.tags.forEach((t) => (tagCount[t] = (tagCount[t] || 0) + 1)));

    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({
          totalNotes: allNotes.length,
          tags: tagCount,
          lastUpdated: allNotes.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]?.updatedAt || null,
        }, null, 2),
      }],
    };
  }
);

// === TOOLS ===

server.tool(
  "create_note",
  "Crea una nueva nota con título, contenido y tags",
  {
    title: z.string().min(1).describe("Título de la nota"),
    content: z.string().describe("Contenido de la nota"),
    tags: z.array(z.string()).default([]).describe("Tags de la nota"),
  },
  async ({ title, content, tags }) => {
    const id = String(nextId++);
    const now = new Date().toISOString();
    const note: Note = { id, title, content, tags, createdAt: now, updatedAt: now };
    notes.set(id, note);

    return {
      content: [{
        type: "text",
        text: JSON.stringify({ message: `Nota "${title}" creada con ID ${id}`, note }, null, 2),
      }],
    };
  }
);

server.tool(
  "update_note",
  "Actualiza el contenido o tags de una nota existente",
  {
    noteId: z.string().describe("ID de la nota a actualizar"),
    title: z.string().optional().describe("Nuevo título"),
    content: z.string().optional().describe("Nuevo contenido"),
    tags: z.array(z.string()).optional().describe("Nuevos tags"),
  },
  async ({ noteId, title, content, tags }) => {
    const note = notes.get(noteId);
    if (!note) {
      return { content: [{ type: "text", text: `Nota ${noteId} no encontrada` }], isError: true };
    }

    if (title) note.title = title;
    if (content) note.content = content;
    if (tags) note.tags = tags;
    note.updatedAt = new Date().toISOString();

    return {
      content: [{ type: "text", text: JSON.stringify({ message: "Nota actualizada", note }, null, 2) }],
    };
  }
);

server.tool(
  "delete_note",
  "Elimina una nota por su ID",
  { noteId: z.string().describe("ID de la nota a eliminar") },
  async ({ noteId }) => {
    const note = notes.get(noteId);
    if (!note) {
      return { content: [{ type: "text", text: `Nota ${noteId} no encontrada` }], isError: true };
    }
    notes.delete(noteId);
    return {
      content: [{ type: "text", text: `Nota "${note.title}" (ID: ${noteId}) eliminada` }],
    };
  }
);

// === PROMPTS ===

server.prompt(
  "organize-notes",
  "Analiza y sugiere organización para las notas existentes",
  {},
  async () => {
    const allNotes = Array.from(notes.values());
    return {
      messages: [{
        role: "user" as const,
        content: {
          type: "text" as const,
          text: `Analiza estas ${allNotes.length} notas y sugiere cómo organizarlas mejor:

${JSON.stringify(allNotes, null, 2)}

**Sugiere:**
1. Tags que faltan o podrían consolidarse
2. Notas que podrían fusionarse
3. Notas que deberían dividirse
4. Una estructura de categorías sugerida`,
        },
      }],
    };
  }
);

server.prompt(
  "summarize-notes",
  "Genera un resumen ejecutivo de todas las notas",
  {
    format: z.enum(["bullet-points", "paragraph", "table"]).default("bullet-points").describe("Formato del resumen"),
  },
  async ({ format }) => {
    const allNotes = Array.from(notes.values());
    return {
      messages: [{
        role: "user" as const,
        content: {
          type: "text" as const,
          text: `Genera un resumen ejecutivo de estas notas en formato ${format}:

${JSON.stringify(allNotes, null, 2)}

El resumen debe capturar los puntos clave de cada nota en 1-2 líneas.`,
        },
      }],
    };
  }
);

El equivalente en Python usa decoradores (@server.resource, @server.tool, @server.prompt) pero sigue exactamente el mismo patrón de cohesión: resources para leer, tools para actuar, prompts para guiar.


Anti-patrones: qué evitar

Anti-patrón 1: Primitivas duplicadas

// ❌ Resource Y tool que hacen lo mismo
server.resource("users", "db://users/all", {}, async () => { ... });
server.tool("list_users", "Lista todos los usuarios", {}, async () => { ... });

// ✅ Resource para lectura, tool solo si necesita parámetros complejos
server.resource("users", "db://users/all", {}, async () => { ... });
server.tool("search_users", "Busca usuarios con filtros avanzados", {
  query: z.string(),
  role: z.enum([...]),
  sortBy: z.string(),
}, async (args) => { ... });

Anti-patrón 2: Prompts que deberían ser tools

// ❌ Prompt que ejecuta una acción
server.prompt("deploy", "Despliega la aplicación", {}, async () => {
  await deployApp(); // ← side effect en un prompt
  return { messages: [...] };
});

// ✅ Prompt que guía, tool que ejecuta
server.prompt("deploy-checklist", "Checklist pre-deployment", {}, async () => ({
  messages: [{ role: "user", content: { type: "text", text: "Verifica estos items antes de deploy..." } }],
}));
server.tool("deploy_app", "Despliega la aplicación", { ... }, async () => { ... });

Anti-patrón 3: Server sin cohesión

// ❌ Primitivas inconexas
server.resource("weather", ...);     // Clima
server.tool("create_user", ...);     // Usuarios
server.prompt("sql-query", ...);     // SQL

// ✅ Primitivas cohesivas
server.resource("db://users/all", ...);     // Datos de usuarios
server.tool("create_user", ...);            // Gestión de usuarios
server.prompt("user-report", ...);          // Reportes de usuarios

Troubleshooting

"El modelo no combina primitivas automáticamente"

Causa: El modelo no sabe que las primitivas están relacionadas.

Solución: Usa descripciones que referencien otras primitivas:

server.tool(
  "update_task",
  "Actualiza una tarea. Usa el resource tasks://all para ver las tareas disponibles antes de actualizar.",
  { ... }
);

"El prompt no tiene acceso a los datos actuales"

Causa: El prompt genera texto estático sin consultar datos.

Solución: Lee los datos dentro del handler del prompt:

server.prompt("report", "Reporte de estado", {}, async () => {
  const data = await getCurrentData(); // ← lee datos dinámicamente
  return { messages: [{ role: "user", content: { type: "text", text: `Datos: ${JSON.stringify(data)}` } }] };
});

"Demasiados tools confunden al modelo"

Causa: El server expone muchos tools y el modelo no sabe cuál elegir.

Solución:

  • Limita a 10-15 tools por server
  • Usa nombres y descripciones muy específicas
  • Agrupa funcionalidad en servers separados si es necesario

Ejercicios

Ejercicio 1: Diseñar primitivas para un caso de uso (Fácil)

Diseña las primitivas (resources, tools, prompts) para un MCP server de gestión de bookmarks. Lista al menos 2 de cada tipo.

Ver solución
Resources:
├── bookmarks://all           → Lista todos los bookmarks
├── bookmarks://tag/{tag}     → Bookmarks filtrados por tag
├── bookmarks://stats         → Estadísticas (total, por tag, más visitados)

Tools:
├── add_bookmark     → Agrega un nuevo bookmark (url, título, tags)
├── delete_bookmark  → Elimina un bookmark por ID
├── tag_bookmark     → Agrega/remueve tags de un bookmark
├── check_links      → Verifica qué bookmarks tienen links rotos

Prompts:
├── weekly-reading    → "Sugiere 5 bookmarks para leer esta semana basado en mis tags"
├── organize-bookmarks → "Analiza mis bookmarks y sugiere mejor organización de tags"
├── find-related      → "Dado un tema, encuentra bookmarks relacionados"

Ejercicio 2: Identificar el patrón de combinación (Medio)

Para cada escenario, identifica qué patrón de combinación (Read-Act-Report, Prompt-Driven Workflow, Resource-Enriched Tools) aplica y por qué:

  1. El usuario pide un code review, el modelo lee el archivo, genera feedback, y opcionalmente aplica fixes
  2. El usuario ejecuta un deployment tool que lee la config antes de desplegar
  3. El modelo lista archivos modificados, los compara con la versión anterior, y genera un changelog
Ver solución
  1. Prompt-Driven Workflow

    • Prompt: template de code review con criterios
    • Resource: contenido del archivo a revisar
    • Tool: aplicar fixes sugeridos
    • El flujo empieza con el prompt que guía todo
  2. Resource-Enriched Tools

    • Tool: deploy_application
    • Resource interno: config://deployment/settings
    • El tool consulta recursos internamente para enriquecer su ejecución
  3. Read-Act-Report

    • Resource: lista de archivos modificados + versiones anteriores
    • Tool: generar changelog (o podría ser un prompt si es solo texto)
    • Resource: verificar que el changelog se generó correctamente

Ejercicio 3: Implementar un flujo combinado (Medio)

Implementa en TypeScript un mini-server con 1 resource, 1 tool y 1 prompt que trabajan juntos para gestionar una lista de compras:

Ver solución
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "shopping-list", version: "1.0.0" });

interface Item { id: number; name: string; quantity: number; bought: boolean; }
const items: Item[] = [];
let nextId = 1;

// Resource: ver la lista actual
server.resource("shopping-list", "shopping://list", {
  description: "Lista de compras actual",
  mimeType: "application/json",
}, async (uri) => ({
  contents: [{
    uri: uri.href, mimeType: "application/json",
    text: JSON.stringify({ total: items.length, pending: items.filter(i => !i.bought).length, items }, null, 2),
  }],
}));

// Tool: agregar item
server.tool("add_item", "Agrega un item a la lista de compras", {
  name: z.string().describe("Nombre del producto"),
  quantity: z.number().default(1).describe("Cantidad"),
}, async ({ name, quantity }) => {
  const item: Item = { id: nextId++, name, quantity, bought: false };
  items.push(item);
  return { content: [{ type: "text", text: `"${name}" (x${quantity}) agregado a la lista` }] };
});

// Prompt: sugerir menú semanal basado en la lista
server.prompt("weekly-menu", "Sugiere un menú semanal basado en los items de la lista", {}, async () => {
  const pending = items.filter(i => !i.bought);
  return {
    messages: [{
      role: "user" as const,
      content: {
        type: "text" as const,
        text: `Tengo estos items en mi lista de compras:\n${JSON.stringify(pending, null, 2)}\n\nSugiere un menú semanal (lunes a viernes) usando estos ingredientes. Si falta algo esencial, recomienda agregarlo usando el tool add_item.`,
      },
    }],
  };
});

Ejercicio 4: Refactorizar primitivas inconexas (Difícil)

El siguiente server tiene primitivas inconexas. Refactorízalo para que sean cohesivas:

server.resource("weather", "api://weather/current", {}, handler);
server.tool("create_user", "Crea un usuario", schema, handler);
server.tool("get_forecast", "Obtiene pronóstico", schema, handler);
server.prompt("user-welcome", "Mensaje de bienvenida", {}, handler);
server.resource("db://users/count", {}, handler);
Ver solución

Separar en 2 servers cohesivos:

// Server 1: Weather Server
const weatherServer = new McpServer({ name: "weather-server", version: "1.0.0" });

weatherServer.resource("current-weather", "weather://current", {
  description: "Clima actual"
}, handler);

weatherServer.resource("forecast", "weather://forecast/{days}", {
  description: "Pronóstico para N días"
}, handler);

weatherServer.prompt("weather-report", "Reporte del clima para planificar la semana", {}, handler);

// Server 2: Users Server
const usersServer = new McpServer({ name: "users-server", version: "1.0.0" });

usersServer.resource("users-count", "db://users/count", {
  description: "Cantidad de usuarios registrados"
}, handler);

usersServer.resource("users-list", "db://users/all", {
  description: "Lista de usuarios"
}, handler);

usersServer.tool("create_user", "Crea un nuevo usuario", schema, handler);

usersServer.prompt("user-welcome", "Genera mensaje de bienvenida personalizado", {}, handler);

Cada server ahora tiene primitivas que se refuerzan mutuamente dentro de un dominio coherente.


Resumen

En esta cápsula aprendiste:

  • Las 3 primitivas trabajan juntas como un sistema — no en aislamiento
  • Read-Act-Report: Resource lee datos → Tool actúa → Resource verifica
  • Prompt-Driven Workflow: Prompt guía → Resource da contexto → Tool ejecuta
  • Resource-Enriched Tools: Tool consulta datos internamente antes de actuar
  • Un buen MCP server tiene primitivas cohesivas — todas relacionadas al mismo dominio
  • Naming conventions y granularidad son decisiones de diseño importantes
  • Evita anti-patrones: primitivas duplicadas, prompts con side effects, servers sin cohesión

Próxima cápsula: Mini-proyecto — vas a construir tu primer MCP server completo con 1 resource, 1 tool, y 1 prompt funcionando juntos.


Recursos adicionales

  1. MCP Specification - Cómo las primitivas se definen en el protocolo
  2. MCP TypeScript SDK Examples - Ejemplos oficiales de servers con múltiples primitivas
  3. MCP Servers Repository - Servers oficiales como referencia de diseño
  4. Awesome MCP Servers - Servers de la comunidad con diferentes combinaciones
  5. MCP Inspector - Inspecciona todas las primitivas de un server
  6. Domain-Driven Design Basics - Principios de cohesión aplicables al diseño de servers