Aller au contenu

Implémenter l'API Game et le serveur MCP Inventory

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 de conversation pour une partie donnée.

Pour définir nos entrées et sorties d’API, créons notre schéma en utilisant 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 ER de notre application.

Diagram

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

Terminal window
pnpm add @aws-sdk/client-s3@3.1111.0 --filter game-api

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 peut également fournir des valeurs par défaut pour certaines valeurs comme les horodatages. De plus, ElectroDB suit la conception à table unique qui est la meilleure pratique lors de l’utilisation de 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 (de packages/game-api/src/procedures) car nous ne l’utiliserons pas dans ce projet.

Après avoir défini nos procédures, pour les intégrer dans notre API, mettez à jour 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 un serveur MCP qui permettra à notre agent de gérer les objets dans l’inventaire d’un joueur.

Nous allons définir 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 allons définir tous les outils en ligne :

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

Au fur et à mesure que le nombre d’outils augmente, vous pouvez 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 Story Agent provisionne déjà et écrit dans son propre bucket de session en interne, mais ne l’expose pas — donc rien en dehors de l’agent ne peut se voir accorder l’accès pour le lire pour le moment. Puisque queryActions a besoin de relire l’historique de conversation, pour l’illustration et la simplicité, nous allons exposer son bucket de session interne en tant que propriété publique sur common/constructs/src/app/agents/story-agent/story-agent.ts :

