Skip to content

Implement the Game API and Inventory MCP server

Before we implement our Game API, we need to create these 5 APIs:

  1. saveGame - create or update a game.
  2. queryGames - return a paginated list of previously saved games.
  3. saveAction - save an action for a given game.
  4. queryActions - return a paginated list of all actions related to a game.
  5. queryInventory - return a paginated list of items in a player’s inventory.

To define our API inputs and outputs, let’s create our schema using Zod within the packages/game-api/src/schema directory as follows:

import { z } from 'zod';
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>;

You can delete the packages/game-api/src/schema/echo.ts file as we will not be using it in this project.

This is the ER diagram for our application.

dungeon-adventure-er.png

We will implement our database in DynamoDB, and will use the ElectroDB DynamoDB client library to simplify things. To install electrodb and the DynamoDB Client, run this command:

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

To define our ElectroDB entities from the ER Diagram, let’s create the following files within our packages/game-api/src/entities folder:

import { Entity } from 'electrodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createActionEntity = (client?: 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 },
);

ElectroDB allows us to not only define our types, but can also provide defaults for certain values like timestamps. In addition, ElectroDB follows single-table design which is the best practice when using DynamoDB.

To prepare for the MCP server to interact with the inventory, let’s ensure we export the inventory entity in 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/inventory.js';

Adding the DynamoDB client to our tRPC context

Section titled “Adding the DynamoDB client to our tRPC context”

As we need access to the DynamoDB client in each of our procedures, we will create a single instance of the client which we can pass through via context. To do this, make the following changes within packages/game-api/src:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { initTRPC } from '@trpc/server';
export interface IDynamoDBContext {
dynamoDb?: DynamoDBClient;
}
export const createDynamoDBPlugin = () => {
const t = initTRPC.context<IDynamoDBContext>().create();
return t.procedure.use(async (opts) => {
const dynamoDb = new DynamoDBClient();
const response = await opts.next({
ctx: {
...opts.ctx,
dynamoDb,
},
});
return response;
});
};

This is a plugin that we instrument to create the DynamoDBClient and inject it into the context.

To implement the API methods, make the following changes within packages/game-api/src/procedures:

import { createActionEntity } from '../entities/action.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, ctx }) => {
const actionEntity = createActionEntity(ctx.dynamoDb);
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,
};
});
import { ActionSchema, IAction } from '../schema/index.js';
import { publicProcedure } from '../init.js';
import { createActionEntity } from '../entities/action.js';
import { createGameEntity } from '../entities/game.js';
export const saveAction = publicProcedure
.input(ActionSchema.omit({ timestamp: true }))
.output(ActionSchema)
.mutation(async ({ input, ctx }) => {
const actionEntity = createActionEntity(ctx.dynamoDb);
const gameEntity = createGameEntity(ctx.dynamoDb);
const action = await actionEntity.put(input).go();
await gameEntity
.update({ playerName: input.playerName })
.set({ lastUpdated: action.data.timestamp })
.go();
return action.data as IAction;
});

You can delete the echo.ts file (from packages/game-api/src/procedures) as we will not be using it in this project.

After we define our procedures, to wire them into our API, update the following file:

import {
awsLambdaRequestHandler,
CreateAWSLambdaContextOptions,
} from '@trpc/server/adapters/aws-lambda';
import { t } from './init.js';
import { APIGatewayProxyEvent } from 'aws-lambda';
import { queryActions } from './procedures/query-actions.js';
import { saveAction } from './procedures/save-action.js';
import { queryGames } from './procedures/query-games.js';
import { saveGame } from './procedures/save-game.js';
import { queryInventory } from './procedures/query-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;

Let us create an MCP server which will allow our agent to manage items in a player’s inventory.

We’ll define the following tools for our agent:

  • list-inventory-items for retrieving the player’s current inventory items
  • add-to-inventory for adding items to the player’s inventory
  • remove-from-inventory for removing items from the player’s inventory

To save time, we will define all the tools inline:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
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 dynamoDb = new DynamoDBClient();
const inventory = createInventoryEntity(dynamoDb);
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;
};

As the number of tools grow, you can refactor them out into separate files if you like.

Delete the tools and resources directories in packages/inventory/src/mcp-server as these will not be used.

The final step is to update our infrastructure to create the DynamoDB table and grant permissions to perform operations from the Game API. To do so, update the packages/infra/src as follows:

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

To build the codebase:

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

To deploy your application, run the following command:

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

Your first deployment will take around 8 minutes to complete. Subsequent deployments will take around 2 minutes.

Once the deployment completes, you will see outputs similar to the following (some values have been redacted):

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

You can test the API by either:

  • Starting a local instance of the tRPC backend and invoke the APIs using curl.
  • Calling the deployed API using sigv4 enabled curl

To start your local game-api server, run the following command:

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

Once your server is up and running, you can call it by running the following command:

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

If the command runs successfully, you will see a response as follows:

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

Congratulations, you have built and deployed your first API using tRPC! 🎉🎉🎉