Ir al contenido

Implementar la API del Juego y el servidor MCP de Inventario

Implementaremos las siguientes APIs en esta sección:

  1. saveGame - crear o actualizar un juego.
  2. queryGames - devolver una lista paginada de juegos guardados anteriormente.
  3. queryInventory - devolver una lista paginada de ítems en el inventario de un jugador.
  4. queryActions - devolver el historial de conversación para un juego dado.

Para definir las entradas y salidas de nuestra API, creemos nuestro esquema usando Zod en el archivo packages/game-api/src/schema/index.ts de la siguiente manera:

import { z } from 'zod';
export const QueryInputSchema = z.object({
cursor: z.string().optional(),
limit: z.number().optional().default(100),
});
export type IQueryInput = z.TypeOf<typeof QueryInputSchema>;
export const ActionSchema = z.object({
role: z.enum(['user', 'assistant']),
content: z.string(),
messageId: z.number(),
});
export type IAction = z.TypeOf<typeof ActionSchema>;
export const GameSchema = z.object({
playerName: z.string(),
genre: z.enum(['zombie', 'superhero', 'medieval']),
lastUpdated: z.iso.datetime(),
});
export type IGame = z.TypeOf<typeof GameSchema>;
export const ItemSchema = z.object({
playerName: z.string(),
itemName: z.string(),
emoji: z.string().optional(),
lastUpdated: z.iso.datetime(),
quantity: z.number(),
});
export type IItem = z.TypeOf<typeof ItemSchema>;
export const createPaginatedQueryOutput = <ItemType extends z.ZodTypeAny>(
itemSchema: ItemType,
) => {
return z.object({
items: z.array(itemSchema),
cursor: z.string().nullable(),
});
};

Elimina el archivo packages/game-api/src/schema/echo.ts ya que no lo usaremos en este proyecto.

Este es el diagrama ER para nuestra aplicación.

Diagram

El generador ts#dynamodb configuró ElectroDB, que usaremos para modelar nuestros datos. Persistiremos el historial de conversación en S3, por lo que agregamos una dependencia en el cliente S3:

Terminal window
pnpm add -w @aws-sdk/client-s3@3.1075.0

Reemplaza la entidad de ejemplo generada en packages/dungeon-db/src/entities/index.ts con nuestras entidades Game e Inventory, y elimina packages/dungeon-db/src/entities/example.ts:

import { Entity } from 'electrodb';
import { getDynamoDBClient, resolveTableName } from '../client.js';
export const createGameEntity = async () =>
new Entity(
{
model: {
entity: 'Game',
version: '1',
service: 'game',
},
attributes: {
playerName: { type: 'string', required: true, readOnly: true },
genre: { type: 'string', required: true, readOnly: true },
lastUpdated: {
type: 'string',
required: true,
default: () => new Date().toISOString(),
},
},
indexes: {
primary: {
pk: { field: 'pk', composite: ['playerName'] },
sk: { field: 'sk', composite: [] },
},
},
},
{ client: getDynamoDBClient(), table: await resolveTableName() },
);
export const createInventoryEntity = async () =>
new Entity(
{
model: {
entity: 'Inventory',
version: '1',
service: 'game',
},
attributes: {
playerName: { type: 'string', required: true, readOnly: true },
lastUpdated: {
type: 'string',
required: true,
default: () => new Date().toISOString(),
},
itemName: {
type: 'string',
required: true,
},
emoji: {
type: 'string',
required: false,
},
quantity: {
type: 'number',
required: true,
},
},
indexes: {
primary: {
pk: { field: 'pk', composite: ['playerName'] },
sk: { field: 'sk', composite: ['itemName'] },
},
},
},
{ client: getDynamoDBClient(), table: await resolveTableName() },
);

ElectroDB nos permite no solo definir nuestros tipos, sino también proporcionar valores predeterminados para ciertos campos como timestamps. Además, ElectroDB sigue el diseño de tabla única, que es la mejor práctica al usar DynamoDB.

Para implementar los métodos de la API, realiza los siguientes cambios en packages/game-api/src/procedures:

import { createGameEntity } from ':dungeon-adventure/dungeon-db';
import {
GameSchema,
IGame,
QueryInputSchema,
createPaginatedQueryOutput,
} from '../schema/index.js';
import { publicProcedure } from '../init.js';
export const queryGames = publicProcedure
.input(QueryInputSchema)
.output(createPaginatedQueryOutput(GameSchema))
.query(async ({ input }) => {
const gameEntity = await createGameEntity();
const result = await gameEntity.scan.go({
cursor: input.cursor,
count: input.limit,
});
return {
items: result.data as IGame[],
cursor: result.cursor,
};
});
export const saveGame = publicProcedure
.input(GameSchema.omit({ lastUpdated: true }))
.output(GameSchema)
.mutation(async ({ input }) => {
const gameEntity = await createGameEntity();
const result = await gameEntity.put(input).go();
return result.data as IGame;
});

