Salta ai contenuti

Implementare l'API del Gioco e il server MCP dell'Inventario

Implementeremo le seguenti API in questa sezione:

  1. saveGame - crea o aggiorna un gioco.
  2. queryGames - restituisce una lista paginata di giochi salvati precedentemente.
  3. queryInventory - restituisce una lista paginata degli oggetti nell’inventario di un giocatore.
  4. queryActions - restituisce la cronologia delle conversazioni per un dato gioco.

Per definire gli input e gli output della nostra API, creiamo il nostro schema utilizzando Zod nel file packages/game-api/src/schema/index.ts come segue:

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

Elimina il file packages/game-api/src/schema/echo.ts poiché non lo utilizzeremo in questo progetto.

Questo è il diagramma ER per la nostra applicazione.

Diagram

Il generatore ts#dynamodb ha configurato ElectroDB, che utilizzeremo per modellare i nostri dati. Persisteremo la cronologia delle conversazioni in S3, quindi aggiungiamo una dipendenza sul client S3:

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

Sostituisci l’entità di esempio generata in packages/dungeon-db/src/entities/index.ts con le nostre entità Game e Inventory, ed elimina 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 ci permette non solo di definire i nostri tipi, ma può anche fornire valori predefiniti per certi campi come i timestamp. Inoltre, ElectroDB segue il single-table design, che è la best practice quando si utilizza DynamoDB.

Per implementare i metodi dell’API, apporta le seguenti modifiche in 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;
});

Elimina il file echo.ts (da packages/game-api/src/procedures) poiché non lo utilizzeremo in questo progetto.

Dopo aver definito le nostre procedure, per collegarle alla nostra API, aggiorna il seguente file:

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;

Creiamo un server MCP che permetterà al nostro agente di gestire gli oggetti nell’inventario di un giocatore.

Definiremo i seguenti strumenti per il nostro agente:

  • list-inventory-items per recuperare gli oggetti correnti nell’inventario del giocatore
  • add-to-inventory per aggiungere oggetti all’inventario del giocatore
  • remove-from-inventory per rimuovere oggetti dall’inventario del giocatore

Per risparmiare tempo, definiremo tutti gli strumenti 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;
};

Man mano che il numero di strumenti cresce, puoi eventualmente rifattorizzarli in file separati.

Elimina le directory tools e resources in packages/inventory/src/mcp-server poiché non verranno utilizzate.

Il costrutto DungeonDb generato da ts#dynamodb fornisce già la nostra tabella, quindi dobbiamo solo istanziarlo nel nostro stack e concedere alla Game API e al server MCP dell’Inventario i permessi di cui hanno bisogno. Aggiorna packages/infra/src/stacks/application-stack.ts come 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');
}
}

Non c’è bisogno di deployare su AWS per provare la nostra API — il target dev esegue la Game API contro DynamoDB Local. Poiché abbiamo collegato la Game API al progetto DungeonDb nel Modulo 1, questo target avvia anche DynamoDB Local automaticamente.

Prima, correggi eventuali problemi di linting:

Terminal window
pnpm lint

Poi compila la codebase:

Terminal window
pnpm build

Avvia la Game API localmente con il target dev, che avvia anche DynamoDB Local:

Terminal window
pnpm nx dev game-api

Una volta avviato il server, interroga la lista (vuota) dei giochi:

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

Vedrai una lista vuota:

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

Ora salva un gioco:

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

Il salvataggio restituisce il gioco persistito (con il timestamp lastUpdated che l’entità imposta per te):

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

Interroga di nuovo per confermare che è stato persistito in DynamoDB Local:

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

Questa risposta ora include il gioco salvato:

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

Puoi fermare il server locale (Ctrl+C) una volta terminato.

Task 5: Testare il server MCP dell’Inventario localmente

Sezione intitolata “Task 5: Testare il server MCP dell’Inventario localmente”

Possiamo provare gli strumenti del server MCP con l’MCP Inspector utilizzando il target mcp-server-inspect generato:

Terminal window
pnpm nx mcp-server-inspect inventory

Questo serve il server MCP localmente (avviando anche DynamoDB Local) e lancia l’MCP Inspector su http://localhost:6274 pre-configurato per connettersi ad esso. Fai clic su Connect, passa alla scheda Tools, fai clic su List Tools, e prova add-to-inventory (ad es. playerName: Alice, itemName: Rusty Sword, emoji: ⚔️) seguito da list-inventory-items per vederlo persistito in DynamoDB Local. Ferma il server (Ctrl+C) quando hai finito.

Complimenti, hai costruito e testato la tua prima API tRPC e il server MCP contro una tabella DynamoDB locale! 🎉🎉🎉🎉🎉