跳转到内容

实现游戏API和库存MCP服务器

我们将在本节中实现以下 API:

  1. saveGame - 创建或更新游戏。
  2. queryGames - 返回分页的历史游戏列表。
  3. queryInventory - 返回分页的玩家库存物品列表。
  4. queryActions - 返回给定游戏的对话历史。

使用 Zodpackages/game-api/src/schema/index.ts 文件中定义 API 输入输出模式:

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 文件,因为本项目不会使用它。

这是应用的实体关系图。

Diagram

ts#dynamodb 生成器设置了 ElectroDB,我们将使用它来建模数据。我们将对话历史持久化到 S3,因此添加 S3 客户端依赖:

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

用我们的 GameInventory 实体替换 packages/dungeon-db/src/entities/index.ts 中生成的示例实体,并删除 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;
});

删除 packages/game-api/src/procedures 中的 echo.ts 文件,因为本项目不会使用。

完成操作流程定义后,要将其接入 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/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 目录,因为这些不会被使用。

ts#dynamodb 生成器生成的 DungeonDb 构造已经配置了我们的表,因此我们只需要在堆栈中实例化它并授予游戏 API 和库存 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');
}
}

无需部署到 AWS 即可试用我们的 API——dev 目标针对 DynamoDB Local 运行游戏 API。因为我们在模块 1 中将游戏 API 连接到了 DungeonDb 项目,此目标也会自动启动 DynamoDB Local。

首先修复 lint 问题:

Terminal window
pnpm lint

然后构建代码库:

Terminal window
pnpm build

使用 dev 目标在本地启动游戏 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:本地测试库存 MCP 服务器

Section titled “任务 5:本地测试库存 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 服务器!🎉🎉🎉🎉🎉