Elimina el archivo echo.ts (de packages/game-api/src/procedures) ya que no lo usaremos en este proyecto.

Después de definir nuestros procedimientos, para conectarlos a nuestra API, actualiza el siguiente archivo:

import { t } from './init.js';
import { queryActions } from './procedures/actions.js';
import { queryGames, saveGame } from './procedures/games.js';
import { queryInventory } from './procedures/inventory.js';
export const router = t.router;
export const appRouter = router({
actions: router({
query: queryActions,
}),
games: router({
query: queryGames,
save: saveGame,
}),
inventory: router({
query: queryInventory,
}),
});
export type AppRouter = typeof appRouter;

Tarea 2: Crear un servidor MCP de inventario

Sección titulada «Tarea 2: Crear un servidor MCP de inventario»

Creemos un servidor MCP que permita a nuestro agente gestionar los ítems en el inventario de un jugador.

Definiremos las siguientes herramientas para nuestro agente:

  • list-inventory-items para obtener los ítems actuales del inventario del jugador
  • add-to-inventory para añadir ítems al inventario del jugador
  • remove-from-inventory para eliminar ítems del inventario del jugador

Para ahorrar tiempo, definiremos todas las herramientas inline:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import z from 'zod';
import { createInventoryEntity } from ':dungeon-adventure/dungeon-db';
/**
* Create the MCP Server
*/
export const createServer = async () => {
const server = new McpServer({
name: 'inventory-mcp-server',
version: '1.0.0',
});
server.registerTool(
'list-inventory-items',
{
description: "List items in the player's inventory. Leave cursor blank unless you are requesting subsequent pages",
inputSchema: {
playerName: z.string(),
cursor: z.string().optional(),
},
},
async ({ playerName }) => {
const inventory = await createInventoryEntity();
const results = await inventory.query
.primary({
playerName,
})
.go();
return {
content: [{ type: 'text' as const, text: JSON.stringify(results) }],
};
},
);
server.registerTool(
'add-to-inventory',
{
description: "Add an item to the player's inventory. Quantity defaults to 1 if omitted.",
inputSchema: {
playerName: z.string(),
itemName: z.string(),
emoji: z.string(),
quantity: z.number().optional().default(1),
},
},
async ({ playerName, itemName, emoji, quantity = 1 }) => {
const inventory = await createInventoryEntity();
await inventory
.put({
playerName,
itemName,
quantity,
emoji,
})
.go();
return {
content: [
{
type: 'text' as const,
text: `Added ${itemName} (x${quantity}) to inventory`,
},
],
};
},
);
server.registerTool(
'remove-from-inventory',
{
description: "Remove an item from the player's inventory. If quantity is omitted, all items are removed.",
inputSchema: {
playerName: z.string(),
itemName: z.string(),
quantity: z.number().optional(),
},
},
async ({ playerName, itemName, quantity }) => {
const inventory = await createInventoryEntity();
// If quantity is omitted, remove the entire item
if (quantity === undefined) {
try {
await inventory.delete({ playerName, itemName }).go();
return {
content: [
{ type: 'text' as const, text: `${itemName} removed from inventory.` },
],
};
} catch {
return {
content: [
{ type: 'text' as const, text: `${itemName} not found in inventory` },
],
};
}
}
// If quantity is specified, fetch current quantity and update
const item = await inventory.get({ playerName, itemName }).go();
if (!item.data) {
return {
content: [
{ type: 'text' as const, text: `${itemName} not found in inventory` },
],
};
}
const newQuantity = item.data.quantity - quantity;
if (newQuantity <= 0) {
await inventory.delete({ playerName, itemName }).go();
return {
content: [
{ type: 'text' as const, text: `${itemName} removed from inventory.` },
],
};
}
await inventory
.put({
playerName,
itemName,
quantity: newQuantity,
emoji: item.data.emoji,
})
.go();
return {
content: [
{
type: 'text' as const,
text: `Removed ${itemName} (x${quantity}) from inventory. ${newQuantity} remaining.`,
},
],
};
},
);
return server;
};

A medida que crece el número de herramientas, puedes refactorizarlas en archivos separados si lo prefieres.

Elimina los directorios tools y resources en packages/inventory/src/mcp-server ya que no se usarán.

El constructo DungeonDb generado por ts#dynamodb ya aprovisiona nuestra tabla, así que solo necesitamos instanciarlo en nuestro stack y otorgar a la Game API y al servidor MCP de Inventario los permisos que necesitan. Actualiza packages/infra/src/stacks/application-stack.ts como sigue:

