Module 6: MCP Apps and Interactive UI
Interactive Forms and Workflows via MCP
Interactive Forms and Workflows via MCP
Capsule description
The dashboards from the previous capsule are output — the tool returns information the developer reads. But a complete MCP App also needs input — capturing user data, confirming actions, and guiding multi-step flows. That's what this capsule covers.
In MCP, there's no concept of a "form" like in a web app with HTML inputs and submit buttons. What there is are patterns for capturing user information in a structured way using the primitives you already know: tools with well-designed parameters and prompts that guide the interaction. The combination of both creates flows that feel interactive, even though technically they're sequences of tool calls.
By the end of this capsule, you'll know how to design multi-step workflows, implement confirmations for destructive actions, and combine tools with prompts to create smooth user experiences within Claude Code.
The concept: interactivity in MCP
How interaction works in Claude Code
When you use Claude Code, the interaction is conversational:
You: "I need to configure a new microservice"
Claude Code: [uses the setup_service tool with the parameters it infers]
Claude Code: "I've created the structure. Do you want me to configure the database too?"
You: "Yes, PostgreSQL with the users and products tables"
Claude Code: [uses the configure_db tool with the parameters]
The "interactivity" doesn't come from UI widgets — it comes from intelligent tool design that lets Claude Code:
- Infer parameters from the conversation context
- Ask for confirmation before irreversible actions
- Chain tools in logical sequences
- Return results that suggest the next step
Three levels of interactivity
Level 1: Tool with parameters (you already know this)
The user asks for something → Claude Code invokes the tool → result
Level 2: Tool with confirmation
Claude Code prepares the action → shows a preview → asks for confirmation → executes
Level 3: Multi-step workflow
Tool 1 (collect) → Tool 2 (validate) → Tool 3 (preview) → Tool 4 (execute)
Pattern 1: Confirmation before executing
The most common pattern in MCP Apps: the tool has a "preview" mode (dryRun) that shows what's going to happen, and an "execute" mode that does it. Claude Code can show the preview and ask for confirmation before executing.
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
const server = new McpServer({ name: "safe-ops", version: "1.0.0" });
server.tool(
"batch_rename",
"Renames files in a directory. By default it shows a preview without executing. Use execute=true to apply the changes.",
{
directory: z.string().describe("Directory with the files"),
find: z.string().min(1).describe("Text to search for in the names"),
replace: z.string().describe("Replacement text"),
execute: z.boolean().default(false)
.describe("false = preview, true = execute the changes"),
},
async ({ directory, find, replace, execute }) => {
let entries: string[];
try {
entries = await fs.readdir(directory);
} catch {
return {
content: [{ type: "text" as const, text: `Error: could not read '${directory}'` }],
isError: true,
};
}
const changes = entries
.filter(name => name.includes(find))
.map(name => ({ from: name, to: name.replace(find, replace) }));
if (changes.length === 0) {
return {
content: [{
type: "text" as const,
text: `## Batch Rename\n\nNo files found with '${find}' in \`${directory}\``,
}],
};
}
if (!execute) {
let preview = `## 📋 Preview — Batch Rename\n\n`;
preview += `**Directory:** \`${directory}\`\n`;
preview += `**Pattern:** "${find}" → "${replace}"\n`;
preview += `**Affected files:** ${changes.length}\n\n`;
preview += `| # | Current name | New name |\n`;
preview += `|---|--------------|-------------|\n`;
changes.forEach((c, i) => {
preview += `| ${i + 1} | \`${c.from}\` | \`${c.to}\` |\n`;
});
preview += `\n---\n\n⚠️ **This is a preview.** To execute, use \`execute: true\`.`;
return { content: [{ type: "text" as const, text: preview }] };
}
const results: { from: string; to: string; status: string }[] = [];
for (const { from, to } of changes) {
try {
await fs.rename(path.join(directory, from), path.join(directory, to));
results.push({ from, to, status: "✅" });
} catch (e) {
results.push({ from, to, status: `❌ ${e instanceof Error ? e.message : "error"}` });
}
}
const success = results.filter(r => r.status === "✅").length;
let report = `## ✅ Batch Rename — Executed\n\n`;
report += `**Result:** ${success}/${changes.length} files renamed\n\n`;
report += `| File | New name | Status |\n`;
report += `|---------|-------------|--------|\n`;
for (const r of results) {
report += `| \`${r.from}\` | \`${r.to}\` | ${r.status} |\n`;
}
return { content: [{ type: "text" as const, text: report }] };
}
);
Claude Code uses this naturally: it first calls the tool with execute: false, shows the preview to the user, and if the user confirms, calls again with execute: true.
Pattern 2: Multi-step workflow with state
A more complex workflow that guides the user through a configuration process:
Python
import json
import os
from datetime import datetime
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP("project-scaffolder")
active_sessions: dict[str, dict] = {}
class ProjectConfig(BaseModel):
name: str = Field(min_length=1, max_length=50, description="Project name")
language: str = Field(description="Language: python, typescript, or rust")
features: list[str] = Field(default_factory=list, description="Features: api, db, auth, tests, docker")
description: str = Field(default="", description="Project description")
@mcp.tool()
async def start_project_setup(name: str, language: str) -> str:
"""Starts the setup of a new project. Returns the available options."""
if language not in ("python", "typescript", "rust"):
return f"Error: language '{language}' not supported. Options: python, typescript, rust"
session_id = f"{name}-{datetime.now().strftime('%H%M%S')}"
active_sessions[session_id] = {
"name": name,
"language": language,
"features": [],
"step": "features",
}
features_by_lang = {
"python": ["api (FastAPI)", "db (SQLAlchemy)", "auth (JWT)", "tests (pytest)", "docker"],
"typescript": ["api (Express)", "db (Prisma)", "auth (Passport)", "tests (Vitest)", "docker"],
"rust": ["api (Actix)", "db (Diesel)", "auth (JWT)", "tests (cargo test)", "docker"],
}
features = features_by_lang[language]
return f"""## 🚀 Project Setup — Step 1/3
**Project:** {name}
**Language:** {language}
**Session:** `{session_id}`
---
### Select features
| # | Feature | Description |
|---|---------|-------------|
{chr(10).join(f"| {i+1} | {f} | Include {f.split(' ')[0]} |" for i, f in enumerate(features))}
---
**Next step:** Use `configure_features` with the session_id `{session_id}` and the list of features you want to include.
Example: `configure_features(session_id="{session_id}", features=["api", "db", "tests"])`
"""
@mcp.tool()
async def configure_features(session_id: str, features: list[str]) -> str:
"""Configures the project's features. Step 2 of the setup."""
if session_id not in active_sessions:
return f"Error: session '{session_id}' not found. Use `start_project_setup` first."
session = active_sessions[session_id]
valid_features = {"api", "db", "auth", "tests", "docker"}
invalid = [f for f in features if f not in valid_features]
if invalid:
return f"Error: invalid features: {invalid}. Options: {sorted(valid_features)}"
session["features"] = features
session["step"] = "confirm"
feature_icons = {"api": "🌐", "db": "💾", "auth": "🔐", "tests": "🧪", "docker": "🐳"}
structure_lines = [f" {session['name']}/"]
if "api" in features: structure_lines.append(" ├── src/api/")
if "db" in features: structure_lines.append(" ├── src/db/")
if "auth" in features: structure_lines.append(" ├── src/auth/")
if "tests" in features: structure_lines.append(" ├── tests/")
if "docker" in features: structure_lines.append(" ├── Dockerfile")
structure_lines.append(" ├── README.md")
if session["language"] == "python": structure_lines.append(" └── requirements.txt")
elif session["language"] == "typescript": structure_lines.append(" └── package.json")
else: structure_lines.append(" └── Cargo.toml")
return f"""## 🚀 Project Setup — Step 2/3
**Project:** {session["name"]}
**Language:** {session["language"]}
**Features:** {" ".join(feature_icons.get(f, "📦") + " " + f for f in features)}
---
### Proposed structure
{chr(10).join(structure_lines)}
---
### Files that will be created
| File | Purpose |
|---------|-----------|
| `README.md` | Project documentation |
{"| `src/api/` | API endpoints |" + chr(10) if "api" in features else ""}{"| `src/db/` | Models and migrations |" + chr(10) if "db" in features else ""}{"| `src/auth/` | Authentication |" + chr(10) if "auth" in features else ""}{"| `tests/` | Test suite |" + chr(10) if "tests" in features else ""}{"| `Dockerfile` | Containerization |" + chr(10) if "docker" in features else ""}
---
**Next step:** Use `execute_setup(session_id="{session_id}")` to create the project, or `start_project_setup` to start over.
"""
@mcp.tool()
async def execute_setup(session_id: str, target_dir: str = ".") -> str:
"""Executes the project setup. Step 3 (final)."""
if session_id not in active_sessions:
return f"Error: session '{session_id}' not found."
session = active_sessions[session_id]
if session["step"] != "confirm":
return "Error: you must configure features first (step 2)."
project_dir = os.path.join(target_dir, session["name"])
created_files: list[str] = []
errors: list[str] = []
try:
os.makedirs(project_dir, exist_ok=True)
created_files.append(f"{session['name']}/")
for feature in session["features"]:
dir_name = f"src/{feature}" if feature != "docker" else ""
if dir_name:
os.makedirs(os.path.join(project_dir, dir_name), exist_ok=True)
created_files.append(f"{dir_name}/")
readme = f"# {session['name']}\n\n{session['language'].title()} project with {', '.join(session['features'])}.\n"
with open(os.path.join(project_dir, "README.md"), "w") as f:
f.write(readme)
created_files.append("README.md")
if "docker" in session["features"]:
dockerfile = f"FROM {'python:3.12-slim' if session['language'] == 'python' else 'node:20-slim' if session['language'] == 'typescript' else 'rust:1.75-slim'}\nWORKDIR /app\nCOPY . .\n"
with open(os.path.join(project_dir, "Dockerfile"), "w") as f:
f.write(dockerfile)
created_files.append("Dockerfile")
except Exception as e:
errors.append(str(e))
del active_sessions[session_id]
status = "✅ Completed" if not errors else "⚠️ Completed with errors"
report = f"""## {status} — Project Created
**Project:** `{project_dir}`
**Language:** {session["language"]}
**Features:** {", ".join(session["features"])}
---
### Created files
"""
for f in created_files:
report += f"- ✅ `{f}`\n"
for e in errors:
report += f"- ❌ Error: {e}\n"
report += f"\n---\n\n**Next steps:** `cd {session['name']}` → install dependencies → configure .env"
return report
if __name__ == "__main__":
mcp.run()
What this example demonstrates
- State between calls —
active_sessionsmaintains the workflow's context - Progressive validation — each step validates before advancing
- Preview before executing — step 2 shows the structure before creating it
- Clear instructions — each response indicates the next step
- Cleanup — the session is deleted upon completion
Pattern 3: Prompts as interaction templates
MCP prompts complement tools to create standardized interactive flows:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("code-reviewer")
@mcp.prompt()
async def review_code(file_path: str, focus: str = "general") -> str:
"""Template for a code review with a configurable focus."""
focus_instructions = {
"general": "Review the code considering clarity, maintainability, and possible bugs.",
"security": "Focus on security vulnerabilities: injection, auth, secrets, input validation.",
"performance": "Focus on performance: algorithmic complexity, N+1 queries, memory leaks.",
"testing": "Focus on testability: pure functions, dependency injection, edge cases.",
}
instruction = focus_instructions.get(focus, focus_instructions["general"])
return f"""Review the file `{file_path}`.
**Focus:** {focus}
**Instructions:** {instruction}
Please provide:
1. **Summary** — what the code does in 2-3 sentences
2. **Positives** — what's well implemented
3. **Issues found** — problems ordered by severity (🔴 critical, 🟡 medium, 🟢 minor)
4. **Suggestions** — concrete improvements with code examples
5. **Verdict** — ✅ approve, 🟡 approve with minor changes, 🔴 requires changes
Format your response as a professional code review report."""
@mcp.prompt()
async def setup_env(project_type: str = "web") -> str:
"""Template to configure a new development environment."""
return f"""I need to configure a development environment for a project of type: {project_type}
Please:
1. Check which tools I have installed (node, python, docker, git)
2. Identify what's missing
3. Suggest the installation steps for what's missing
4. Create base configuration files if necessary
Use the available tools to check the system and create files."""
Prompts standardize common interactions. Instead of the developer writing instructions each time, they use the prompt as a template that Claude Code executes with the available tools.
Pattern 4: Structured data capture
When you need the user to provide data with a specific format, design tools that guide the input:
server.tool(
"create_env_config",
"Creates a .env file with the variables needed for the project. Use template to see which variables are needed, or generate to create the file.",
{
action: z.enum(["template", "generate"]).describe("template = see what's needed, generate = create .env"),
projectType: z.enum(["api", "fullstack", "worker"]).describe("Project type"),
values: z.record(z.string()).optional()
.describe("Values for the environment variables (only for action=generate)"),
},
async ({ action, projectType, values }) => {
const templates: Record<string, { key: string; description: string; required: boolean; example: string }[]> = {
api: [
{ key: "PORT", description: "Server port", required: true, example: "3000" },
{ key: "DATABASE_URL", description: "The DB's connection string", required: true, example: "postgresql://user:pass@localhost:5432/mydb" },
{ key: "JWT_SECRET", description: "Secret for JWT tokens", required: true, example: "your-secret-here" },
{ key: "LOG_LEVEL", description: "Logging level", required: false, example: "info" },
{ key: "CORS_ORIGIN", description: "Allowed origin for CORS", required: false, example: "http://localhost:3001" },
],
fullstack: [
{ key: "PORT", description: "Backend port", required: true, example: "3000" },
{ key: "DATABASE_URL", description: "Connection string", required: true, example: "postgresql://localhost/mydb" },
{ key: "JWT_SECRET", description: "Secret for tokens", required: true, example: "change-me" },
{ key: "NEXT_PUBLIC_API_URL", description: "The API's URL for the frontend", required: true, example: "http://localhost:3000/api" },
{ key: "REDIS_URL", description: "Redis URL for cache", required: false, example: "redis://localhost:6379" },
],
worker: [
{ key: "QUEUE_URL", description: "The message queue's URL", required: true, example: "amqp://localhost" },
{ key: "DATABASE_URL", description: "Connection string", required: true, example: "postgresql://localhost/mydb" },
{ key: "CONCURRENCY", description: "Concurrent workers", required: false, example: "4" },
{ key: "RETRY_ATTEMPTS", description: "Retry attempts", required: false, example: "3" },
],
};
const vars = templates[projectType];
if (action === "template") {
let output = `## ⚙️ Environment Variables — ${projectType}\n\n`;
output += `| Variable | Description | Required | Example |\n`;
output += `|----------|-------------|-----------|----------|\n`;
for (const v of vars) {
output += `| \`${v.key}\` | ${v.description} | ${v.required ? "✅ Yes" : "No"} | \`${v.example}\` |\n`;
}
output += `\n---\n\n**Next step:** Use this tool with \`action: "generate"\` and provide the values in the \`values\` parameter.\n`;
output += `\nExample:\n\`\`\`\nvalues: { "PORT": "3000", "DATABASE_URL": "postgresql://..." }\n\`\`\``;
return { content: [{ type: "text" as const, text: output }] };
}
const missing = vars.filter(v => v.required && !values?.[v.key]);
if (missing.length > 0) {
let error = `## ❌ Missing required variables\n\n`;
for (const v of missing) {
error += `- \`${v.key}\` — ${v.description} (example: \`${v.example}\`)\n`;
}
return { content: [{ type: "text" as const, text: error }], isError: true };
}
let envContent = `# ${projectType} environment configuration\n`;
envContent += `# Generated: ${new Date().toISOString()}\n\n`;
for (const v of vars) {
const value = values?.[v.key] || v.example;
envContent += `# ${v.description}\n${v.key}=${value}\n\n`;
}
let report = `## ✅ .env file generated\n\n\`\`\`env\n${envContent}\`\`\`\n\n`;
report += `**Variables configured:** ${vars.length}\n`;
report += `**⚠️ Remember:** Don't commit this file to git. Add \`.env\` to your \`.gitignore\`.`;
return { content: [{ type: "text" as const, text: report }] };
}
);
What this example demonstrates
- Two modes in a single tool:
templateto discover what's needed,generateto create - Required field validation — reports what's missing before generating
- Examples — each variable has an example that guides the user
- Safe output — reminds you not to commit the
.env
Combining tools and prompts
The most powerful combination in MCP Apps is using prompts to start workflows that use tools:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("deploy-assistant")
@mcp.prompt()
async def deploy_checklist(environment: str = "staging") -> str:
"""Deploy checklist with automatic checks."""
return f"""Run a deploy checklist for the environment: {environment}
Use the following tools in order:
1. `check_tests()` — Verify that all tests pass
2. `check_dependencies()` — Verify updated dependencies
3. `check_env_vars(environment="{environment}")` — Verify environment variables
4. `deploy_preview(environment="{environment}")` — Preview the deploy
If any step fails, stop and report the problem.
Present the results as a checklist with ✅/❌ for each step."""
@mcp.tool()
async def check_tests() -> str:
"""Verifies that the tests pass."""
return "## 🧪 Tests\n\n✅ Unit: 45/45 (2.3s) | ✅ Integration: 12/12 (8.1s)"
@mcp.tool()
async def check_dependencies() -> str:
"""Verifies the status of the dependencies."""
return "## 📦 Deps\n\n✅ 42 up-to-date | ⚠️ 3 minor updates | 🔴 0 vulnerabilities"
@mcp.tool()
async def check_env_vars(environment: str = "staging") -> str:
"""Verifies environment variables."""
return f"## ⚙️ Vars ({environment})\n\n✅ DATABASE_URL | ✅ JWT_SECRET | ✅ API_KEY | ⚠️ SENTRY_DSN (optional)"
@mcp.tool()
async def deploy_preview(environment: str = "staging") -> str:
"""Preview of the deploy."""
return f"## 🚀 Preview ({environment})\n\nBranch: main | Commit: abc1234 | 5 files changed\n\n⚠️ **Preview.** Confirm to proceed."
if __name__ == "__main__":
mcp.run()
When the user invokes the deploy_checklist prompt, Claude Code runs each tool in sequence and presents a consolidated report — exactly like a CI/CD pipeline but inside Claude Code.
Exercises
Exercise 1: Tool with preview/execute (Easy)
Create a cleanup_logs tool with an execute parameter (default: false). In preview, show the .log files that would be deleted with their size. In execute, delete them.
See solution
import os
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("log-cleaner")
@mcp.tool()
async def cleanup_logs(directory: str, execute: bool = False) -> str:
"""Cleans up .log files. Preview by default, execute=True to delete."""
if not os.path.isdir(directory):
return f"Error: '{directory}' doesn't exist"
log_files = []
for f in os.listdir(directory):
if f.endswith(".log"):
path = os.path.join(directory, f)
size = os.path.getsize(path)
log_files.append({"name": f, "path": path, "size": size})
if not log_files:
return f"## 🧹 Log Cleanup\n\nNo .log files found in `{directory}`"
total_size = sum(f["size"] for f in log_files)
size_str = f"{total_size / 1024:.1f} KB" if total_size < 1024 * 1024 else f"{total_size / 1024 / 1024:.1f} MB"
if not execute:
lines = [f"## 🧹 Log Cleanup — Preview", "",
f"**Files:** {len(log_files)} | **Total size:** {size_str}", "",
"| File | Size |", "|---------|--------|"]
for f in sorted(log_files, key=lambda x: -x["size"]):
s = f"{f['size'] / 1024:.1f} KB"
lines.append(f"| `{f['name']}` | {s} |")
lines.append(f"\n⚠️ **Preview.** Use `execute=True` to delete.")
return "\n".join(lines)
deleted = 0
for f in log_files:
try:
os.remove(f["path"])
deleted += 1
except OSError:
pass
return f"## ✅ Log Cleanup\n\n**Deleted:** {deleted}/{len(log_files)} files | **Freed:** {size_str}"
if __name__ == "__main__":
mcp.run()
Exercise 2: Onboarding prompt (Easy)
Create an onboard_developer prompt that guides a new developer to configure their environment. The prompt should indicate which tools to use and in what order.
See solution
@mcp.prompt()
async def onboard_developer(name: str, role: str = "backend") -> str:
"""Onboarding guide for a new developer."""
return f"""Welcome to the team, {name}! Your role: {role}.
Please run the following steps using the available tools:
1. **Check the system:** Review the versions of node, python, docker, git
2. **Clone repos:** List the team's repositories and clone the relevant ones for {role}
3. **Configure the environment:** Create .env files with the necessary variables
4. **Verify access:** Confirm access to staging and development databases
5. **Run tests:** Run the test suite to verify that everything works
Present each step as a checklist:
- ✅ Completed correctly
- ❌ Problem found (with instructions to resolve)
- ⏭️ Pending
At the end, generate a summary of {name}'s onboarding status."""
Exercise 3: Multi-step workflow to create an API endpoint (Medium)
Create three tools that form a workflow: design_endpoint (defines the endpoint with method, path, params), preview_endpoint (shows the code that would be generated), create_endpoint (generates the files).
See solution
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP("endpoint-creator")
endpoint_sessions: dict[str, dict] = {}
class EndpointDesign(BaseModel):
method: str = Field(description="HTTP method: GET, POST, PUT, DELETE")
path: str = Field(description="Endpoint path, e.g. /api/users")
params: list[str] = Field(default_factory=list, description="Endpoint parameters")
description: str = Field(default="", description="Endpoint description")
@mcp.tool()
async def design_endpoint(method: str, path: str, params: list[str] = [], description: str = "") -> str:
"""Step 1: Design a new endpoint."""
session_id = f"{method}-{path.replace('/', '-')}"
endpoint_sessions[session_id] = {
"method": method.upper(), "path": path,
"params": params, "description": description,
}
return f"""## 📐 Endpoint Design
| Field | Value |
|-------|-------|
| Method | `{method.upper()}` |
| Path | `{path}` |
| Params | {', '.join(f'`{p}`' for p in params) or 'none'} |
| Description | {description or 'N/A'} |
**Next:** `preview_endpoint(session_id="{session_id}")`"""
@mcp.tool()
async def preview_endpoint(session_id: str) -> str:
"""Step 2: Preview of the code that will be generated."""
if session_id not in endpoint_sessions:
return f"Error: session '{session_id}' not found"
ep = endpoint_sessions[session_id]
params_str = ", ".join(f"{p}: str" for p in ep["params"])
code = f'''from fastapi import APIRouter
router = APIRouter()
@router.{ep["method"].lower()}("{ep["path"]}")
async def handler({params_str}):
"""{ep["description"]}"""
return {{"status": "ok"}}
'''
return f"## 👀 Preview\n\n```python\n{code}```\n\n**Next:** `create_endpoint(session_id=\"{session_id}\")` to create the files."
@mcp.tool()
async def create_endpoint(session_id: str) -> str:
"""Step 3: Create the endpoint (simulated)."""
if session_id not in endpoint_sessions:
return f"Error: session '{session_id}' not found"
ep = endpoint_sessions.pop(session_id)
return f"## ✅ Endpoint Created\n\n`{ep['method']} {ep['path']}` ready.\n\nFiles: `routes/{ep['path'].split('/')[-1]}.py`"
if __name__ == "__main__":
mcp.run()
Exercise 4: Configuration tool with validation (Hard)
Create a TypeScript tool configure_database with two modes: validate (checks that the connection string is valid for the DB type — postgres, mysql, sqlite — and shows a summary) and generate (creates a configuration JSON). Validate that the connection string starts with the correct prefix (postgresql://, mysql://, sqlite:///).
See solution
server.tool(
"configure_database",
"Configures a DB connection: validate to check, generate to create the config",
{
dbType: z.enum(["postgres", "mysql", "sqlite"]),
connectionString: z.string(),
poolSize: z.number().int().min(1).max(100).default(10),
action: z.enum(["validate", "generate"]).default("validate"),
},
async ({ dbType, connectionString, poolSize, action }) => {
const prefixes = { postgres: "postgresql://", mysql: "mysql://", sqlite: "sqlite:///" };
const isValid = connectionString.startsWith(prefixes[dbType]);
if (action === "validate") {
let out = `## 🔍 DB Config\n\n| Field | Status |\n|-------|--------|\n`;
out += `| Type: ${dbType} | ✅ |\n`;
out += `| Connection | ${isValid ? "✅" : "❌ Prefix: " + prefixes[dbType]} |\n`;
out += `| Pool: ${poolSize} | ${poolSize <= 50 ? "✅" : "⚠️"} |\n`;
if (isValid) out += `\nUse \`action: "generate"\` to create the config.`;
return { content: [{ type: "text" as const, text: out }] };
}
if (!isValid) return { content: [{ type: "text" as const, text: "❌ Validate first" }], isError: true };
const cfg = JSON.stringify({ database: { type: dbType, url: connectionString, pool: { size: poolSize } } }, null, 2);
return { content: [{ type: "text" as const, text: `## ✅ Config\n\n\`\`\`json\n${cfg}\n\`\`\`` }] };
}
);
Troubleshooting
"Claude Code doesn't follow the multi-step workflow"
Cause: The model doesn't always run the steps in the expected order. It may skip steps or combine them.
Solution: Design each tool to validate its precondition:
if session["step"] != "confirm":
return "Error: you must complete the previous step first"
"The sessions are lost between invocations"
Cause: The MCP process can restart, losing the in-memory state.
Solution: For critical workflows, persist the state in a file:
import json
def save_session(session_id: str, data: dict):
sessions = load_all_sessions()
sessions[session_id] = data
with open(".mcp_sessions.json", "w") as f:
json.dump(sessions, f)
"The prompt doesn't produce the expected result"
Cause: Prompts are suggestions, not absolute instructions. The model can interpret them differently.
Solution: Be more specific in the prompt, including the exact name of the tools and the parameters:
return f"""Use the `check_tests()` tool first. If it returns ✅, use `deploy_preview(environment="{env}")`."""
Summary
In this capsule you learned:
- Preview/execute confirmation — every destructive tool should have a preview mode (default) and an execute mode
- Multi-step workflows — tools with state that guide the user through a process
- Prompts as orchestrators — templates that define sequences of tools
- Structured data capture — tools with a template + generate mode for configurations
- Combining tools + prompts — the most powerful way to create interactive flows in MCP
Interactivity in MCP doesn't come from UI widgets — it comes from intelligent tool design that makes the conversation with Claude Code a natural and productive flow.
Additional resources
- MCP Specification — Prompts — Official prompts reference
- MCP Python SDK — Prompts — Prompts implementation in Python
- MCP TypeScript SDK — Prompts — Implementation in TypeScript
- Wizard Pattern (UX) — Design pattern for step-by-step workflows
Next capsule: Project — an MCP App with an Interactive Dashboard. You're going to combine dashboards, visualizations, and interactive workflows into a complete MCP App connected to Claude Code.