Skip to content

Game APIとInventory MCPサーバーの実装

このセクションでは以下のAPIを実装します:

  1. saveGame - ゲームの作成または更新
  2. queryGames - 保存済みゲームのページネーション付きリストを返す
  3. queryInventory - プレイヤーのインベントリ内アイテムのページネーション付きリストを返す
  4. queryActions - 指定されたゲームの会話履歴を返す

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

このプロジェクトで使用しないため、packages/game-api/src/schema/echo.tsファイルを削除してください。

これがアプリケーションのER図です。

Diagram

ts#dynamodbジェネレーターはElectroDBをセットアップしており、これを使用してデータをモデリングします。会話履歴はS3に保存するため、S3クライアントへの依存関係を追加します:

Terminal window
pnpm add -w @aws-sdk/client-s3@3.1085.0

packages/dungeon-db/src/entities/index.tsの生成されたサンプルエンティティをGameInventoryエンティティに置き換え、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;
});

このプロジェクトで使用しないため、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;

タスク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;
};

ツール数が増えた場合、必要に応じて別ファイルにリファクタリングできます。

packages/inventory/src/mcp-server内のtoolsresourcesディレクトリは使用しないため削除してください。

タスク3: インフラストラクチャの更新

Section titled “タスク3: インフラストラクチャの更新”

ts#dynamodbジェネレーターで生成されたDungeonDbコンストラクトは既にテーブルをプロビジョニングしているため、スタックでインスタンス化し、Game APIとInventory MCPサーバーに必要な権限を付与するだけです。packages/infra/src/stacks/application-stack.tsを以下のように更新します:

import {
DungeonDb,
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';
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');
// Sandbox-friendly: allow the table to be deleted with the stack.
const dungeonDb = new DungeonDb(this, 'DungeonDb', {
deletionProtection: false,
removalPolicy: RemovalPolicy.DESTROY,
});
// 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(),
});
dungeonDb.grantReadData(gameApi.integrations['games.query'].handler);
dungeonDb.grantReadData(gameApi.integrations['inventory.query'].handler);
dungeonDb.grantReadWriteData(gameApi.integrations['games.save'].handler);
storySessions.grantRead(gameApi.integrations['actions.query'].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,
});
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: Game APIをローカルでテスト

Section titled “タスク4: Game APIをローカルでテスト”

AWSにデプロイせずにAPIを試すことができます。devターゲットはDynamoDB Localに対してGame APIを実行します。モジュール1でGame APIをDungeonDbプロジェクトに接続したため、このターゲットはDynamoDB Localも自動的に起動します。

まず、リンターの問題を修正します:

Terminal window
pnpm lint

次にコードベースをビルドします:

Terminal window
pnpm build

devターゲットでGame APIをローカルで起動します。これによりDynamoDB Localも起動されます:

Terminal window
pnpm nx dev game-api

サーバーが起動したら、(空の)ゲームリストをクエリします:

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

空のリストが表示されます:

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

次にゲームを保存します:

Terminal window
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に永続化されていることを確認します:

Terminal window
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サーバーをローカルでテスト

Section titled “タスク5: Inventory MCPサーバーをローカルでテスト”

生成されたmcp-server-inspectターゲットを使用して、MCP InspectorでMCPサーバーのツールを試すことができます:

Terminal window
pnpm nx mcp-server-inspect inventory

これによりMCPサーバーがローカルで提供され(DynamoDB Localも起動されます)、MCP Inspectorがhttp://localhost:6274で起動し、接続するように事前設定されます。Connectをクリックし、Toolsタブに切り替え、List Toolsをクリックし、add-to-inventory(例: playerName: AliceitemName: Rusty Swordemoji: ⚔️)を試した後、list-inventory-itemsでDynamoDB Localに永続化されていることを確認します。完了したらサーバーを停止します(Ctrl+C)。

おめでとうございます!ローカルのDynamoDBテーブルに対して、最初のtRPC APIとMCPサーバーの構築とテストに成功しました! 🎉🎉🎉🎉🎉