Salta ai contenuti

Implementare l'API del gioco e il server MCP Inventory

Implementeremo le seguenti API in questa sezione:

  1. saveGame - creare o aggiornare un gioco.
  2. queryGames - restituire un elenco paginato di giochi salvati in precedenza.
  3. queryInventory - restituire un elenco paginato di oggetti nell’inventario di un giocatore.
  4. queryActions - restituire la cronologia delle conversazioni per un determinato gioco.

Per definire i nostri input e output dell’API, creiamo il nostro schema utilizzando Zod all’interno del 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 al client S3:

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

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 consente non solo di definire i nostri tipi, ma può anche fornire valori predefiniti per determinati valori 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 all’interno di 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 consentirà 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 refactorizzarli in file separati se lo desideri.

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

Il costrutto dello Story Agent già fornisce e scrive nel proprio bucket di sessione internamente, ma non lo espone — quindi nulla al di fuori dell’agente può ottenere l’accesso per leggerlo ancora. Poiché queryActions deve leggere la cronologia delle conversazioni, per illustrazione e semplicità esporremo il suo bucket di sessione interno come proprietà pubblica su 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`;
}
}

Il costrutto DungeonDb generato da ts#dynamodb fornisce già la nostra tabella, quindi dobbiamo solo istanziarlo nel nostro stack e concedere all’API del gioco e al server MCP Inventory i permessi di cui hanno bisogno. Aggiorna packages/infra/src/stacks/application-stack.ts come segue:

import {
DungeonDb,
GameApi,
GameUI,
InventoryMcpServer,
StoryAgent,
UserIdentity,
suppressRules,
} from '@dungeon-adventure/common-constructs';
import { Stack, StackProps, CfnOutput, RemovalPolicy } from 'aws-cdk-lib';
import { TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
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,
encryption: TableEncryption.DEFAULT,
});
suppressRules(
dungeonDb.table,
['CKV_AWS_119'],
'Sandbox stack uses the AWS owned key so it can be torn down and recreated freely',
);
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');
}
}

Non è necessario distribuire su AWS per provare la nostra API — il target dev esegue l’API del gioco contro DynamoDB Local. Poiché abbiamo collegato l’API del gioco al progetto DungeonDb nel Modulo 1, questo target avvia anche DynamoDB Local automaticamente.

Prima, correggi eventuali problemi di lint:

Terminal window
pnpm lint

Quindi compila il codebase:

Terminal window
pnpm build

Avvia l’API del gioco localmente con il target dev, che avvia anche DynamoDB Local:

Terminal window
pnpm nx dev game-api

Una volta che il server è attivo e funzionante, interroga l’elenco (vuoto) dei giochi:

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

Vedrai un elenco vuoto:

{"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 è 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 Inventory localmente

Sezione intitolata “Task 5: Testare il server MCP Inventory localmente”

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

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 preconfigurato per connettersi ad esso. Clicca su Connect, passa alla scheda Tools, clicca 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.

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