Bỏ qua để đến nội dung

Triển khai Game API và Inventory MCP server

Chúng ta sẽ triển khai các API sau trong phần này:

  1. saveGame - tạo hoặc cập nhật một trò chơi.
  2. queryGames - trả về danh sách phân trang các trò chơi đã lưu trước đó.
  3. queryInventory - trả về danh sách phân trang các vật phẩm trong kho đồ của người chơi.
  4. queryActions - trả về lịch sử hội thoại cho một trò chơi cụ thể.

Để định nghĩa đầu vào và đầu ra của API, hãy tạo schema sử dụng Zod trong file packages/game-api/src/schema/index.ts như sau:

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

Xóa file packages/game-api/src/schema/echo.ts vì chúng ta sẽ không sử dụng nó trong dự án này.

Đây là sơ đồ ER cho ứng dụng của chúng ta.

Diagram

Generator ts#dynamodb đã thiết lập ElectroDB, chúng ta sẽ sử dụng nó để mô hình hóa dữ liệu. Chúng ta sẽ lưu trữ lịch sử hội thoại trong S3, vì vậy chúng ta thêm phụ thuộc vào S3 client:

Terminal window
pnpm add @aws-sdk/client-s3@3.1116.0 --filter game-api

Thay thế thực thể ví dụ được tạo trong packages/dungeon-db/src/entities/index.ts bằng các thực thể GameInventory của chúng ta, và xóa 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 cho phép chúng ta không chỉ định nghĩa các kiểu, mà còn có thể cung cấp giá trị mặc định cho một số giá trị như timestamp. Ngoài ra, ElectroDB tuân theo single-table design là best practice khi sử dụng DynamoDB.

Để triển khai các phương thức API, thực hiện các thay đổi sau trong 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;
});

Xóa file echo.ts (từ packages/game-api/src/procedures) vì chúng ta sẽ không sử dụng nó trong dự án này.

Sau khi định nghĩa các procedure, để kết nối chúng vào API, cập nhật file sau:

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;

Hãy tạo một MCP server cho phép agent của chúng ta quản lý các vật phẩm trong kho đồ của người chơi.

Chúng ta sẽ định nghĩa các công cụ sau cho agent:

  • list-inventory-items để lấy các vật phẩm hiện tại trong kho đồ của người chơi
  • add-to-inventory để thêm vật phẩm vào kho đồ của người chơi
  • remove-from-inventory để xóa vật phẩm khỏi kho đồ của người chơi

Để tiết kiệm thời gian, chúng ta sẽ định nghĩa tất cả các công cụ inline:

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;
};

Khi số lượng công cụ tăng lên, bạn có thể refactor chúng ra thành các file riêng biệt nếu muốn.

Xóa các thư mục toolsresources trong packages/inventory/src/mcp-server vì chúng sẽ không được sử dụng.

Construct của Story Agent đã cung cấp và ghi vào session bucket riêng của nó bên trong, nhưng không expose nó — vì vậy không có gì bên ngoài agent có thể được cấp quyền truy cập để đọc từ nó. Vì queryActions cần đọc lại lịch sử hội thoại, để minh họa và đơn giản, chúng ta sẽ expose session bucket nội bộ của nó như một thuộc tính công khai trên 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`;
}
}

Construct DungeonDb được tạo bởi ts#dynamodb đã cung cấp bảng của chúng ta, vì vậy chúng ta chỉ cần khởi tạo nó trong stack và cấp cho Game API và Inventory MCP server các quyền mà chúng cần. Cập nhật packages/infra/src/stacks/application-stack.ts như sau:

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');
}
}

Không cần triển khai lên AWS để thử nghiệm API của chúng ta — target dev chạy Game API với DynamoDB Local. Vì chúng ta đã kết nối Game API với dự án DungeonDb trong Module 1, target này cũng tự động khởi động DynamoDB Local.

Đầu tiên, sửa các vấn đề lint:

Terminal window
pnpm lint

Sau đó build codebase:

Terminal window
pnpm build

Khởi động Game API cục bộ với target dev, cũng khởi động DynamoDB Local:

Terminal window
pnpm nx dev game-api

Khi server của bạn đã chạy, truy vấn danh sách trò chơi (trống):

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

Bạn sẽ thấy một danh sách trống:

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

Bây giờ lưu một trò chơi:

Terminal window
curl -X POST 'http://localhost:2022/games.save' \
-H 'Content-Type: application/json' \
-d '{"playerName":"Alice","genre":"zombie"}'

Lệnh save trả về trò chơi đã được lưu (với timestamp lastUpdated mà thực thể đặt cho bạn):

{"result":{"data":{"playerName":"Alice","genre":"zombie","lastUpdated":"..."}}}

Truy vấn lại để xác nhận nó đã được lưu trong DynamoDB Local:

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

Phản hồi này bây giờ bao gồm trò chơi đã lưu:

{"result":{"data":{"items":[{"playerName":"Alice","genre":"zombie","lastUpdated":"..."}],"cursor":null}}}

Bạn có thể dừng server cục bộ (Ctrl+C) khi hoàn tất.

Nhiệm vụ 5: Kiểm tra Inventory MCP server cục bộ

Phần tiêu đề “Nhiệm vụ 5: Kiểm tra Inventory MCP server cục bộ”

Chúng ta có thể thử nghiệm các công cụ của MCP server với MCP Inspector sử dụng target mcp-server-inspect đã được tạo:

Terminal window
pnpm nx mcp-server-inspect inventory

Điều này phục vụ MCP server cục bộ (cũng khởi động DynamoDB Local) và khởi chạy MCP Inspector tại http://localhost:6274 được cấu hình sẵn để kết nối với nó. Nhấp Connect, chuyển sang tab Tools, nhấp List Tools, và thử add-to-inventory (ví dụ: playerName: Alice, itemName: Rusty Sword, emoji: ⚔️) theo sau là list-inventory-items để xem nó được lưu vào DynamoDB Local. Dừng server (Ctrl+C) khi hoàn tất.

Chúc mừng, bạn đã xây dựng và kiểm tra tRPC API và MCP server đầu tiên của mình với bảng DynamoDB cục bộ! 🎉🎉🎉