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.1126.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 {
AgentCoreRuntime,
AgentRuntimeArtifact,
ProtocolType,
Runtime,
RuntimeAuthorizerConfiguration,
RuntimeProps,
} from 'aws-cdk-lib/aws-bedrockagentcore';
import { IUserPool, IUserPoolClient } from 'aws-cdk-lib/aws-cognito';
import { Connections, IConnectable } from 'aws-cdk-lib/aws-ec2';
import {
Effect,
IGrantable,
IPrincipal,
PolicyStatement,
ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import { Key } from 'aws-cdk-lib/aws-kms';
import {
CfnDelivery,
CfnDeliveryDestination,
CfnDeliverySource,
LogGroup,
RetentionDays,
} from 'aws-cdk-lib/aws-logs';
import {
BlockPublicAccess,
Bucket,
BucketEncryption,
IBucket,
} from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
import * as path from 'path';
import * as url from 'url';
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 code: 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 packaged code directory, uploaded as a zip asset
const bundleDir = path.join(
findWorkspaceRoot(url.fileURLToPath(new URL(import.meta.url))),
'dist/packages/story/package/story-agent',
);
// The `opentelemetry-instrument` prefix auto-instruments with the AWS Distro
// for OpenTelemetry packaged alongside the code.
// https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-configure.html
this.code = AgentRuntimeArtifact.fromCodeAsset({
path: bundleDir,
runtime: AgentCoreRuntime.PYTHON_3_14,
entrypoint: ['opentelemetry-instrument', 'main.py'],
});
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.code,
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.

Le procedure e il server MCP che abbiamo appena scritto importano @dungeon-adventure/dungeon-db, una nuova importazione tra progetti, quindi inizia sincronizzando il workspace:

Terminal window
pnpm nx sync

Quindi correggi eventuali problemi di lint:

Terminal window
pnpm lint

E 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:

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

Vedrai un elenco vuoto:

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

Ora salva un gioco:

Finestra del terminale
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:

Finestra del terminale
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. Attiva l’interruttore accanto al server localhost:8000 per connetterti, passa alla scheda 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! 🎉🎉🎉