Aller au contenu

Implémenter l'API de Jeu et le serveur MCP d'Inventaire

Nous allons implémenter les API suivantes dans cette section :

  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 fichier packages/game-api/src/schema/index.ts comme suit :

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({
playerName: z.string(),
timestamp: z.iso.datetime(),
role: z.enum(['assistant', 'user']),
content: z.string(),
});
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(),
});
};

Supprimez le fichier packages/game-api/src/schema/echo.ts car nous ne l’utiliserons pas dans ce projet.

Voici le diagramme entité-relation de notre application.

Diagram

Nous allons implémenter notre base de données dans DynamoDB en utilisant la bibliothèque cliente ElectroDB pour simplifier les choses. Pour installer electrodb et le client DynamoDB, exécutez cette commande :

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

Pour définir nos entités ElectroDB à partir du diagramme ER, créons le fichier packages/game-api/src/entities/index.ts :

import { Entity } from 'electrodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { getAppConfig } from '@aws-lambda-powertools/parameters/appconfig';
/**
* Resolves the DynamoDB table name from runtime config (AppConfig).
*/
const resolveTableName = async (): Promise<string> => {
const tablesConfig = await getAppConfig('tables', {
application: process.env.RUNTIME_CONFIG_APP_ID!,
environment: 'default',
transform: 'json',
});
const tableName = (tablesConfig as Record<string, any>).ElectroDbTable?.tableName;
if (!tableName) {
throw new Error(
'Could not resolve table name from runtime config',
);
}
return tableName;
};
export const createActionEntity = async (client: DynamoDBClient = new 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: await resolveTableName() },
);
export const createGameEntity = async (client: DynamoDBClient = new DynamoDBClient()) =>
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, table: await resolveTableName() },
);
export const createInventoryEntity = async (client: DynamoDBClient = new DynamoDBClient()) =>
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, table: await resolveTableName() },
);

ElectroDB nous permet non seulement de définir nos types, mais aussi de fournir des valeurs par défaut pour certains champs comme les horodatages. 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/index.js';

Pour implémenter les méthodes de l’API, effectuez les modifications suivantes dans packages/game-api/src/procedures :

import { createActionEntity, createGameEntity } from '../entities/index.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 }) => {
const actionEntity = await createActionEntity();
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,
};
});
export const saveAction = publicProcedure
.input(ActionSchema.omit({ timestamp: true }))
.output(ActionSchema)
.mutation(async ({ input }) => {
const actionEntity = await createActionEntity();
const gameEntity = await createGameEntity();
const action = await actionEntity.put(input).go();
await gameEntity
.update({ playerName: input.playerName })
.set({ lastUpdated: action.data.timestamp })
.go();
return action.data as IAction;
});

Supprimez le fichier echo.ts (dans packages/game-api/src/procedures) car nous ne l’utiliserons pas dans ce projet.

Après avoir défini nos procédures, pour les connecter à notre API, modifiez le fichier suivant :

import { t } from './init.js';
import { queryActions, saveAction } 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,
save: saveAction,
}),
games: router({
query: queryGames,
save: saveGame,
}),
inventory: router({
query: queryInventory,
}),
});
export type AppRouter = typeof appRouter;

Créons maintenant un serveur MCP permettant à notre agent de gérer l’inventaire des joueurs.

Nous définirons les outils suivants pour notre agent :

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

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

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import z from 'zod';
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',
});
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;
};

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

Supprimez les répertoires tools et resources dans packages/inventory/src/mcp-server car ils ne seront 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 ce faire, modifiez 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 });
}
}

Tout d’abord, corrigez les problèmes de lint :

Terminal window
pnpm lint

Puis compilez le codebase :

Terminal window
pnpm build

Pour déployer votre application, exécutez la commande suivante :

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 verrez 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
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.RuntimeConfigApplicationId = xxxx
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

Vous pouvez tester l’API via :

  • Le démarrage d’une instance locale du backend tRPC et l’invocation des API avec curl.
  • Appeler l'API déployée avec curl sigv4

Pour démarrer votre serveur game-api local, exécutez la commande suivante :

Terminal window
RUNTIME_CONFIG_APP_ID=xxxx pnpm nx serve game-api

Une fois votre serveur démarré, vous pouvez l’appeler en exécutant la commande suivante :

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

Si la commande s’exécute avec succès, vous verrez une réponse comme suit :

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

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