实现游戏 API 和库存 MCP 服务器
任务 1:实现游戏 API
Section titled “任务 1:实现游戏 API”我们将在本节中实现以下 API:
saveGame- 创建或更新游戏。queryGames- 返回先前保存的游戏的分页列表。queryInventory- 返回玩家库存中物品的分页列表。queryActions- 返回给定游戏的对话历史记录。
API 模式
Section titled “API 模式”为了定义我们的 API 输入和输出,让我们在 packages/game-api/src/schema/index.ts 文件中使用 Zod 创建我们的模式,如下所示:
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(), });};export * from './echo.js'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(), });};删除 packages/game-api/src/schema/echo.ts 文件,因为我们在此项目中不会使用它。
这是我们应用程序的 ER 图。
ts#dynamodb 生成器设置了 ElectroDB,我们将使用它来建模我们的数据。我们将在 S3 中持久化对话历史记录,因此我们添加对 S3 客户端的依赖:
pnpm add @aws-sdk/client-s3@3.1116.0 --filter game-apiyarn workspace @dungeon-adventure/game-api add @aws-sdk/client-s3@3.1116.0npm install --legacy-peer-deps @aws-sdk/client-s3@3.1116.0 -w packages/game-apibun add @aws-sdk/client-s3@3.1116.0 --cwd packages/game-api用我们的 Game 和 Inventory 实体替换 packages/dungeon-db/src/entities/index.ts 中生成的示例实体,并删除 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 不仅允许我们定义类型,还可以为某些值(如时间戳)提供默认值。此外,ElectroDB 遵循单表设计,这是使用 DynamoDB 时的最佳实践。
定义我们的过程
Section titled “定义我们的过程”要实现 API 方法,请在 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; });import { ItemSchema, QueryInputSchema, createPaginatedQueryOutput,} from '../schema/index.js';import { publicProcedure } from '../init.js';import { z } from 'zod';import { createInventoryEntity } from '@dungeon-adventure/dungeon-db';
export const queryInventory = publicProcedure .input(QueryInputSchema.extend({ playerName: z.string() })) .output(createPaginatedQueryOutput(ItemSchema)) .query(async ({ input }) => { const inventoryEntity = await createInventoryEntity(); const result = await inventoryEntity.query .primary({ playerName: input.playerName }) .go({ cursor: input.cursor, count: input.limit });
return { items: result.data, cursor: result.cursor, }; });import { publicProcedure } from '../init.js';import { ActionSchema, IAction } from '../schema/index.js';import { z } from 'zod';import { S3Client, ListObjectsV2Command, GetObjectCommand,} from '@aws-sdk/client-s3';import { getAppConfig } from '@aws-lambda-powertools/parameters/appconfig';import { readFile, readdir } from 'node:fs/promises';import { join, resolve } from 'node:path';
const resolveSessionsBucket = async (): Promise<string> => { const agentcore = await getAppConfig('agentcore', { application: process.env.RUNTIME_CONFIG_APP_ID!, environment: 'default', transform: 'json', }); const bucket = (agentcore as Record<string, any>).agentRuntimes?.StoryAgent ?.session?.bucketName; if (!bucket) throw new Error( 'StoryAgent session bucket not found in runtime config', ); return bucket;};
const s3 = new S3Client();const LOCAL_SESSION_STORAGE_DIR = '../../tmp/agents/strands/story-agent';
// Matches ``session_<sessionId>/agents/agent_<agentId>/messages/message_<idx>.json``// written by ``strands.session.S3SessionManager`` on the agent side. We only// ever care about the default agent id ``default`` that Strands uses when// none is set explicitly, so hard-code the path prefix the UI needs to list.const messagesPrefix = (sessionId: string) => `session_${sessionId}/agents/agent_default/messages/`;
const messageIndex = (pathOrKey: string): number => { const match = pathOrKey.match(/message_(\d+)\.json$/); return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;};
const toAction = (body: any): IAction | undefined => { const message = body.redact_message ?? body.message; const role = message?.role; if (role !== 'user' && role !== 'assistant') return undefined; const text = Array.isArray(message.content) ? message.content .filter((b: any) => typeof b?.text === 'string') .map((b: any) => b.text) .join('') : String(message.content ?? ''); if (!text) return undefined; return { role, content: text, messageId: body.message_id };};
const readLocalActions = async (sessionId: string): Promise<IAction[]> => { const baseDir = resolve(LOCAL_SESSION_STORAGE_DIR); const messagesDir = resolve(baseDir, messagesPrefix(sessionId)); if (!messagesDir.startsWith(`${baseDir}/`)) { throw new Error('Invalid session id'); }
let files: string[]; try { files = await readdir(messagesDir); } catch (error: any) { if (error?.code === 'ENOENT') return []; throw error; }
const actions: IAction[] = []; for (const file of files .filter((f) => f.endsWith('.json')) .sort((a, b) => messageIndex(a) - messageIndex(b))) { const body = JSON.parse(await readFile(join(messagesDir, file), 'utf8')); const action = toAction(body); if (action) actions.push(action); } return actions;};
const readS3Actions = async (sessionId: string): Promise<IAction[]> => { const bucket = await resolveSessionsBucket(); const list = await s3.send( new ListObjectsV2Command({ Bucket: bucket, Prefix: messagesPrefix(sessionId), }), ); const keys = (list.Contents ?? []) .map((o) => o.Key!) .filter((k) => k.endsWith('.json')) .sort((a, b) => messageIndex(a) - messageIndex(b));
const actions: IAction[] = []; for (const key of keys) { const obj = await s3.send( new GetObjectCommand({ Bucket: bucket, Key: key }), ); const body = JSON.parse(await obj.Body!.transformToString()); const action = toAction(body); if (action) actions.push(action); } return actions;};
export const queryActions = publicProcedure .input(z.object({ sessionId: z.string() })) .output(z.object({ items: z.array(ActionSchema) })) .query(async ({ input }) => { const actions = process.env.LOCAL_DEV === 'true' ? await readLocalActions(input.sessionId) : await readS3Actions(input.sessionId); return { items: actions }; });删除 echo.ts 文件(从 packages/game-api/src/procedures),因为我们在此项目中不会使用它。
在我们定义过程之后,要将它们连接到我们的 API,请更新以下文件:
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;import { echo } from './procedures/echo.js';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({ echo, actions: router({ query: queryActions, }), games: router({ query: queryGames, save: saveGame, }), inventory: router({ query: queryInventory, }),});
export type AppRouter = typeof appRouter;任务 2:创建库存 MCP 服务器
Section titled “任务 2:创建库存 MCP 服务器”让我们创建一个 MCP 服务器,它将允许我们的代理管理玩家库存中的物品。
我们将为我们的代理定义以下工具:
list-inventory-items用于检索玩家当前的库存物品add-to-inventory用于向玩家的库存添加物品remove-from-inventory用于从玩家的库存中移除物品
为了节省时间,我们将内联定义所有工具:
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;};import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import { registerDivideTool } from './tools/divide.js';import { registerSampleGuidanceResource } from './resources/sample-guidance.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', });
registerDivideTool(server); registerSampleGuidanceResource(server); 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;};随着工具数量的增长,如果您愿意,可以将它们重构到单独的文件中。
删除 packages/inventory/src/mcp-server 中的 tools 和 resources 目录,因为这些不会被使用。
任务 3:更新基础设施
Section titled “任务 3:更新基础设施”公开会话存储桶
Section titled “公开会话存储桶”Story Agent 的构造已经在内部配置并写入其自己的会话存储桶,但没有公开它 — 因此存储桶外部的任何内容都无法被授予读取访问权限。由于 queryActions 需要读取对话历史记录,为了说明和简单起见,我们将在 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`; }}ts#dynamodb 生成的 DungeonDb 构造已经配置了我们的表,因此我们只需要在堆栈中实例化它,并授予游戏 API 和库存 MCP 服务器所需的权限。按如下方式更新 packages/infra/src/stacks/application-stack.ts:
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'); }}import { DungeonDb, GameApi, GameUI, InventoryMcpServer, StoryAgent, UserIdentity, suppressRules,} from '@dungeon-adventure/common-constructs';import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';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'); }}任务 4:在本地测试游戏 API
Section titled “任务 4:在本地测试游戏 API”无需部署到 AWS 即可试用我们的 API — dev 目标针对 DynamoDB Local 运行游戏 API。因为我们在模块 1 中将游戏 API 连接到 DungeonDb 项目,所以此目标也会自动启动 DynamoDB Local。
首先,修复任何 lint 问题:
pnpm lintyarn lintnpm run lintbun lint然后构建代码库:
pnpm buildyarn buildnpm run buildbun build启动本地服务器
Section titled “启动本地服务器”使用 dev 目标在本地启动游戏 API,这也会启动 DynamoDB Local:
pnpm nx dev game-apiyarn nx dev game-apinpx nx dev game-apibunx nx dev game-api测试 API
Section titled “测试 API”一旦您的服务器启动并运行,查询(空的)游戏列表:
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'您将看到一个空列表:
{"result":{"data":{"items":[],"cursor":null}}}现在保存一个游戏:
curl -X POST 'http://localhost:2022/games.save' \ -H 'Content-Type: application/json' \ -d '{"playerName":"Alice","genre":"zombie"}'保存操作返回持久化的游戏(带有实体为您设置的 lastUpdated 时间戳):
{"result":{"data":{"playerName":"Alice","genre":"zombie","lastUpdated":"..."}}}再次查询以确认它已持久化到 DynamoDB Local:
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'此响应现在包含已保存的游戏:
{"result":{"data":{"items":[{"playerName":"Alice","genre":"zombie","lastUpdated":"..."}],"cursor":null}}}完成后,您可以停止本地服务器(Ctrl+C)。
任务 5:在本地测试库存 MCP 服务器
Section titled “任务 5:在本地测试库存 MCP 服务器”我们可以使用 MCP Inspector 通过生成的 mcp-server-inspect 目标试用 MCP 服务器的工具:
pnpm nx mcp-server-inspect inventoryyarn nx mcp-server-inspect inventorynpx nx mcp-server-inspect inventorybunx nx mcp-server-inspect inventory这会在本地提供 MCP 服务器(也会启动 DynamoDB Local)并在 http://localhost:6274 启动 MCP Inspector,预配置为连接到它。点击 Connect,切换到 Tools 选项卡,点击 List Tools,然后尝试 add-to-inventory(例如 playerName: Alice、itemName: Rusty Sword、emoji: ⚔️),然后使用 list-inventory-items 查看它已持久化到 DynamoDB Local。完成后停止服务器(Ctrl+C)。
恭喜,您已经构建并测试了针对本地 DynamoDB 表的第一个 tRPC API 和 MCP 服务器!🎉🎉🎉