Agentic AI Dungeon Game
Module 2: Game API and Inventory MCP Server implementation
Section titled “Module 2: Game API and Inventory MCP Server implementation”We are going to start by implementing our Game API. To do this, we need to create 5 APIs in total:
saveGame
- create or updte a game.queryGames
- return a paginated list of previously saved games.saveAction
- save an action for a given game.queryActions
- return a paginated list of all actions related to a game.queryInventory
- return a paginated list of items in a player’s inventory.
API Schema
Section titled “API Schema”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>;
import { z } from 'zod';
export const QueryInputSchema = z.object({ cursor: z.string().optional(), limit: z.number().optional().default(100),});
export const createPaginatedQueryOutput = <ItemType extends z.ZodTypeAny>( itemSchema: ItemType,) => { return z.object({ items: z.array(itemSchema), cursor: z.string().nullable(), });};
export type IQueryInput = z.TypeOf<typeof QueryInputSchema>;
import { z } from 'zod';
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>;
import { z } from 'zod';
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>;
You can also delete the packages/game-api/src/schema/echo.ts
file given we will not be using it in this project.
Entity modelling
Section titled “Entity modelling”The ER diagram for our application is as follows:

We are going to implement our Database in DynamoDB and will be using the ElectroDB DynamoDB client library to simplify things. To get started we need to first install electrodb
and the DynamoDB Client by running the following command:
pnpm add -w electrodb@3.5.0 @aws-sdk/client-dynamodb@3.913.0
yarn add electrodb@3.5.0 @aws-sdk/client-dynamodb@3.913.0
npm install --legacy-peer-deps electrodb@3.5.0 @aws-sdk/client-dynamodb@3.913.0
bun install electrodb@3.5.0 @aws-sdk/client-dynamodb@3.913.0
Now let’s create the following files within our packages/game-api/src/entities
folder to define our ElectroDB entities as per the above ER Diagram:
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 }, );
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createGameEntity = (client?: 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 }, );
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createInventoryEntity = (client?: 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 allows us to not only define our types, but can also provide defaults for certain values like the timestamps above. 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”Given we need access to the DynamoDB client in each of our procedures, we want to be able to 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.
import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';import type { APIGatewayProxyEvent } from 'aws-lambda';import { ILoggerContext } from './logger.js';import { IMetricsContext } from './metrics.js';import { ITracerContext } from './tracer.js';import { IDynamoDBContext } from './dynamodb.js';
export * from './dynamodb.js';export * from './logger.js';export * from './metrics.js';export * from './tracer.js';export * from './error.js';
export type IMiddlewareContext = CreateAWSLambdaContextOptions<APIGatewayProxyEvent> & IDynamoDBContext & ILoggerContext & IMetricsContext & ITracerContext;
Augment our IMiddlewareContext
to add the IDynamoDBContext
.
import { initTRPC } from '@trpc/server';import { createDynamoDBPlugin, createErrorPlugin, createLoggerPlugin, createMetricsPlugin, createTracerPlugin, IMiddlewareContext,} from './middleware/index.js';
process.env.POWERTOOLS_SERVICE_NAME = 'GameApi';process.env.POWERTOOLS_METRICS_NAMESPACE = 'GameApi';
export type Context = IMiddlewareContext;
export const t = initTRPC.context<Context>().create();
export const publicProcedure = t.procedure .concat(createDynamoDBPlugin()) .concat(createLoggerPlugin()) .concat(createTracerPlugin()) .concat(createMetricsPlugin()) .concat(createErrorPlugin());
The DynamoDB plugin is instrumented.
Defining our procedures
Section titled “Defining our procedures”Now it’s time to implement the API methods. To do this, make the following changes within packages/game-api/src/procedures
:
Queries
Section titled “Queries”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 { createGameEntity } from '../entities/game.js';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, ctx }) => { const gameEntity = createGameEntity(ctx.dynamoDb); const result = await gameEntity.scan.go({ cursor: input.cursor, count: input.limit, });
return { items: result.data as IGame[], cursor: result.cursor, }; });
import { ItemSchema, QueryInputSchema, createPaginatedQueryOutput,} from '../schema/index.js';import { publicProcedure } from '../init.js';import { z } from 'zod';import { createInventoryEntity } from '../entities/inventory.js';
export const queryInventory = publicProcedure .input(QueryInputSchema.extend({ playerName: z.string() })) .output(createPaginatedQueryOutput(ItemSchema)) .query(async ({ input, ctx }) => { const inventoryEntity = createInventoryEntity(ctx.dynamoDb); const result = await inventoryEntity.query .primary({ playerName: input.playerName }) .go({ cursor: input.cursor, count: input.limit });
return { items: result.data, cursor: result.cursor, }; });
Mutations
Section titled “Mutations”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; });
import { createGameEntity } from '../entities/game.js';import { GameSchema, IGame } from '../schema/index.js';import { publicProcedure } from '../init.js';
export const saveGame = publicProcedure .input(GameSchema.omit({ lastUpdated: true })) .output(GameSchema) .mutation(async ({ input, ctx }) => { const gameEntity = createGameEntity(ctx.dynamoDb);
const result = await gameEntity.put(input).go(); return result.data as IGame; });
You can also delete the echo.ts
file (from packages/game-api/src/procedures
) given we will not be using it in this project.
Router setup
Section titled “Router setup”Now that we have defined our procedures, let’s wire them into our API. To do this, 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;
import { awsLambdaRequestHandler, CreateAWSLambdaContextOptions,} from '@trpc/server/adapters/aws-lambda';import { echo } from './procedures/echo.js';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({ echo, 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;
Inventory MCP Server
Section titled “Inventory MCP Server”Now let’s 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 itemsadd-to-inventory
for adding items to the player’s inventoryremove-from-inventory
for removing items from the player’s inventory
To save time, we’ll 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;};
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import { registerAddTool } from './tools/add.js';import { registerSampleGuidanceResource } from './resources/sample-guidance.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', });
registerAddTool(server); registerSampleGuidanceResource(server); 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 grows, you refactor them out into separate files if you like.
You can now delete the tools
and resources
directories in packages/inventory/src/mcp-server
as these are unused.
Infrastructure
Section titled “Infrastructure”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 }); }}
import { GameApi, GameUI, InventoryMcpServer, RuntimeConfig, StoryAgent, UserIdentity,} from ':dungeon-adventure/common-constructs';import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';import { Construct } from 'constructs';import { ElectrodbDynamoTable } from '../constructs/electrodb-table.js';
export class ApplicationStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props);
const userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
const gameApi = new GameApi(this, 'GameApi', { integrations: GameApi.defaultIntegrations(this) .withDefaultOptions({ environment: { TABLE_NAME: electroDbTable.tableName, }, }) .build(), });
electroDbTable.grantReadData(gameApi.integrations['actions.query'].handler); electroDbTable.grantReadData(gameApi.integrations['games.query'].handler); electroDbTable.grantReadData(gameApi.integrations['inventory.query'].handler); electroDbTable.grantReadWriteData( gameApi.integrations['actions.save'].handler, ); electroDbTable.grantReadWriteData( gameApi.integrations['games.save'].handler, );
const { userPool, userPoolClient } = userIdentity;
const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer', { environment: { TABLE_NAME: electroDbTable.tableName, }, }); electroDbTable.grantReadWriteData(mcpServer.agentCoreRuntime);
// Use Cognito for user authentication with the agent const storyAgent = new StoryAgent(this, 'StoryAgent', { authorizerConfiguration: { customJwtAuthorizer: { discoveryUrl: `https://cognito-idp.${Stack.of(userPool).region}.amazonaws.com/${userPool.userPoolId}/.well-known/openid-configuration`, allowedAudience: [userPoolClient.userPoolClientId], }, }, environment: { INVENTORY_MCP_ARN: mcpServer.agentCoreRuntime.arn, }, }); // Add the Story Agent ARN to runtime-config.json so it can be referenced by the website RuntimeConfig.ensure(this).config.agentArn = storyAgent.agentCoreRuntime.arn;
new CfnOutput(this, 'StoryAgentArn', { value: storyAgent.agentCoreRuntime.arn, }); new CfnOutput(this, 'InventoryMcpArn', { value: mcpServer.agentCoreRuntime.arn, });
// Grant the agent permissions to invoke our mcp server mcpServer.agentCoreRuntime.grantInvoke(storyAgent.agentCoreRuntime);
// Grant the authenticated role access to invoke the api gameApi.grantInvokeAccess(userIdentity.identityPool.authenticatedRole);
// Ensure this is instantiated last so our runtime-config.json can be automatically configured new GameUI(this, 'GameUI'); }}
import { GameApi, GameUI, InventoryMcpServer, RuntimeConfig, StoryAgent, UserIdentity,} from ':dungeon-adventure/common-constructs';import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';import { Construct } from 'constructs';import { ElectrodbDynamoTable } from '../constructs/electrodb-table.js';
export class ApplicationStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props);
const userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
const gameApi = new GameApi(this, 'GameApi', { integrations: GameApi.defaultIntegrations(this).build(), integrations: GameApi.defaultIntegrations(this) .withDefaultOptions({ environment: { TABLE_NAME: electroDbTable.tableName, }, }) .build(), });
electroDbTable.grantReadData(gameApi.integrations['actions.query'].handler); electroDbTable.grantReadData(gameApi.integrations['games.query'].handler); electroDbTable.grantReadData(gameApi.integrations['inventory.query'].handler); electroDbTable.grantReadWriteData( gameApi.integrations['actions.save'].handler, ); electroDbTable.grantReadWriteData( gameApi.integrations['games.save'].handler, );
const { userPool, userPoolClient } = userIdentity;
const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer'); const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer', { environment: { TABLE_NAME: electroDbTable.tableName, }, }); electroDbTable.grantReadWriteData(mcpServer.agentCoreRuntime);
// Use Cognito for user authentication with the agent const storyAgent = new StoryAgent(this, 'StoryAgent', { authorizerConfiguration: { customJwtAuthorizer: { discoveryUrl: `https://cognito-idp.${Stack.of(userPool).region}.amazonaws.com/${userPool.userPoolId}/.well-known/openid-configuration`, allowedAudience: [userPoolClient.userPoolClientId], }, }, environment: { INVENTORY_MCP_ARN: mcpServer.agentCoreRuntime.arn, }, }); // Add the Story Agent ARN to runtime-config.json so it can be referenced by the website RuntimeConfig.ensure(this).config.agentArn = storyAgent.agentCoreRuntime.arn;
new CfnOutput(this, 'StoryAgentArn', { value: storyAgent.agentCoreRuntime.arn, }); new CfnOutput(this, 'InventoryMcpArn', { value: mcpServer.agentCoreRuntime.arn, });
// Grant the agent permissions to invoke our mcp server mcpServer.agentCoreRuntime.grantInvoke(storyAgent.agentCoreRuntime);
// Grant the authenticated role access to invoke the api gameApi.grantInvokeAccess(userIdentity.identityPool.authenticatedRole);
// Ensure this is instantiated last so our runtime-config.json can be automatically configured new GameUI(this, 'GameUI'); }}
Deployment and testing
Section titled “Deployment and testing”First, lets build the codebase:
pnpm nx run-many --target build --all
yarn nx run-many --target build --all
npx nx run-many --target build --all
bunx nx run-many --target build --all
Your application can now be deployed by running the following command:
pnpm nx deploy infra dungeon-adventure-infra-sandbox/*
yarn nx deploy infra dungeon-adventure-infra-sandbox/*
npx nx deploy infra dungeon-adventure-infra-sandbox/*
bunx 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 should see some outputs similar to the following (some values have been redacted):
dungeon-adventure-sandbox-Applicationdungeon-adventure-sandbox-Application: deploying... [2/2]
✅ dungeon-adventure-sandbox-Application
✨ Deployment time: 354s
Outputs:dungeon-adventure-sandbox-Application.ElectroDbTableTableNameXXX = dungeon-adventure-sandbox-Application-ElectroDbTableXXX-YYYdungeon-adventure-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-sandbox-Application.StoryApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx
We can test our 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
Sigv4 enabled curl
You can either add the following script to your
.bashrc
file (andsource
it) or simply paste the following into the same terminal you wish to run the command in.~/.bashrc acurl () {REGION=$1SERVICE=$2shift; shift;curl --aws-sigv4 "aws:amz:$REGION:$SERVICE" --user "$(aws configure get aws_access_key_id):$(aws configure get aws_secret_access_key)" -H "X-Amz-Security-Token: $(aws configure get aws_session_token)" "$@"}Then to make a sigv4 authenticated curl request, you can simply invoke
acurl
like the following examples:API Gateway
Section titled “API Gateway”Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxStreaming Lambda function url
Section titled “Streaming Lambda function url”Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxxYou can either add the following function to your PowerShell profile or simply paste the following into the same PowerShell session you wish to run the command in.
Terminal window # PowerShell profile or current sessionfunction acurl {param([Parameter(Mandatory=$true)][string]$Region,[Parameter(Mandatory=$true)][string]$Service,[Parameter(ValueFromRemainingArguments=$true)][string[]]$CurlArgs)$AccessKey = aws configure get aws_access_key_id$SecretKey = aws configure get aws_secret_access_key$SessionToken = aws configure get aws_session_token& curl --aws-sigv4 "aws:amz:$Region`:$Service" --user "$AccessKey`:$SecretKey" -H "X-Amz-Security-Token: $SessionToken" @CurlArgs}Then to make a sigv4 authenticated curl request, you can simply invoke
acurl
like the following examples:API Gateway
Section titled “API Gateway”Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxStreaming Lambda function url
Section titled “Streaming Lambda function url”Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxx
Start your local game-api
server by running the following command:
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY yarn nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY npx nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY bunx nx run @dungeon-adventure/game-api:serve
Once your server is up and running, you can call it by running the following command:
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'
acurl ap-southeast-2 execute-api -X GET 'https://xxx.execute-api.ap-southeast-2.amazonaws.com/prod/games.query?input=%7B%7D'
If the command executes successfully, you should see a response as follows:
{"result":{"data":{"items":[],"cursor":null}}}
Congratulations, you have built and deployed your first API using tRPC! 🎉🎉🎉