import { Fn, Lazy, Names, RemovalPolicy, Stack } from 'aws-cdk-lib';
import { Platform } from 'aws-cdk-lib/aws-ecr-assets';
import { Connections, IConnectable } from 'aws-cdk-lib/aws-ec2';
import {
BlockPublicAccess,
Bucket,
BucketEncryption,
IBucket,
} from 'aws-cdk-lib/aws-s3';
import { Key } from 'aws-cdk-lib/aws-kms';
import {
CfnDelivery,
CfnDeliveryDestination,
CfnDeliverySource,
LogGroup,
RetentionDays,
} from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
import * as path from 'path';
import * as url from 'url';
import {
AgentRuntimeArtifact,
ProtocolType,
Runtime,
RuntimeProps,
RuntimeAuthorizerConfiguration,
} from 'aws-cdk-lib/aws-bedrockagentcore';
import {
PolicyStatement,
Effect,
ServicePrincipal,
IGrantable,
IPrincipal,
} from 'aws-cdk-lib/aws-iam';
import { IUserPool, IUserPoolClient } from 'aws-cdk-lib/aws-cognito';
import { suppressRules } from '../../../core/checkov.js';
import { RuntimeConfig } from '../../../core/runtime-config.js';
import { findWorkspaceRoot } from '../../../core/workspace.js';
export type StoryAgentProps = Omit<
RuntimeProps,
| 'runtimeName'
| 'protocolConfiguration'
| 'agentRuntimeArtifact'
| 'authorizerConfiguration'
> & {
/**
* Identity details for Cognito Authentication
*/
identity: {
userPool: IUserPool;
userPoolClient: IUserPoolClient;
};
/**
* Removal policy for the session bucket holding the agent's conversation
* history. Defaults to retaining it so a stack `destroy` doesn't silently
* delete session data — set to `RemovalPolicy.DESTROY` for sandbox/CI teardown.
*
* @default RemovalPolicy.RETAIN
*/
readonly sessionBucketRemovalPolicy?: RemovalPolicy;
};
export class StoryAgent extends Construct implements IGrantable, IConnectable {
public readonly dockerImage: AgentRuntimeArtifact;
public readonly agentCoreRuntime: Runtime;
/** The S3 bucket backing this agent's session storage — exposed so other constructs (e.g. the Game API) can be granted access to read conversation history back. */
public readonly sessionBucket: IBucket;
/** Default Gateway target name for this agent. */
public readonly agentName = 'story-agent';
/** Inbound auth — a fronting Gateway uses this to pick its outbound credential. */
public readonly auth = 'cognito';
constructor(scope: Construct, id: string, props: StoryAgentProps) {
super(scope, id);
const rc = RuntimeConfig.ensure(this);
// Resolve the bundle output directory containing the Dockerfile and built artifacts
const bundleDir = path.join(
findWorkspaceRoot(url.fileURLToPath(new URL(import.meta.url))),
'dist/packages/story/docker/story-agent',
);
this.dockerImage = AgentRuntimeArtifact.fromAsset(bundleDir, {
platform: Platform.LINUX_ARM64,
});
const {
identity,
sessionBucketRemovalPolicy = RemovalPolicy.RETAIN,
...restProps
} = props ?? {};
const sessionKey = new Key(this, 'SessionKey', {
enableKeyRotation: true,
});
// Allow CloudWatch Logs to use the session key for server access log delivery.
const stack = Stack.of(this);
sessionKey.addToResourcePolicy(
new PolicyStatement({
effect: Effect.ALLOW,
principals: [
new ServicePrincipal(`logs.${stack.region}.amazonaws.com`),
],
actions: [
'kms:Encrypt',
'kms:Decrypt',
'kms:ReEncrypt*',
'kms:GenerateDataKey*',
'kms:DescribeKey',
],
resources: ['*'],
conditions: {
ArnLike: {
'kms:EncryptionContext:aws:logs:arn': `arn:aws:logs:${stack.region}:${stack.account}:log-group:*`,
},
},
}),
);
const sessionAccessLogs = new LogGroup(this, 'SessionAccessLogs', {
retention: RetentionDays.ONE_YEAR,
encryptionKey: sessionKey,
removalPolicy: RemovalPolicy.DESTROY,
});
const sessionBucket = new Bucket(this, 'SessionBucket', {
enforceSSL: true,
removalPolicy: sessionBucketRemovalPolicy,
encryption: BucketEncryption.KMS,
encryptionKey: sessionKey,
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
});
this.sessionBucket = sessionBucket;
suppressRules(
sessionBucket,
['CKV_AWS_21'],
'Session data does not need versioning enabled',
);
suppressRules(
sessionBucket,
['CKV2_AWS_61'],
'Lifecycle configuration not required for session data',
);
suppressRules(
sessionBucket,
['CKV_AWS_144'],
'Cross-region replication not required for session data',
);
suppressRules(
sessionBucket,
['CKV2_AWS_62'],
'Event notifications not required for session data',
);
suppressRules(
sessionBucket,
['CKV_AWS_18'],
'Server access logs are delivered to CloudWatch Logs',
);
const sessionAccessLogsSource: CfnDeliverySource = new CfnDeliverySource(
this,
'SessionAccessLogsSource',
{
name: Lazy.string({
produce: () =>
Names.uniqueResourceName(sessionAccessLogsSource, {
maxLength: 60,
}),
}),
logType: 'S3_SERVER_ACCESS_LOGS',
resourceArn: sessionBucket.bucketArn,
},
);
const sessionBucketPolicy = sessionBucket.policy;
if (sessionBucketPolicy) {
sessionAccessLogsSource.node.addDependency(sessionBucketPolicy);
}
const sessionAccessLogsDestination: CfnDeliveryDestination =
new CfnDeliveryDestination(this, 'SessionAccessLogsDestination', {
name: Lazy.string({
produce: () =>
Names.uniqueResourceName(sessionAccessLogsDestination, {
maxLength: 60,
}),
}),
destinationResourceArn: sessionAccessLogs.logGroupArn,
});
const sessionAccessLogsDelivery = new CfnDelivery(
this,
'SessionAccessLogsDelivery',
{
deliverySourceName: sessionAccessLogsSource.name,
deliveryDestinationArn: sessionAccessLogsDestination.attrArn,
},
);
sessionAccessLogsDelivery.addDependency(sessionAccessLogsSource);
this.agentCoreRuntime = new Runtime(this, 'StoryAgent', {
runtimeName: Lazy.string({
produce: () =>
Names.uniqueResourceName(this.agentCoreRuntime, { maxLength: 40 }),
}),
protocolConfiguration: ProtocolType.HTTP,
agentRuntimeArtifact: this.dockerImage,
authorizerConfiguration: RuntimeAuthorizerConfiguration.usingCognito(
identity.userPool,
[identity.userPoolClient],
),
// Receive the caller's Authorization header (validated by the authorizer).
requestHeaderConfiguration: {
allowlistedHeaders: ['Authorization'],
},
...restProps,
environmentVariables: {
RUNTIME_CONFIG_APP_ID: rc.appConfigApplicationId,
...restProps?.environmentVariables,
},
});
// Grant access for the agent to invoke bedrock models
this.agentCoreRuntime.addToRolePolicy(
new PolicyStatement({
actions: [
'bedrock:InvokeModel',
'bedrock:InvokeModelWithResponseStream',
],
resources: [
'arn:aws:bedrock:*:*:foundation-model/*',
'arn:aws:bedrock:*:*:inference-profile/*',
],
}),
);
sessionBucket.grantReadWrite(this.agentCoreRuntime);
rc.grantReadAppConfig(this.agentCoreRuntime);
rc.set('agentcore', 'agentRuntimes', {
...rc.get('agentcore').agentRuntimes,
StoryAgent: {
arn: this.agentCoreRuntime.agentRuntimeArn,
session: {
bucketName: sessionBucket.bucketName,
},
},
});
rc.set('connection', 'agentRuntimes', {
...rc.get('connection').agentRuntimes,
StoryAgent: this.agentCoreRuntime.agentRuntimeArn,
});
}
/**
* The principal to grant permissions to.
*/
public get grantPrincipal(): IPrincipal {
return this.agentCoreRuntime.grantPrincipal;
}
/**
* Network connections for this agent runtime.
*/
public get connections(): Connections {
return this.agentCoreRuntime.connections;
}
/**
* The HTTPS invocation URL of the runtime.
*/
public get invocationUrl(): string {
// The URL must URL-encode the runtime ARN (':' -> '%3A', '/' -> '%2F').
// The ARN is a CDK token, so encode at deploy time via Fn.join/Fn.split.
const encodedArn = Fn.join(
'%2F',
Fn.split(
'/',
Fn.join('%3A', Fn.split(':', this.agentCoreRuntime.agentRuntimeArn)),
),
);
return `https://bedrock-agentcore.${Stack.of(this).region}.amazonaws.com/runtimes/${encodedArn}/invocations?qualifier=DEFAULT`;
}
}

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 Game et au serveur MCP Inventory les permissions dont ils ont besoin. Mettez à jour packages/infra/src/stacks/application-stack.ts comme suit :

import {
DungeonDb,
GameApi,
GameUI,
InventoryMcpServer,
StoryAgent,
UserIdentity,
} from '@dungeon-adventure/common-constructs';
import { Stack, StackProps, CfnOutput, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
export class ApplicationStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
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,
});
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);
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,
});
// The agent's own session bucket already persists conversation history via
// S3SessionManager; grant the Game API read access so it can replay it.
storyAgent.sessionBucket.grantRead(
gameApi.integrations['actions.query'].handler,
);
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 Game contre DynamoDB Local. Parce que nous avons connecté l’API Game 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

Ensuite, construisez la base de code :

Terminal window
pnpm build

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

Terminal window
pnpm nx dev game-api

Une fois votre serveur opérationnel, 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 Inventory localement

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

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

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