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 @aws-sdk/client-s3@3.1116.0 --filter game-api

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: Inventory MCP サーバーの作成

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

ツールの数が増えるにつれて、必要に応じて別々のファイルにリファクタリングできます。

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

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

Section titled “タスク 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');
}
}

タスク 4: Game API のローカルテスト

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

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

まず、lint の問題を修正します:

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

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