import {
DungeonDb,
GameApi,
GameUI,
InventoryMcpServer,
RuntimeConfig,
StoryAgent,
UserIdentity,
suppressRules,
} from ':dungeon-adventure/common-constructs';
import { Stack, StackProps, CfnOutput, RemovalPolicy } from 'aws-cdk-lib';
import {
BlockPublicAccess,
Bucket,
BucketEncryption,
} from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
export class ApplicationStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const rc = RuntimeConfig.ensure(this);
const userIdentity = new UserIdentity(this, 'UserIdentity');
// Sandbox-friendly: allow the table to be deleted with the stack.
const dungeonDb = new DungeonDb(this, 'DungeonDb', {
deletionProtection: false,
removalPolicy: RemovalPolicy.DESTROY,
});
// S3 bucket for Strands conversation history. The Story Agent writes each
// turn via ``S3SessionManager``; the Game API reads them back for replay.
const storySessions = new Bucket(this, 'StorySessions', {
encryption: BucketEncryption.S3_MANAGED,
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
suppressRules(
storySessions,
['CKV_AWS_18', 'CKV_AWS_21'],
'Access logging and object versioning are unnecessary for ephemeral chat transcripts',
);
rc.set('buckets', 'StorySessions', {
bucketName: storySessions.bucketName,
});
const gameApi = new GameApi(this, 'GameApi', {
integrations: GameApi.defaultIntegrations(this).build(),
});
dungeonDb.grantReadData(gameApi.integrations['games.query'].handler);
dungeonDb.grantReadData(gameApi.integrations['inventory.query'].handler);
dungeonDb.grantReadWriteData(gameApi.integrations['games.save'].handler);
storySessions.grantRead(gameApi.integrations['actions.query'].handler);
const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer');
dungeonDb.grantReadWriteData(mcpServer.agentCoreRuntime);
// Use Cognito for user authentication with the agent
const storyAgent = new StoryAgent(this, 'StoryAgent', {
identity: userIdentity,
});
storySessions.grantReadWrite(storyAgent);
new CfnOutput(this, 'StoryAgentArn', {
value: storyAgent.agentCoreRuntime.agentRuntimeArn,
});
new CfnOutput(this, 'InventoryMcpArn', {
value: mcpServer.agentCoreRuntime.agentRuntimeArn,
});
// Grant the agent permissions to invoke our mcp server
mcpServer.grantInvokeAccess(storyAgent);
// Grant the authenticated role access to invoke the api
gameApi.grantInvokeAccess(userIdentity.identityPool.authenticatedRole);
new GameUI(this, 'GameUI');
}
}

No es necesario desplegar en AWS para probar nuestra API — el target serve-local ejecuta la Game API contra DynamoDB Local. Como conectamos la Game API al proyecto DungeonDb en el Módulo 1, este target también inicia DynamoDB Local automáticamente.

Primero, corrige cualquier problema de linting:

Terminal window
pnpm lint

Luego construye la base de código:

Terminal window
pnpm build

Inicia la Game API localmente con el target dev, que también arranca DynamoDB Local:

Terminal window
pnpm nx dev game-api

Una vez que tu servidor esté en funcionamiento, consulta la lista (vacía) de juegos:

Ventana de terminal
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'

Verás una lista vacía:

{"result":{"data":{"items":[],"cursor":null}}}

Ahora guarda un juego:

Ventana de terminal
curl -X POST 'http://localhost:2022/games.save' \
-H 'Content-Type: application/json' \
-d '{"playerName":"Alice","genre":"zombie"}'

El guardado devuelve el juego persistido (con el timestamp lastUpdated que la entidad establece por ti):

{"result":{"data":{"playerName":"Alice","genre":"zombie","lastUpdated":"..."}}}

Consulta nuevamente para confirmar que está persistido en DynamoDB Local:

Ventana de terminal
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'

Esta respuesta ahora incluye el juego guardado:

{"result":{"data":{"items":[{"playerName":"Alice","genre":"zombie","lastUpdated":"..."}],"cursor":null}}}

Puedes detener el servidor local (Ctrl+C) una vez que hayas terminado.

Tarea 5: Probar el servidor MCP de Inventario localmente

Sección titulada «Tarea 5: Probar el servidor MCP de Inventario localmente»

Podemos probar las herramientas del servidor MCP con el MCP Inspector usando el target mcp-server-inspect generado:

Terminal window
pnpm nx mcp-server-inspect inventory

Esto sirve el servidor MCP localmente (arrancando DynamoDB Local también) y lanza el MCP Inspector en http://localhost:6274 preconfigurado para conectarse a él. Haz clic en Connect, cambia a la pestaña Tools, haz clic en List Tools, y prueba add-to-inventory (por ejemplo, playerName: Alice, itemName: Rusty Sword, emoji: ⚔️) seguido de list-inventory-items para verlo persistido en DynamoDB Local. Detén el servidor (Ctrl+C) cuando hayas terminado.

¡Felicidades, has construido y probado tu primera API tRPC y servidor MCP contra una tabla DynamoDB local! 🎉🎉🎉