콘텐츠로 이동

게임 API 및 인벤토리 MCP 서버 구현

이 섹션에서는 다음 API를 구현합니다:

  1. saveGame - 게임 생성 또는 업데이트
  2. queryGames - 저장된 게임 목록을 페이지네이션으로 반환
  3. queryInventory - 플레이어 인벤토리 아이템 목록을 페이지네이션으로 반환
  4. queryActions - 특정 게임의 대화 기록 반환

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(),
});
};

이 프로젝트에서 사용하지 않을 packages/game-api/src/schema/echo.ts 파일은 삭제합니다.

애플리케이션의 ER 다이어그램은 다음과 같습니다.

Diagram

DynamoDB에 데이터베이스를 구현할 것이며, ElectroDB DynamoDB 클라이언트 라이브러리를 사용하여 작업을 단순화합니다. Game API의 queryActions는 에이전트가 작성한 대화 턴을 읽기 위해 S3 클라이언트도 필요합니다. 다음 명령어로 모두 설치합니다:

Terminal window
pnpm add -w electrodb@3.7.5 @aws-sdk/client-dynamodb@3.1053.0 @aws-sdk/client-s3@3.1053.0

ER 다이어그램에서 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;
});

이 프로젝트에서 사용하지 않을 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;

에이전트가 플레이어 인벤토리 아이템을 관리할 수 있는 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;
};

도구 수가 증가하면 별도 파일로 리팩토링할 수 있습니다.

사용되지 않을 packages/inventory/src/mcp-server 내의 toolsresources 디렉토리를 삭제합니다.

마지막 단계로 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 });
}
}

먼저 린트 문제를 수정합니다:

Terminal window
pnpm lint

그런 다음 코드베이스를 빌드합니다:

Terminal window
pnpm build

애플리케이션을 배포하려면 다음 명령어를 실행합니다:

Terminal window
pnpm nx deploy infra "dungeon-adventure-infra-sandbox/*"

첫 배포는 약 8분 정도 소요됩니다. 이후 배포는 약 2분 정도 걸립니다.

배포가 완료되면 다음과 유사한 출력을 확인할 수 있습니다 (일부 값은 편집됨):

Terminal window
dungeon-adventure-infra-sandbox-Application
dungeon-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-YYY
dungeon-adventure-infra-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-infra-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-infra-sandbox-Application.InventoryMcpArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventureventoryMcpServerXXXX-YYYY
dungeon-adventure-infra-sandbox-Application.RuntimeConfigApplicationId = xxxx
dungeon-adventure-infra-sandbox-Application.StoryAgentArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventurecationStoryAgentXXXX-YYYY
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolClientIdXXX = xxxxxxxxxx
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

다음 방법으로 API를 테스트할 수 있습니다:

  • tRPC 백엔드 로컬 인스턴스를 시작하고 curl로 API 호출
  • 배포된 API를 Sigv4 활성화된 curl로 호출

로컬 game-api 서버를 시작하려면 다음 명령어를 실행합니다:

Terminal window
RUNTIME_CONFIG_APP_ID=xxxx pnpm nx serve game-api

서버가 실행되면 다음 명령어로 호출할 수 있습니다:

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

명령어가 성공적으로 실행되면 다음과 같은 응답을 확인할 수 있습니다:

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

축하합니다! tRPC를 사용하여 첫 번째 API를 구축하고 배포했습니다! 🎉🎉🎉