Pular para o conteúdo

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

Vamos implementar as seguintes APIs nesta seção:

  1. saveGame - criar ou atualizar um jogo.
  2. queryGames - retornar uma lista paginada de jogos salvos anteriormente.
  3. queryInventory - retornar uma lista paginada de itens no inventário de um jogador.
  4. queryActions - retornar o histórico de conversação para um determinado jogo.

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

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(),
});
};

Exclua o arquivo packages/game-api/src/schema/echo.ts pois não o usaremos neste projeto.

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

Diagram

O gerador ts#dynamodb configurou o ElectroDB, que usaremos para modelar nossos dados. Vamos persistir o histórico de conversação no S3, então adicionamos uma dependência no cliente S3:

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

Substitua a entidade de exemplo gerada em packages/dungeon-db/src/entities/index.ts por nossas entidades Game e Inventory, e exclua 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() },
);

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 implementar os métodos da API, faça as seguintes alterações em 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;
});

Exclua 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 { 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;

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 { 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;
};

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.

O construto DungeonDb gerado pelo ts#dynamodb já provisiona nossa tabela, então só precisamos instanciá-lo em nossa stack e conceder à Game API e ao servidor MCP de Inventário as permissões necessárias. Atualize packages/infra/src/stacks/application-stack.ts conforme segue:

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');
}
}

Não há necessidade de implantar na AWS para testar nossa API — o target serve-local executa a Game API contra o DynamoDB Local. Como conectamos a Game API ao projeto DungeonDb no Módulo 1, este target também inicia o DynamoDB Local automaticamente.

Primeiro, corrija quaisquer problemas de lint:

Terminal window
pnpm lint

Depois construa a base de código:

Terminal window
pnpm build

Inicie a Game API localmente com o target dev, que também inicializa o DynamoDB Local:

Terminal window
pnpm nx dev game-api

Assim que seu servidor estiver em execução, consulte a lista (vazia) de jogos:

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

Você verá uma lista vazia:

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

Agora salve um jogo:

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

O save retorna o jogo persistido (com o timestamp lastUpdated que a entidade define para você):

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

Consulte novamente para confirmar que está persistido no DynamoDB Local:

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

Esta resposta agora inclui o jogo salvo:

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

Você pode parar o servidor local (Ctrl+C) quando terminar.

Tarefa 5: Testar o servidor MCP de Inventário localmente

Seção intitulada “Tarefa 5: Testar o servidor MCP de Inventário localmente”

Podemos testar as ferramentas do servidor MCP com o MCP Inspector usando o target mcp-server-inspect gerado:

Terminal window
pnpm nx mcp-server-inspect inventory

Isso serve o servidor MCP localmente (inicializando o DynamoDB Local também) e lança o MCP Inspector em http://localhost:6274 pré-configurado para se conectar a ele. Clique em Connect, mude para a aba Tools, clique em List Tools, e experimente add-to-inventory (por exemplo, playerName: Alice, itemName: Rusty Sword, emoji: ⚔️) seguido de list-inventory-items para vê-lo persistido no DynamoDB Local. Pare o servidor (Ctrl+C) quando terminar.

Parabéns, você construiu e testou sua primeira API tRPC e servidor MCP contra uma tabela DynamoDB local! 🎉🎉🎉