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. queryInventory - retourner une liste paginée des objets dans l’inventaire d’un joueur.
  4. queryActions - retourner l’historique des conversations pour une partie donnée.

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

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

Le générateur ts#dynamodb a configuré ElectroDB, que nous utiliserons pour modéliser nos données. Nous persisterons l’historique des conversations dans S3, nous ajoutons donc une dépendance au client S3 :

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

Remplacez l’entité d’exemple générée dans packages/dungeon-db/src/entities/index.ts par nos entités Game et Inventory, et supprimez 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() },
);

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 implémenter les méthodes de l’API, effectuez les modifications suivantes dans 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;
});

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

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

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.

Le construct DungeonDb généré par ts#dynamodb provisionne déjà notre table, nous devons donc simplement l’instancier dans notre stack et accorder à l’API de jeu et au serveur MCP d’inventaire les permissions dont ils ont besoin. Mettez à jour packages/infra/src/stacks/application-stack.ts comme suit :

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

Il n’est pas nécessaire de déployer sur AWS pour essayer notre API — la cible dev exécute l’API de jeu contre DynamoDB Local. Comme nous avons connecté l’API de jeu au projet DungeonDb dans le Module 1, cette cible démarre également DynamoDB Local automatiquement.

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

Terminal window
pnpm lint

Puis compilez le codebase :

Terminal window
pnpm build

Démarrez l’API de jeu localement avec la cible dev, qui démarre également DynamoDB Local :

Terminal window
pnpm nx dev game-api

Une fois votre serveur démarré, interrogez la liste (vide) des parties :

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

Vous verrez une liste vide :

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

Maintenant, sauvegardez une partie :

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

La sauvegarde retourne la partie persistée (avec l’horodatage lastUpdated que l’entité définit pour vous) :

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

Interrogez à nouveau pour confirmer qu’elle est persistée dans DynamoDB Local :

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

Cette réponse inclut maintenant la partie sauvegardée :

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

Vous pouvez arrêter le serveur local (Ctrl+C) une fois que vous avez terminé.

Tâche 5 : Tester le serveur MCP d’inventaire localement

Section intitulée « Tâche 5 : Tester le serveur MCP d’inventaire localement »

Nous pouvons essayer les outils du serveur MCP avec le MCP Inspector en utilisant la cible mcp-server-inspect générée :

Terminal window
pnpm nx mcp-server-inspect inventory

Cela sert le serveur MCP localement (en démarrant également DynamoDB Local) et lance le MCP Inspector à http://localhost:6274 pré-configuré pour s’y connecter. Cliquez sur Connect, passez à l’onglet Tools, cliquez sur List Tools, et essayez add-to-inventory (par exemple playerName: Alice, itemName: Rusty Sword, emoji: ⚔️) suivi de list-inventory-items pour le voir persisté dans DynamoDB Local. Arrêtez le serveur (Ctrl+C) lorsque vous avez terminé.

Félicitations, vous avez construit et testé votre première API tRPC et serveur MCP contre une table DynamoDB locale ! 🎉🎉🎉🎉🎉