게임 API 및 인벤토리 MCP 서버 구현
작업 1: Game API 구현
섹션 제목: “작업 1: Game API 구현”이 섹션에서는 다음 API를 구현합니다:
saveGame- 게임 생성 또는 업데이트queryGames- 저장된 게임 목록을 페이지네이션으로 반환queryInventory- 플레이어 인벤토리 아이템 목록을 페이지네이션으로 반환queryActions- 특정 게임의 대화 기록 반환
API 스키마
섹션 제목: “API 스키마”Zod를 사용하여 API 입력/출력 스키마를 packages/game-api/src/schema/index.ts 파일에 다음과 같이 정의합니다:
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 다이어그램은 다음과 같습니다.
DynamoDB에 데이터베이스를 구현할 것이며, ElectroDB DynamoDB 클라이언트 라이브러리를 사용하여 작업을 단순화합니다. Game API의 queryActions는 에이전트가 작성한 대화 턴을 읽기 위해 S3 클라이언트도 필요합니다. 다음 명령어로 모두 설치합니다:
pnpm add -w electrodb@3.7.5 @aws-sdk/client-dynamodb@3.1053.0 @aws-sdk/client-s3@3.1053.0yarn add electrodb@3.7.5 @aws-sdk/client-dynamodb@3.1053.0 @aws-sdk/client-s3@3.1053.0npm install --legacy-peer-deps electrodb@3.7.5 @aws-sdk/client-dynamodb@3.1053.0 @aws-sdk/client-s3@3.1053.0bun install electrodb@3.7.5 @aws-sdk/client-dynamodb@3.1053.0 @aws-sdk/client-s3@3.1053.0ER 다이어그램에서 ElectroDB 엔티티를 정의하기 위해 packages/game-api/src/entities/index.ts 파일을 생성합니다:
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';import { getAppConfig } from '@aws-lambda-powertools/parameters/appconfig';
/** * Resolves the DynamoDB table name from runtime config (AppConfig). */const resolveTableName = async (): Promise<string> => { const tablesConfig = await getAppConfig('tables', { application: process.env.RUNTIME_CONFIG_APP_ID!, environment: 'default', transform: 'json', });
const tableName = (tablesConfig as Record<string, any>).ElectroDbTable?.tableName; if (!tableName) { throw new Error( 'Could not resolve table name from runtime config', ); } return tableName;};
export const createGameEntity = async (client: DynamoDBClient = new DynamoDBClient()) => new Entity( { model: { entity: 'Game', version: '1', service: 'game', }, attributes: { playerName: { type: 'string', required: true, readOnly: true }, genre: { type: 'string', required: true, readOnly: true }, lastUpdated: { type: 'string', required: true, default: () => new Date().toISOString(), }, }, indexes: { primary: { pk: { field: 'pk', composite: ['playerName'] }, sk: { field: 'sk', composite: [], }, }, }, }, { client, table: await resolveTableName() }, );
export const createInventoryEntity = async (client: DynamoDBClient = new DynamoDBClient()) => new Entity( { model: { entity: 'Inventory', version: '1', service: 'game', }, attributes: { playerName: { type: 'string', required: true, readOnly: true }, lastUpdated: { type: 'string', required: true, default: () => new Date().toISOString(), }, itemName: { type: 'string', required: true, }, emoji: { type: 'string', required: false, }, quantity: { type: 'number', required: true, }, }, indexes: { primary: { pk: { field: 'pk', composite: ['playerName'] }, sk: { field: 'sk', composite: ['itemName'] }, }, }, }, { client, table: await resolveTableName() }, );ElectroDB를 사용하면 타입 정의뿐만 아니라 타임스탬프와 같은 특정 값에 대한 기본값을 제공할 수 있습니다. 또한 ElectroDB는 DynamoDB 사용 시 권장되는 단일 테이블 디자인을 따릅니다.
MCP 서버가 인벤토리와 상호작용할 수 있도록 packages/game-api/src/index.ts에서 인벤토리 엔티티를 내보내야 합니다:
export type { AppRouter } from './router.js';export { appRouter } from './router.js';export type { Context } from './init.js';export * from './client/index.js';export * from './schema/index.js';export * from './entities/index.js';프로시저 정의
섹션 제목: “프로시저 정의”API 메서드를 구현하기 위해 packages/game-api/src/procedures 내에서 다음 변경사항을 적용합니다:
import { createGameEntity } from '../entities/index.js';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 '../entities/index.js';
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 buckets = await getAppConfig('buckets', { application: process.env.RUNTIME_CONFIG_APP_ID!, environment: 'default', transform: 'json', }); const bucket = (buckets as Record<string, any>).StorySessions?.bucketName; if (!bucket) throw new Error('StorySessions bucket not found in runtime config'); return bucket;};
const s3 = new S3Client();const LOCAL_SESSION_STORAGE_DIR = '/tmp/strands-sessions';
// 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.SERVE_LOCAL === '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 서버 생성
섹션 제목: “작업 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/game-api';
/** * 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/game-api';
/** * 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: 인프라 업데이트”마지막 단계로 DynamoDB 테이블을 생성하고 Game API에서 작업 권한을 부여하기 위해 인프라를 업데이트합니다.
이를 위해 packages/infra/src를 다음과 같이 수정합니다:
import { CfnOutput } from 'aws-cdk-lib';import { AttributeType, BillingMode, ProjectionType, Table, TableProps,} from 'aws-cdk-lib/aws-dynamodb';import { Construct } from 'constructs';import { suppressRules } from ':dungeon-adventure/common-constructs';
export type ElectrodbDynamoTableProps = Omit< TableProps, 'partitionKey' | 'sortKey' | 'billingMode'>;
export class ElectrodbDynamoTable extends Table { constructor(scope: Construct, id: string, props?: ElectrodbDynamoTableProps) { super(scope, id, { partitionKey: { name: 'pk', type: AttributeType.STRING, }, sortKey: { name: 'sk', type: AttributeType.STRING, }, billingMode: BillingMode.PAY_PER_REQUEST, ...props, });
this.addGlobalSecondaryIndex({ indexName: 'gsi1pk-gsi1sk-index', partitionKey: { name: 'gsi1pk', type: AttributeType.STRING, }, sortKey: { name: 'gsi1sk', type: AttributeType.STRING, }, projectionType: ProjectionType.ALL, });
// Suppress checkov rules that expect a KMS customer managed key and backup to be enabled suppressRules(this, ['CKV_AWS_119', 'CKV_AWS_28'], 'No need for custom encryption or backup');
new CfnOutput(this, 'TableName', { value: this.tableName }); }}import { GameApi, GameUI, InventoryMcpServer, RuntimeConfig, StoryAgent, UserIdentity, suppressRules,} from ':dungeon-adventure/common-constructs';import { Stack, StackProps, CfnOutput, RemovalPolicy } from 'aws-cdk-lib';import { BlockPublicAccess, Bucket, BucketEncryption,} from 'aws-cdk-lib/aws-s3';import { Construct } from 'constructs';import { ElectrodbDynamoTable } from '../constructs/electrodb-table.js';
export class ApplicationStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props);
const rc = RuntimeConfig.ensure(this);
const userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
// Register the table name in runtime config 'tables' namespace rc.set('tables', 'ElectroDbTable', { tableName: electroDbTable.tableName, });
// S3 bucket for Strands conversation history. The Story Agent writes each // turn via ``S3SessionManager``; the Game API reads them back for replay. const storySessions = new Bucket(this, 'StorySessions', { encryption: BucketEncryption.S3_MANAGED, blockPublicAccess: BlockPublicAccess.BLOCK_ALL, enforceSSL: true, removalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: true, }); suppressRules( storySessions, ['CKV_AWS_18', 'CKV_AWS_21'], 'Access logging and object versioning are unnecessary for ephemeral chat transcripts', ); rc.set('buckets', 'StorySessions', { bucketName: storySessions.bucketName, });
const gameApi = new GameApi(this, 'GameApi', { integrations: GameApi.defaultIntegrations(this).build(), });
electroDbTable.grantReadData(gameApi.integrations['games.query'].handler); electroDbTable.grantReadData( gameApi.integrations['inventory.query'].handler, ); electroDbTable.grantReadWriteData( gameApi.integrations['games.save'].handler, ); storySessions.grantRead(gameApi.integrations['actions.query'].handler);
const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer'); electroDbTable.grantReadWriteData(mcpServer.agentCoreRuntime);
// Use Cognito for user authentication with the agent const storyAgent = new StoryAgent(this, 'StoryAgent', { identity: userIdentity, }); storySessions.grantReadWrite(storyAgent);
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 { GameApi, GameUI, InventoryMcpServer, RuntimeConfig, 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 { BlockPublicAccess, Bucket, BucketEncryption,} from 'aws-cdk-lib/aws-s3';import { Construct } from 'constructs';import { ElectrodbDynamoTable } from '../constructs/electrodb-table.js';
export class ApplicationStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props);
const rc = RuntimeConfig.ensure(this);
const userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
// Register the table name in runtime config 'tables' namespace rc.set('tables', 'ElectroDbTable', { tableName: electroDbTable.tableName, });
// S3 bucket for Strands conversation history. The Story Agent writes each // turn via ``S3SessionManager``; the Game API reads them back for replay. const storySessions = new Bucket(this, 'StorySessions', { encryption: BucketEncryption.S3_MANAGED, blockPublicAccess: BlockPublicAccess.BLOCK_ALL, enforceSSL: true, removalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: true, }); suppressRules( storySessions, ['CKV_AWS_18', 'CKV_AWS_21'], 'Access logging and object versioning are unnecessary for ephemeral chat transcripts', ); rc.set('buckets', 'StorySessions', { bucketName: storySessions.bucketName, });
const gameApi = new GameApi(this, 'GameApi', { integrations: GameApi.defaultIntegrations(this).build(), });
electroDbTable.grantReadData(gameApi.integrations['games.query'].handler); electroDbTable.grantReadData( gameApi.integrations['inventory.query'].handler, ); electroDbTable.grantReadWriteData( gameApi.integrations['games.save'].handler, ); storySessions.grantRead(gameApi.integrations['actions.query'].handler);
const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer'); electroDbTable.grantReadWriteData(mcpServer.agentCoreRuntime);
// Use Cognito for user authentication with the agent const storyAgent = new StoryAgent(this, 'StoryAgent', { identity: userIdentity, }); storySessions.grantReadWrite(storyAgent);
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: 배포 및 테스트
섹션 제목: “작업 4: 배포 및 테스트”먼저 린트 문제를 수정합니다:
pnpm lintyarn lintnpm run lintbun lint그런 다음 코드베이스를 빌드합니다:
pnpm buildyarn buildnpm run buildbun build애플리케이션 배포
섹션 제목: “애플리케이션 배포”애플리케이션을 배포하려면 다음 명령어를 실행합니다:
pnpm nx deploy infra "dungeon-adventure-infra-sandbox/*"yarn nx deploy infra "dungeon-adventure-infra-sandbox/*"npx nx deploy infra "dungeon-adventure-infra-sandbox/*"bunx nx deploy infra "dungeon-adventure-infra-sandbox/*"첫 배포는 약 8분 정도 소요됩니다. 이후 배포는 약 2분 정도 걸립니다.
배포가 완료되면 다음과 유사한 출력을 확인할 수 있습니다 (일부 값은 편집됨):
dungeon-adventure-infra-sandbox-Applicationdungeon-adventure-infra-sandbox-Application: deploying... [2/2]
✅ dungeon-adventure-infra-sandbox-Application
✨ Deployment time: 354s
Outputs:dungeon-adventure-infra-sandbox-Application.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYYdungeon-adventure-infra-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-infra-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-infra-sandbox-Application.InventoryMcpArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventureventoryMcpServerXXXX-YYYYdungeon-adventure-infra-sandbox-Application.RuntimeConfigApplicationId = xxxxdungeon-adventure-infra-sandbox-Application.StoryAgentArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventurecationStoryAgentXXXX-YYYYdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolClientIdXXX = xxxxxxxxxxdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxxAPI 테스트
섹션 제목: “API 테스트”다음 방법으로 API를 테스트할 수 있습니다:
- tRPC 백엔드 로컬 인스턴스를 시작하고
curl로 API 호출 - 배포된 API를 Sigv4 활성화된 curl로 호출
Sigv4 활성화된 curl
다음 스크립트를
.bashrc파일에 추가하고source하거나, 명령을 실행하려는 터미널에 직접 붙여넣을 수 있습니다.~/.bashrc acurl () {REGION=$1SERVICE=$2shift; shift;curl --aws-sigv4 "aws:amz:$REGION:$SERVICE" --user "$(aws configure get aws_access_key_id):$(aws configure get aws_secret_access_key)" -H "X-Amz-Security-Token: $(aws configure get aws_session_token)" "$@"}sigv4인증된 curl 요청을 만들려면 다음과 같이acurl을 호출하세요:Terminal window acurl <region> <service> <other-curl-arguments>예시:
API Gateway
섹션 제목: “API Gateway”Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxStreaming Lambda function url
섹션 제목: “Streaming Lambda function url”Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxx다음 함수를 PowerShell 프로필에 추가하거나, 명령을 실행하려는 PowerShell 세션에 직접 붙여넣을 수 있습니다.
Terminal window # PowerShell profile or current sessionfunction acurl {param([Parameter(Mandatory=$true)][string]$Region,[Parameter(Mandatory=$true)][string]$Service,[Parameter(ValueFromRemainingArguments=$true)][string[]]$CurlArgs)$AccessKey = aws configure get aws_access_key_id$SecretKey = aws configure get aws_secret_access_key$SessionToken = aws configure get aws_session_token& curl --aws-sigv4 "aws:amz:$Region`:$Service" --user "$AccessKey`:$SecretKey" -H "X-Amz-Security-Token: $SessionToken" @CurlArgs}sigv4인증된 curl 요청을 만들려면 다음 예시를 사용하여acurl을 호출하세요:API Gateway
섹션 제목: “API Gateway”Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxStreaming Lambda function url
섹션 제목: “Streaming Lambda function url”Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxx
로컬 game-api 서버를 시작하려면 다음 명령어를 실행합니다:
RUNTIME_CONFIG_APP_ID=xxxx pnpm nx serve game-apiRUNTIME_CONFIG_APP_ID=xxxx yarn nx serve game-apiRUNTIME_CONFIG_APP_ID=xxxx npx nx serve game-apiRUNTIME_CONFIG_APP_ID=xxxx bunx nx serve game-api서버가 실행되면 다음 명령어로 호출할 수 있습니다:
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'acurl ap-southeast-2 execute-api -X GET 'https://xxx.execute-api.ap-southeast-2.amazonaws.com/prod/games.query?input=%7B%7D'명령어가 성공적으로 실행되면 다음과 같은 응답을 확인할 수 있습니다:
{"result":{"data":{"items":[],"cursor":null}}}축하합니다! tRPC를 사용하여 첫 번째 API를 구축하고 배포했습니다! 🎉🎉🎉