Aller au contenu

Jeu de Donjon d'IA Agentique

Module 2 : Implémentation de l’API de jeu et du serveur MCP d’inventaire

Section intitulée « Module 2 : Implémentation de l’API de jeu et du serveur MCP d’inventaire »

Nous allons commencer par implémenter notre API de jeu. Pour cela, nous devons créer 5 API au total :

  1. saveGame - créer ou mettre à jour une partie.
  2. queryGames - retourner une liste paginée des parties précédemment sauvegardées.
  3. saveAction - sauvegarder une action pour une partie donnée.
  4. queryActions - retourner une liste paginée de toutes les actions liées à une partie.
  5. queryInventory - retourner une liste paginée des objets dans l’inventaire d’un joueur.

Pour définir les entrées et sorties de notre API, créons notre schéma avec Zod dans le répertoire packages/game-api/src/schema comme suit :

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

Vous pouvez également supprimer le fichier packages/game-api/src/schema/echo.ts car nous ne l’utiliserons pas dans ce projet.

Le diagramme entité-relation de notre application est le suivant :

dungeon-adventure-er.png

Nous allons implémenter notre base de données dans DynamoDB en utilisant la bibliothèque cliente ElectroDB pour simplifier les opérations. Pour commencer, installons d’abord electrodb en exécutant la commande suivante :

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

Maintenant, créons les fichiers suivants dans notre dossier packages/game-api/src/entities pour définir nos entités ElectroDB conformément au diagramme ER ci-dessus :

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

ElectroDB nous permet non seulement de définir nos types, mais peut aussi fournir des valeurs par défaut pour certains champs comme les horodatages ci-dessus. De plus, ElectroDB suit le single-table design, une meilleure pratique avec DynamoDB.

Pour préparer l’interaction du serveur MCP avec l’inventaire, assurons-nous d’exporter l’entité d’inventaire dans 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';

Étant donné que nous avons besoin d’accéder au client DynamoDB dans chacune de nos procédures, nous voulons créer une instance unique du client que nous pouvons passer via le contexte. Pour cela, effectuons les modifications suivantes dans 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;
});
};

Ce plugin est instrumenté pour créer le DynamoDBClient et l’injecter dans le contexte.

Il est maintenant temps d’implémenter les méthodes de l’API. Pour cela, effectuons les modifications suivantes dans 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;
});

Vous pouvez également supprimer le fichier echo.ts (de packages/game-api/src/procedures) car nous ne l’utiliserons pas dans ce projet.

Maintenant que nos procédures sont définies, intégrons-les à notre API. Pour cela, mettons à jour le fichier suivant :

import {
awsLambdaRequestHandler,
CreateAWSLambdaContextOptions,
} from '@trpc/server/adapters/aws-lambda';
import { echo } from './procedures/echo.js';
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({
echo,
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;

Créons maintenant un serveur MCP qui permettra à notre agent de gérer les objets dans l’inventaire d’un joueur.

Nous définirons les outils suivants pour notre Agent :

  • list-inventory-items pour récupérer les objets actuels de l’inventaire du joueur
  • add-to-inventory pour ajouter des objets à l’inventaire du joueur
  • remove-from-inventory pour retirer des objets de l’inventaire du joueur

Pour gagner du temps, nous définirons tous les outils inline :

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerAddTool } from './tools/add.js';
import { registerSampleGuidanceResource } from './resources/sample-guidance.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',
});
registerAddTool(server);
registerSampleGuidanceResource(server);
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;
};

Si le nombre d’outils augmente, vous pourrez les refactoriser dans des fichiers séparés.

Vous pouvez maintenant supprimer les répertoires tools et resources dans packages/inventory/src/mcp-server car ils ne sont pas utilisés.

La dernière étape consiste à mettre à jour notre infrastructure pour créer la table DynamoDB et accorder les permissions nécessaires à l’API de jeu. Pour cela, mettons à jour packages/infra/src comme suit :

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

D’abord, construisons la base de code :

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

Votre application peut maintenant être déployée en exécutant :

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

Le premier déploiement prendra environ 8 minutes. Les déploiements suivants prendront environ 2 minutes.

Une fois le déploiement terminé, vous devriez voir des sorties similaires à ceci (certaines valeurs ont été masquées) :

Fenêtre de terminal
dungeon-adventure-sandbox-Application
dungeon-adventure-sandbox-Application: deploying... [2/2]
dungeon-adventure-sandbox-Application
Durée du déploiement : 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

Nous pouvons tester notre API en :

  • Démarrant une instance locale du backend tRPC et en appelant les API via curl.
  • Appeler l'API déployée avec curl sigv4

Démarrez le serveur game-api localement avec :

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

Une fois le serveur démarré, appelez-le avec :

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

Si la commande réussit, vous devriez voir cette réponse :

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

Félicitations, vous avez déployé votre première API avec tRPC ! 🎉🎉🎉