Pular para o conteúdo

Jogo de Dungeons de IA Agêntica

Módulo 2: Implementação da API do Jogo e do Servidor MCP de Inventário

Seção intitulada “Módulo 2: Implementação da API do Jogo e do Servidor MCP de Inventário”

Vamos começar implementando nossa Game API. Para isso, precisamos criar 5 APIs no total:

  1. saveGame - criar ou atualizar um jogo.
  2. queryGames - retornar uma lista paginada de jogos salvos anteriormente.
  3. saveAction - salvar uma ação para um determinado jogo.
  4. queryActions - retornar uma lista paginada de todas as ações relacionadas a um jogo.
  5. queryInventory - retornar uma lista paginada de itens no inventário de um jogador.

Para definir as entradas e saídas da nossa API, vamos criar nosso esquema usando Zod no diretório packages/game-api/src/schema da seguinte forma:

import { z } from 'zod';
export const ActionSchema = z.object({
playerName: z.string(),
timestamp: z.iso.datetime(),
role: z.enum(['assistant', 'user']),
content: z.string(),
});
export type IAction = z.TypeOf<typeof ActionSchema>;

Você também pode excluir o arquivo packages/game-api/src/schema/echo.ts já que não o usaremos neste projeto.

O diagrama ER para nossa aplicação é o seguinte:

dungeon-adventure-er.png

Vamos implementar nosso banco de dados no DynamoDB usando a biblioteca cliente ElectroDB para simplificar o processo. Para começar, primeiro precisamos instalar o electrodb e o DynamoDB Client executando o seguinte comando:

Terminal window
pnpm add -w electrodb@3.5.0 @aws-sdk/client-dynamodb@3.913.0

Agora vamos criar os seguintes arquivos na pasta packages/game-api/src/entities para definir nossas entidades ElectroDB de acordo com o diagrama ER acima:

import { Entity } from 'electrodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createActionEntity = (client?: DynamoDBClient) =>
new Entity(
{
model: {
entity: 'Action',
version: '1',
service: 'game',
},
attributes: {
playerName: { type: 'string', required: true, readOnly: true },
timestamp: {
type: 'string',
required: true,
readOnly: true,
set: () => new Date().toISOString(),
default: () => new Date().toISOString(),
},
role: { type: 'string', required: true, readOnly: true },
content: { type: 'string', required: true, readOnly: true },
},
indexes: {
primary: {
pk: { field: 'pk', composite: ['playerName'] },
sk: { field: 'sk', composite: ['timestamp'] },
},
},
},
{ client, table: process.env.TABLE_NAME },
);

O ElectroDB nos permite não apenas definir nossos tipos, mas também fornecer valores padrão para certos campos como os timestamps acima. Além disso, o ElectroDB segue o design de tabela única, que é a melhor prática ao usar DynamoDB.

Para preparar o servidor MCP para interagir com o inventário, vamos garantir que exportamos a entidade de inventário em packages/game-api/src/index.ts:

export type { AppRouter } from './router.js';
export { appRouter } from './router.js';
export type { Context } from './init.js';
export * from './client/index.js';
export * from './schema/index.js';
export * from './entities/inventory.js';

Adicionando o cliente DynamoDB ao contexto do tRPC

Seção intitulada “Adicionando o cliente DynamoDB ao contexto do tRPC”

Como precisamos de acesso ao cliente DynamoDB em cada um de nossos procedimentos, queremos criar uma única instância do cliente que possamos passar via contexto. Para isso, faça as seguintes alterações em packages/game-api/src:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { initTRPC } from '@trpc/server';
export interface IDynamoDBContext {
dynamoDb?: DynamoDBClient;
}
export const createDynamoDBPlugin = () => {
const t = initTRPC.context<IDynamoDBContext>().create();
return t.procedure.use(async (opts) => {
const dynamoDb = new DynamoDBClient();
const response = await opts.next({
ctx: {
...opts.ctx,
dynamoDb,
},
});
return response;
});
};

Este é um plugin que instrumentamos para criar o DynamoDBClient e injetá-lo no contexto.

Agora é hora de implementar os métodos da API. Para isso, faça as seguintes alterações em packages/game-api/src/procedures:

import { createActionEntity } from '../entities/action.js';
import {
ActionSchema,
IAction,
QueryInputSchema,
createPaginatedQueryOutput,
} from '../schema/index.js';
import { publicProcedure } from '../init.js';
import { z } from 'zod';
export const queryActions = publicProcedure
.input(QueryInputSchema.extend({ playerName: z.string() }))
.output(createPaginatedQueryOutput(ActionSchema))
.query(async ({ input, ctx }) => {
const actionEntity = createActionEntity(ctx.dynamoDb);
const result = await actionEntity.query
.primary({ playerName: input.playerName })
.go({ cursor: input.cursor, count: input.limit });
return {
items: result.data as IAction[],
cursor: result.cursor,
};
});
import { ActionSchema, IAction } from '../schema/index.js';
import { publicProcedure } from '../init.js';
import { createActionEntity } from '../entities/action.js';
import { createGameEntity } from '../entities/game.js';
export const saveAction = publicProcedure
.input(ActionSchema.omit({ timestamp: true }))
.output(ActionSchema)
.mutation(async ({ input, ctx }) => {
const actionEntity = createActionEntity(ctx.dynamoDb);
const gameEntity = createGameEntity(ctx.dynamoDb);
const action = await actionEntity.put(input).go();
await gameEntity
.update({ playerName: input.playerName })
.set({ lastUpdated: action.data.timestamp })
.go();
return action.data as IAction;
});

