Guide · Amazon Lex v2

MCP Server Amazon Lex v2 — RecognizeTextCommand, session management, IAM

Amazon Lex v2 brings intent classification, slot extraction, and multi-turn dialog management to any MCP server tool. The runtime SDK (@aws-sdk/client-lex-runtime-v2) exposes RecognizeTextCommand as the primary entry point for text-based conversations: you send a user utterance and Lex returns ranked intent interpretations, extracted slot values, and a list of response messages that the bot configured. The three things you must get right before the first call succeeds are botId and botAliasId (the 10-character identifiers from the Lex console), a stable sessionId per conversation thread (Lex uses it to maintain dialog state across turns), and IAM permissions narrowed to the specific bot alias ARN. Getting any of these wrong produces an AccessDeniedException, a ValidationException, or a silently broken conversation where every turn starts fresh with no memory of prior exchanges.

TL;DR

Import LexRuntimeV2Client and RecognizeTextCommand from @aws-sdk/client-lex-runtime-v2. Provide botId, botAliasId (or "TSTALIASID" for the draft bot), localeId (e.g. "en_US"), a stable per-conversation sessionId, and the user's text. Read the highest-confidence intent from interpretations[0].intent.name, check sessionState.dialogAction.type to know if Lex wants more slots ("ElicitSlot") or is done ("Close"), and render messages[0].content back to the user. IAM needs lex:RecognizeText on arn:aws:lex:REGION:ACCOUNT:bot-alias/BOT_ID/BOT_ALIAS_ID. Session TTL is 5 minutes of inactivity — after that Lex resets dialog state automatically.

Installing the runtime SDK and constructing the client

The Lex v2 runtime SDK is a separate package from the Lex models SDK. The runtime SDK handles conversation turns (sending text/audio, receiving responses). The models SDK handles bot management (creating intents, slot types, publishing versions). For an MCP server tool that calls an existing bot, you only need the runtime SDK.

# Runtime SDK — for sending text/audio and reading responses
npm install @aws-sdk/client-lex-runtime-v2

# Models SDK — only needed if you create or modify bots programmatically
# npm install @aws-sdk/client-lex-models-v2

Construct the LexRuntimeV2Client once at module initialization. The client is stateless and safe to reuse across concurrent tool calls.

import { LexRuntimeV2Client } from '@aws-sdk/client-lex-runtime-v2';

const lex = new LexRuntimeV2Client({
  region: process.env.AWS_REGION ?? 'us-east-1',
  // Credentials resolve from the standard chain:
  // 1. AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars
  // 2. ~/.aws/credentials file
  // 3. ECS task role / EC2 instance profile / Lambda execution role (preferred)
});

// Bot identifiers — from the Lex console under "Bot details" or CreateBotCommand response
const BOT_ID = process.env.LEX_BOT_ID!;         // 10-char alphanumeric, e.g. "ABCDE12345"
const BOT_ALIAS_ID = process.env.LEX_ALIAS_ID!; // "TSTALIASID" for draft bot, production alias otherwise
const LOCALE_ID = 'en_US';

Lex v2 is regional — the bot you created in us-east-1 does not exist in eu-west-1. The client region, the bot's region, and the region in the IAM resource ARN must all match, or you get a ValidationException: BotId not found.

Sending a turn with RecognizeTextCommand

RecognizeTextCommand is the standard entry point for text-based Lex conversations. Each call represents one turn: the user says something, Lex responds with intent data and messages. Maintaining sessionId across calls is what makes turns part of the same conversation.

import {
  LexRuntimeV2Client,
  RecognizeTextCommand,
  type RecognizeTextCommandInput,
  type RecognizeTextCommandOutput,
} from '@aws-sdk/client-lex-runtime-v2';

const lex = new LexRuntimeV2Client({ region: process.env.AWS_REGION ?? 'us-east-1' });

export async function sendToLex(
  userText: string,
  sessionId: string,
): Promise {
  const input: RecognizeTextCommandInput = {
    botId: process.env.LEX_BOT_ID!,
    botAliasId: process.env.LEX_ALIAS_ID ?? 'TSTALIASID',
    localeId: 'en_US',
    sessionId,
    text: userText,
    // sessionState: { ... }  // optional — inject session attributes or override dialog state
    // requestAttributes: { 'x-forwarded-user': userId }  // key-value pairs passed to Lambda hooks
  };

  return lex.send(new RecognizeTextCommand(input));
}

