Pular para o conteúdo

Implementar a API do Jogo e o servidor MCP de Inventário

Antes de implementar nossa Game API, precisamos criar estas 5 APIs:

  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ê pode excluir o arquivo packages/game-api/src/schema/echo.ts pois não o usaremos neste projeto.

Este é o diagrama ER para nossa aplicação.

dungeon-adventure-er.png

Vamos implementar nosso banco de dados no DynamoDB e usaremos a biblioteca cliente ElectroDB do DynamoDB para simplificar o processo. Para instalar o electrodb e o DynamoDB Client, execute este comando:

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

Para definir nossas entidades ElectroDB a partir do diagrama ER, vamos criar os seguintes arquivos na pasta packages/game-api/src/entities:

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 timestamps. 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, vamos 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.

Para implementar os métodos da API, 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ê pode excluir o arquivo echo.ts (de packages/game-api/src/procedures) pois não o usaremos neste projeto.

Depois de definir nossos procedimentos, para conectá-los à nossa API, 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;

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, vamos definir 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.

Exclua os diretórios tools e resources em packages/inventory/src/mcp-server pois não serão utilizados.

A etapa final é atualizar nossa infraestrutura para criar a tabela DynamoDB e conceder permissões para executar 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 });
}
}

Para construir a base de código:

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

Para implantar sua aplicação, execute 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á 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

Você pode testar a 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 habilitado

Para iniciar seu servidor local game-api, execute o seguinte comando:

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

Assim que seu servidor estiver em execução, você pode chamá-lo executando o seguinte comando:

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! 🎉🎉🎉