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. saveAction - lưu một hành động cho một trò chơi nhất định.
  4. queryActions - trả về danh sách phân trang tất cả các hành động liên quan đến một trò chơi.
  5. queryInventory - trả về danh sách phân trang các vật phẩm trong kho của người chơi.

Để định nghĩa đầu vào và đầu ra của API, hãy tạo schema của chúng ta bằ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({
playerName: z.string(),
timestamp: z.iso.datetime(),
role: z.enum(['assistant', 'user']),
content: z.string(),
});
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.

dungeon-adventure-er.png

Chúng ta sẽ triển khai cơ sở dữ liệu trong DynamoDB và sẽ sử dụng thư viện client DynamoDB ElectroDB để đơn giản hóa mọi thứ. Để cài đặt electrodb và DynamoDB Client, chạy lệnh này:

Terminal window
pnpm add -w electrodb@3.5.0 @aws-sdk/client-dynamodb@3.922.0

Để định nghĩa các thực thể ElectroDB từ Sơ đồ ER, hãy tạo file packages/game-api/src/entities/index.ts:

import { Entity } from 'electrodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createActionEntity = (client: DynamoDBClient = new DynamoDBClient()) =>
new Entity(
{
model: {
entity: 'Action',
version: '1',
service: 'game',
},
attributes: {
playerName: { type: 'string', required: true, readOnly: true },
timestamp: {
type: 'string',
required: true,
readOnly: true,
set: () => new Date().toISOString(),
default: () => new Date().toISOString(),
},
role: { type: 'string', required: true, readOnly: true },
content: { type: 'string', required: true, readOnly: true },
},
indexes: {
primary: {
pk: { field: 'pk', composite: ['playerName'] },
sk: { field: 'sk', composite: ['timestamp'] },
},
},
},
{ client, table: process.env.TABLE_NAME },
);
export const createGameEntity = (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: process.env.TABLE_NAME },
);
export const createInventoryEntity = (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: process.env.TABLE_NAME },
);

ElectroDB cho phép chúng ta không chỉ định nghĩa các kiểu của mình mà còn có thể cung cấp giá trị mặc định cho các giá trị nhất định như timestamps. Ngoài ra, ElectroDB tuân theo single-table design là best practice khi sử dụng DynamoDB.

Để chuẩn bị cho MCP server tương tác với inventory, hãy đảm bảo chúng ta xuất inventory entity trong 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';

Để 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 { createActionEntity, createGameEntity } from '../entities/index.js';
import {
ActionSchema,
IAction,
QueryInputSchema,
createPaginatedQueryOutput,
} from '../schema/index.js';
import { publicProcedure } from '../init.js';
import { z } from 'zod';
export const queryActions = publicProcedure
.input(QueryInputSchema.extend({ playerName: z.string() }))
.output(createPaginatedQueryOutput(ActionSchema))
.query(async ({ input }) => {
const actionEntity = createActionEntity();
const result = await actionEntity.query
.primary({ playerName: input.playerName })
.go({ cursor: input.cursor, count: input.limit });
return {
items: result.data as IAction[],
cursor: result.cursor,
};
});
export const saveAction = publicProcedure
.input(ActionSchema.omit({ timestamp: true }))
.output(ActionSchema)
.mutation(async ({ input }) => {
const actionEntity = createActionEntity();
const gameEntity = createGameEntity();
const action = await actionEntity.put(input).go();
await gameEntity
.update({ playerName: input.playerName })
.set({ lastUpdated: action.data.timestamp })
.go();
return action.data as IAction;
});

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 chúng ta định nghĩa các procedures, để kết nối chúng vào API của chúng ta, cập nhật file sau:

import {
awsLambdaRequestHandler,
CreateAWSLambdaContextOptions,
} from '@trpc/server/adapters/aws-lambda';
import { t } from './init.js';
import { APIGatewayProxyEvent } from 'aws-lambda';
import { queryActions, saveAction } 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,
save: saveAction,
}),
games: router({
query: queryGames,
save: saveGame,
}),
inventory: router({
query: queryInventory,
}),
});
export const handler = awsLambdaRequestHandler({
router: appRouter,
createContext: (
ctx: CreateAWSLambdaContextOptions<APIGatewayProxyEvent>,
) => ctx,
responseMeta: () => ({
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
},
}),
});
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 của chúng ta:

  • list-inventory-items để lấy các vật phẩm kho hiện tại 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-v3';
