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. saveAction - salva un’azione per un determinato gioco.
  4. queryActions - restituisce una lista paginata di tutte le azioni relative a un gioco.
  5. queryInventory - restituisce una lista paginata degli oggetti nell’inventario di un giocatore.

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

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.

dungeon-adventure-er.png

Implementeremo il nostro database in DynamoDB e utilizzeremo la libreria client ElectroDB per semplificare le operazioni. Per installare electrodb e il DynamoDB Client, esegui questo comando:

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

Per definire le entità ElectroDB dal diagramma ER, creiamo il file packages/game-api/src/entities/index.ts:

import { Entity } from 'electrodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createActionEntity = (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: process.env.TABLE_NAME },
);
export const createGameEntity = (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: process.env.TABLE_NAME },
);
export const createInventoryEntity = (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: process.env.TABLE_NAME },
);

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 preparare il server MCP a interagire con l’inventario, assicuriamoci di esportare l’entità dell’inventario in 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';

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

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

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-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',
});
const inventory = createInventoryEntity();
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;
};

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 passo finale è aggiornare la nostra infrastruttura per creare la tabella DynamoDB e concedere i permessi per eseguire operazioni dalla Game API. Per farlo, aggiorna packages/infra/src come segue:

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

Prima, correggi eventuali problemi di linting:

Terminal window
pnpm nx run-many --target lint --configuration=fix --all

Poi compila la codebase:

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

Per deployare la tua applicazione, esegui il seguente comando:

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

Il primo deployment richiederà circa 8 minuti. I deployment successivi richiederanno circa 2 minuti.

Una volta completato il deployment, vedrai output simili ai seguenti (alcuni valori sono stati oscurati):

Terminal window
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.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

Puoi testare l’API in due modi:

  • Avviando un’istanza locale del backend tRPC e invocando le API con curl.
  • Chiamare l'API deployata utilizzando curl con sigv4 abilitato

Per avviare il server locale game-api, esegui il seguente comando:

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

Una volta avviato il server, puoi chiamarlo eseguendo il seguente comando:

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

Se il comando viene eseguito con successo, vedrai una risposta come:

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

Complimenti, hai costruito e deployato la tua prima API utilizzando tRPC! 🎉🎉🎉