// Usage in an MCP tool handler:
// const response = await sendToLex(tool.input.userMessage, tool.sessionId);
// const botReply = response.messages?.[0]?.content ?? 'I did not understand that.';

The sessionId is an arbitrary string up to 100 characters — it must be unique per conversation thread and stable across all turns in that thread. A UUID (e.g. crypto.randomUUID()) generated when the conversation starts and stored in the MCP tool's context is the standard approach. Never reuse session IDs across different users — Lex stores dialog state (slot values, active contexts) keyed on session ID.

Parsing the response: interpretations, sessionState, messages

The RecognizeTextCommand response has three main fields: interpretations (ranked NLU results), sessionState (dialog state machine position), and messages (what the bot says next). Most MCP tool implementations only need a subset.

import type { RecognizeTextCommandOutput, Interpretation } from '@aws-sdk/client-lex-runtime-v2';

interface LexTurnResult {
  topIntent: string;
  confidence: number;
  dialogAction: string;
  slotToElicit?: string;
  botMessages: string[];
  sessionAttributes: Record<string, string>;
}

export function parseLexResponse(response: RecognizeTextCommandOutput): LexTurnResult {
  // interpretations[] is sorted by NLU confidence — highest score first
  const top: Interpretation = response.interpretations?.[0] ?? {};
  const intentName = top.intent?.name ?? 'FallbackIntent';
  const confidence = top.nluConfidence?.score ?? 0;

  const dialogAction = response.sessionState?.dialogAction?.type ?? 'None';
  const slotToElicit = response.sessionState?.dialogAction?.slotToElicit;

  // messages[] = what the bot wants to say (configured in Lex console or returned by Lambda hook)
  const botMessages = (response.messages ?? [])
    .filter(m => m.contentType === 'PlainText' || m.contentType === 'SSML')
    .map(m => m.content ?? '');

  const sessionAttributes = response.sessionState?.sessionAttributes ?? {};

  return { topIntent: intentName, confidence, dialogAction, slotToElicit, botMessages, sessionAttributes };
}

// dialogAction.type values and their meaning:
// 'ElicitIntent'  — bot could not classify intent; asking user to rephrase
// 'ElicitSlot'    — intent known, but a required slot is missing; bot is asking for it
// 'ConfirmIntent' — bot has all slots, asking "did you mean X?" before fulfilling
// 'Close'         — fulfillment is complete (or failed); conversation turn is done
// 'Delegate'      — Lex handled this turn internally (Lambda code hook delegated)
// 'None'          — no specific action; uncommon in production bots

When dialogAction.type is "ElicitSlot", the slotToElicit field names which slot Lex is asking for. You can use this to route the user's next input directly back to Lex rather than trying to parse it yourself. When dialogAction.type is "Close", the conversation turn is finished — either successfully fulfilled or explicitly failed by the fulfillment Lambda.

Confidence thresholds and fallback handling

The nluConfidence.score ranges from 0.0 to 1.0. Lex routes to FallbackIntent automatically when no intent meets the bot's configured confidence threshold, but you can also implement your own threshold logic above Lex.

const CONFIDENCE_FLOOR = 0.75; // below this, treat as low-confidence and ask to rephrase

export async function handleLexTurn(
  userText: string,
  sessionId: string,
): Promise<{ reply: string; done: boolean }> {
  const response = await lex.send(new RecognizeTextCommand({
    botId: process.env.LEX_BOT_ID!,
    botAliasId: process.env.LEX_ALIAS_ID ?? 'TSTALIASID',
    localeId: 'en_US',
    sessionId,
    text: userText,
  }));

  const top = response.interpretations?.[0];
  const intentName = top?.intent?.name;
  const score = top?.nluConfidence?.score ?? 0;

  if (intentName === 'FallbackIntent' || score < CONFIDENCE_FLOOR) {
    return {
      reply: response.messages?.[0]?.content ?? "I didn't catch that — could you rephrase?",
      done: false,
    };
  }

  const dialogType = response.sessionState?.dialogAction?.type;
  const botReply = response.messages?.[0]?.content ?? '';

  return {
    reply: botReply,
    done: dialogType === 'Close',
  };
}

For MCP server tools that expose Lex to end users, a confidence floor of 0.70–0.80 is common. Below that, re-prompt the user rather than forwarding a low-confidence interpretation. For internal tooling where the input is generated by another AI system, you can drop the floor to 0.50 since the upstream LLM has already done semantic normalization.

Session management: TTL, state injection, and conversation restart

