Game API 및 Inventory MCP 서버 구현
작업 1: Game API 구현
섹션 제목: “작업 1: Game API 구현”이 섹션에서는 다음 API를 구현합니다:
saveGame- 게임을 생성하거나 업데이트합니다.queryGames- 이전에 저장된 게임의 페이지네이션된 목록을 반환합니다.queryInventory- 플레이어 인벤토리의 아이템 페이지네이션된 목록을 반환합니다.queryActions- 주어진 게임의 대화 기록을 반환합니다.
API 스키마
섹션 제목: “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-apipackages/dungeon-db/src/entities/index.ts에서 생성된 예제 엔티티를 Game 및 Inventory 엔티티로 교체하고, 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를 사용할 때 모범 사례인 단일 테이블 설계를 따릅니다.
프로시저 정의
섹션 제목: “프로시저 정의”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 }; });이 프로젝트에서 사용하지 않을 것이므로 (packages/game-api/src/procedures에서) echo.ts 파일을 삭제합니다.
라우터 설정
섹션 제목: “라우터 설정”프로시저를 정의한 후 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: Inventory MCP 서버 생성
섹션 제목: “작업 2: Inventory 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: 인프라 업데이트
섹션 제목: “작업 3: 인프라 업데이트”세션 버킷 노출
섹션 제목: “세션 버킷 노출”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 구성이 이미 테이블을 프로비저닝하므로, 스택에서 인스턴스화하고 Game API와 Inventory 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: Game API를 로컬에서 테스트
섹션 제목: “작업 4: Game API를 로컬에서 테스트”API를 시험해 보기 위해 AWS에 배포할 필요가 없습니다. dev 타겟은 DynamoDB Local에 대해 Game API를 실행합니다. 모듈 1에서 Game API를 DungeonDb 프로젝트에 연결했기 때문에 이 타겟은 DynamoDB Local도 자동으로 시작합니다.
먼저 린트 문제를 수정합니다:
pnpm lintyarn lintnpm run lintbun lint그런 다음 코드베이스를 빌드합니다:
pnpm buildyarn buildnpm run buildbun build로컬 서버 시작
섹션 제목: “로컬 서버 시작”dev 타겟으로 Game API를 로컬에서 시작하면 DynamoDB Local도 부팅됩니다:
pnpm nx dev game-apiyarn nx dev game-apinpx nx dev game-apibunx nx dev game-apiAPI 테스트
섹션 제목: “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: Inventory MCP 서버를 로컬에서 테스트
섹션 제목: “작업 5: Inventory MCP 서버를 로컬에서 테스트”생성된 mcp-server-inspect 타겟을 사용하여 MCP Inspector로 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 서버를 구축하고 테스트했습니다! 🎉🎉🎉