Você também pode excluir o arquivo echo.ts (de packages/game-api/src/procedures) já que não o usaremos neste projeto.

Agora que definimos nossos procedimentos, vamos conectá-los à nossa API. Para isso, atualize o seguinte arquivo:

import {
awsLambdaRequestHandler,
CreateAWSLambdaContextOptions,
} from '@trpc/server/adapters/aws-lambda';
import { t } from './init.js';
import { APIGatewayProxyEvent } from 'aws-lambda';
import { queryActions } from './procedures/query-actions.js';
import { saveAction } from './procedures/save-action.js';
import { queryGames } from './procedures/query-games.js';
import { saveGame } from './procedures/save-game.js';
import { queryInventory } from './procedures/query-inventory.js';
export const router = t.router;
export const appRouter = router({
actions: router({
query: queryActions,
save: saveAction,
}),
games: router({
query: queryGames,
save: saveGame,
}),
inventory: router({
query: queryInventory,
}),
});
export const handler = awsLambdaRequestHandler({
router: appRouter,
createContext: (
ctx: CreateAWSLambdaContextOptions<APIGatewayProxyEvent>,
) => ctx,
responseMeta: () => ({
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
},
}),
});
export type AppRouter = typeof appRouter;

Agora vamos criar um servidor MCP que permitirá ao nosso agente gerenciar itens no inventário de um jogador.

Definiremos as seguintes ferramentas para nosso Agente:

  • list-inventory-items para recuperar os itens atuais do inventário do jogador
  • add-to-inventory para adicionar itens ao inventário do jogador
  • remove-from-inventory para remover itens do inventário do jogador

Para economizar tempo, definiremos todas as ferramentas inline:

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

Conforme o número de ferramentas crescer, você pode refatorá-las em arquivos separados se preferir.

Agora você pode excluir os diretórios tools e resources em packages/inventory/src/mcp-server pois não são utilizados.

A etapa final é atualizar nossa infraestrutura para criar a tabela DynamoDB e conceder permissões para operações da Game API. Para isso, atualize o packages/infra/src conforme segue:

import { CfnOutput } from 'aws-cdk-lib';
import {
AttributeType,
BillingMode,
ProjectionType,
Table,
TableProps,
} from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';
import { suppressRules } from ':dungeon-adventure/common-constructs';
export type ElectrodbDynamoTableProps = Omit<
TableProps,
'partitionKey' | 'sortKey' | 'billingMode'
>;
export class ElectrodbDynamoTable extends Table {
constructor(scope: Construct, id: string, props?: ElectrodbDynamoTableProps) {
super(scope, id, {
partitionKey: {
name: 'pk',
type: AttributeType.STRING,
},
sortKey: {
name: 'sk',
type: AttributeType.STRING,
},
billingMode: BillingMode.PAY_PER_REQUEST,
...props,
});
this.addGlobalSecondaryIndex({
indexName: 'gsi1pk-gsi1sk-index',
partitionKey: {
name: 'gsi1pk',
type: AttributeType.STRING,
},
sortKey: {
name: 'gsi1sk',
type: AttributeType.STRING,
},
projectionType: ProjectionType.ALL,
});
// Suppress checkov rules that expect a KMS customer managed key and backup to be enabled
suppressRules(this, ['CKV_AWS_119', 'CKV_AWS_28'], 'No need for custom encryption or backup');
new CfnOutput(this, 'TableName', { value: this.tableName });
}
}

Primeiro, vamos construir a base de código:

Terminal window
pnpm nx run-many --target build --all

Sua aplicação agora pode ser implantada executando o seguinte comando:

Terminal window
pnpm nx deploy infra dungeon-adventure-infra-sandbox/*

Sua primeira implantação levará cerca de 8 minutos para ser concluída. Implantações subsequentes levarão cerca de 2 minutos.

Uma vez concluída a implantação, você verá algumas saídas semelhantes às seguintes (alguns valores foram omitidos):

Terminal window
dungeon-adventure-sandbox-Application
dungeon-adventure-sandbox-Application: deploying... [2/2]
dungeon-adventure-sandbox-Application
Deployment time: 354s
Outputs:
dungeon-adventure-sandbox-Application.ElectroDbTableTableNameXXX = dungeon-adventure-sandbox-Application-ElectroDbTableXXX-YYY
dungeon-adventure-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-sandbox-Application.StoryApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Podemos testar nossa API de duas formas:

  • Iniciando uma instância local do backend tRPC e invocando as APIs usando curl.
  • Chamando a API implantada usando curl com Sigv4

Inicie seu servidor local game-api executando o seguinte comando:

Terminal window
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api:serve

Com o servidor em execução, você pode chamá-lo executando:

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

Se o comando for executado com sucesso, você verá uma resposta como:

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

Parabéns, você construiu e implantou sua primeira API usando tRPC! 🎉🎉🎉