Lex persists dialog state server-side keyed on sessionId. The session TTL is 5 minutes of inactivity. After TTL expiry, Lex resets the dialog state — the next turn with that session ID starts a new conversation with no memory of previous slots or context. For long-running agentic workflows, you need to handle expiry explicitly.

import { randomUUID } from 'crypto';

// Store per-thread session state in the MCP server's conversation context
interface LexSession {
  sessionId: string;
  createdAt: number;
  lastTurnAt: number;
  attributes: Record<string, string>;
}

const LEX_SESSION_TTL_MS = 4 * 60 * 1000; // 4 min — reset before Lex expires at 5 min

function getOrCreateSession(conversationId: string, store: Map<string, LexSession>): LexSession {
  const now = Date.now();
  const existing = store.get(conversationId);

  if (existing && now - existing.lastTurnAt < LEX_SESSION_TTL_MS) {
    return existing;
  }

  // Create a new session — either first time or after TTL
  const session: LexSession = {
    sessionId: randomUUID(),
    createdAt: now,
    lastTurnAt: now,
    attributes: {},
  };
  store.set(conversationId, session);
  return session;
}

// You can inject sessionAttributes into the next turn to pass context to Lambda hooks
async function sendTurnWithAttributes(
  userText: string,
  session: LexSession,
  extraAttributes?: Record<string, string>,
) {
  return lex.send(new RecognizeTextCommand({
    botId: process.env.LEX_BOT_ID!,
    botAliasId: process.env.LEX_ALIAS_ID ?? 'TSTALIASID',
    localeId: 'en_US',
    sessionId: session.sessionId,
    text: userText,
    sessionState: {
      sessionAttributes: { ...session.attributes, ...extraAttributes },
    },
  }));
}

sessionAttributes is a flat Record<string, string> (all values must be strings) that persists for the life of the session and is accessible in Lambda code hooks. Use it to pass context between turns: authenticated user ID, tenant ID, feature flags, or accumulated slot values from a prior workflow step.

IAM: minimal permissions for a Lex v2 runtime caller

The runtime SDK requires lex:RecognizeText on the specific bot alias ARN. If you also use audio input, add lex:RecognizeUtterance. Never grant lex:* — that includes bot management actions (delete bot, update intents) that an MCP server tool should never perform at runtime.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowLexTextRecognition",
      "Effect": "Allow",
      "Action": [
        "lex:RecognizeText"
      ],
      "Resource": [
        "arn:aws:lex:us-east-1:123456789012:bot-alias/ABCDE12345/FGHIJ67890"
      ]
    }
  ]
}

The ARN structure is arn:aws:lex:REGION:ACCOUNT:bot-alias/BOT_ID/ALIAS_ID. Both BOT_ID and ALIAS_ID are 10-character alphanumeric identifiers visible in the Lex console under "Bot details" and "Aliases". For the draft bot (useful in development), the alias ID is the literal string TSTALIASID — use that in the ARN during development:

# Development ARN (draft bot)
arn:aws:lex:us-east-1:123456789012:bot-alias/ABCDE12345/TSTALIASID

# Production ARN (published alias)
arn:aws:lex:us-east-1:123456789012:bot-alias/ABCDE12345/PRODAL1234

When running on Lambda, the execution role automatically provides credentials — no explicit access keys needed. Add the IAM policy above as an inline policy on the Lambda execution role or attach it via the role's permission boundary.

Error handling reference

ErrorCauseFix
AccessDeniedExceptionIAM missing lex:RecognizeText on the bot alias ARNVerify ARN format: arn:aws:lex:REGION:ACCOUNT:bot-alias/BOT_ID/ALIAS_ID; check region matches
ValidationException: BotId not foundbotId or botAliasId does not exist in this regionGet IDs from Lex console → Bot details; ensure client region matches bot region
ValidationException: BotAlias not readyBot alias is in building/failed stateWait for bot to reach "Built" state; check for intent/slot type errors in Lex console
ThrottlingExceptionExceeded TPS limit (default 5 TPS per bot alias per region)Add jittered exponential backoff; request quota increase via Service Quotas for high-volume use cases
DependencyFailedExceptionLambda fulfillment/dialog code hook timed out or threw unhandled errorCheck Lambda CloudWatch Logs; ensure hook returns required sessionState.dialogAction.type field
BadGatewayExceptionLex could not reach Lambda code hook (permissions or VPC misconfiguration)Verify Lex has lambda:InvokeFunction permission on the hook function; check VPC config if applicable
InternalServerExceptionTransient Lex service errorRetry with exponential backoff; if persistent, check AWS Service Health Dashboard