import { createInventoryEntity } from ':dungeon-adventure/game-api';
/**
* Create the MCP Server
*/
export const createServer = () => {
const server = new McpServer({
name: 'inventory-mcp-server',
version: '1.0.0',
});
const inventory = createInventoryEntity();
server.tool(
'list-inventory-items',
"List items in the player's inventory. Leave cursor blank unless you are requesting subsequent pages",
{
playerName: z.string(),
cursor: z.string().optional(),
},
async ({ playerName }) => {
const results = await inventory.query
.primary({
playerName,
})
.go();
return {
content: [{ type: 'text', text: JSON.stringify(results) }],
};
},
);
server.tool(
'add-to-inventory',
"Add an item to the player's inventory. Quantity defaults to 1 if omitted.",
{
playerName: z.string(),
itemName: z.string(),
emoji: z.string(),
quantity: z.number().optional().default(1),
},
async ({ playerName, itemName, emoji, quantity = 1 }) => {
await inventory
.put({
playerName,
itemName,
quantity,
emoji,
})
.go();
return {
content: [
{
type: 'text',
text: `Added ${itemName} (x${quantity}) to inventory`,
},
],
};
},
);
server.tool(
'remove-from-inventory',
"Remove an item from the player's inventory. If quantity is omitted, all items are removed.",
{
playerName: z.string(),
itemName: z.string(),
quantity: z.number().optional(),
},
async ({ playerName, itemName, quantity }) => {
// If quantity is omitted, remove the entire item
if (quantity === undefined) {
try {
await inventory.delete({ playerName, itemName }).go();
return {
content: [
{ type: 'text', text: `${itemName} removed from inventory.` },
],
} as const;
} catch {
return {
content: [
{ type: 'text', text: `${itemName} not found in inventory` },
],
} as const;
}
}
// If quantity is specified, fetch current quantity and update
const item = await inventory.get({ playerName, itemName }).go();
if (!item.data) {
return {
content: [
{ type: 'text', text: `${itemName} not found in inventory` },
],
} as const;
}
const newQuantity = item.data.quantity - quantity;
if (newQuantity <= 0) {
await inventory.delete({ playerName, itemName }).go();
return {
content: [
{ type: 'text', text: `${itemName} removed from inventory.` },
],
} as const;
}
await inventory
.put({
playerName,
itemName,
quantity: newQuantity,
emoji: item.data.emoji,
})
.go();
return {
content: [
{
type: 'text',
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ể tái cấu trúc chúng 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.

Bước cuối cùng là cập nhật cơ sở hạ tầng của chúng ta để tạo bảng DynamoDB và cấp quyền thực hiện các thao tác từ Game API. Để làm điều này, cập nhật packages/infra/src như sau:

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

Đầu tiên, sửa mọi vấn đề lint:

Terminal window
pnpm nx run-many --target lint --configuration=fix --all

Sau đó build codebase:

Terminal window
pnpm nx run-many --target build --all

Để triển khai ứng dụng của bạn, chạy lệnh sau:

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

Triển khai đầu tiên của bạn sẽ mất khoảng 8 phút để hoàn thành. Các lần triển khai tiếp theo sẽ mất khoảng 2 phút.

Khi triển khai hoàn tất, bạn sẽ thấy các outputs tương tự như sau (một số giá trị đã được che đi):

Terminal window
dungeon-adventure-sandbox-Application
dungeon-adventure-sandbox-Application: deploying... [2/2]
dungeon-adventure-sandbox-Application
Deployment time: 354s
Outputs:
dungeon-adventure-sandbox-Application.ElectroDbTableTableNameXXX = dungeon-adventure-sandbox-Application-ElectroDbTableXXX-YYY
dungeon-adventure-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-sandbox-Application.StoryApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Bạn có thể kiểm thử API bằng cách:

  • Khởi động một instance local của tRPC backend và gọi các API bằng curl.
  • Gọi API đã triển khai bằng curl hỗ trợ sigv4

Để khởi động server game-api local của bạn, chạy lệnh sau:

Terminal window
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api:serve

Khi server của bạn đã chạy, bạn có thể gọi nó bằng cách chạy lệnh sau:

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

Nếu lệnh chạy thành công, bạn sẽ thấy phản hồi như sau:

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

Chúc mừng, bạn đã xây dựng và triển khai API đầu tiên của mình bằng tRPC! 🎉🎉🎉