# Host Experience Design

Course: [Model Context Protocol](https://www.devtutorials.ai/learn/mcp) · Section: Development Workflows
Source: https://www.devtutorials.ai/learn/mcp/dev-workflows/host-experience-design

> Designing the user experience of MCP hosts: approval policies from tool annotations, approval dialogs, remembered decisions, accessible elicitation forms, progress and cancellation, errors and multi-server display.

MCP gives an AI app real powers: create a branch, run the tests, query a database, or delete a file. Whether people trust those powers depends almost entirely on the host's user experience: what the app shows before a tool runs, how it asks for permission, how it gathers missing information, and how it reports progress and errors.

A protocol can describe a tool as read-only or destructive, but only the host can turn that into a dialog a person can understand and trust.

**The main idea:** the host owns the **user experience** of MCP. Start with a sensible **approval policy**: usually, ask before anything that changes data. Show a clear **approval dialog** with the real arguments and the server's hints, never offer "Always allow" for destructive tools, and remember choices **per server and tool**. Turn **elicitation** schemas into accessible forms. Show **progress** with a working **Stop** control, explain **errors** in plain language, and make it obvious **which server** each tool comes from.

## A Real-World Example

A team connects its coding assistant to a dev workbench MCP server with tools for design tokens, tests, the database, and git. The first host version calls every tool silently. One afternoon, a vague request like "clean up the old branches" makes the model call `delete_branch` four times. Two of those branches still have unmerged work.

The team redesigns the host:

1. **Read-only tools** such as token and test lookups run without asking, and they appear in the activity panel.
2. **Write tools** such as `create_branch` show a dialog with the arguments and offer Allow once, Always allow, or Deny.
3. **Destructive tools** such as `delete_branch` are highlighted in red, show exactly which branch will be removed, and only offer Allow once or Deny.
4. A **Host UX** settings page lets each developer review and revoke saved "Always allow" decisions.

Nothing about the server changed. The interface made it safe.

**In simple words:** the host decides what people see and approve. Make every tool call understandable, and every risky call deliberate.

## What You Will Learn

In this lesson, you will learn:

1. How to choose an approval policy from tool annotations.
2. What a good approval dialog should show.
3. How to remember decisions safely.
4. How to design accessible elicitation forms.
5. How to show progress, cancellation, and errors.
6. How to present several servers clearly.

This chapter, **Development Workflows**, uses a new lab server, the **dev workbench**, with developer tools for design tokens and frames (UI/UX and frontend), a read-only database (backend), a test runner (testing), a secret scanner (security), and git branches. The MCP Lab also has a new **Host UX** tab where you choose an approval policy and review every decision.

## Approval Policies

Every approval policy starts with understanding the tool itself. MCP tools include annotations that help the host decide whether a call can run automatically or should require the user's approval.

### Start with the Annotations

Tools carry **annotations** such as `readOnlyHint` and `destructiveHint` (see **Tools**). The host turns those hints into a policy for when it should ask the user:

```js runnable title="Which calls need approval?"
// Approval policy: which tool calls need the user's OK?
function needsApproval(tool, policy) {
  if (policy === "all") {
    return true;
  }
  if (policy === "never") {
    return false;
  }
  return tool.annotations.readOnlyHint !== true; // "writes": ask for anything that may change data
}

const tools = [
  { name: "get_design_tokens", annotations: { readOnlyHint: true } },
  { name: "run_tests", annotations: { readOnlyHint: true } },
  { name: "create_branch", annotations: { readOnlyHint: false, destructiveHint: false } },
  { name: "delete_branch", annotations: { readOnlyHint: false, destructiveHint: true } },
  { name: "mystery_tool", annotations: {} },
];
for (const t of tools) {
  console.log(
    `${t.name.padEnd(18)} writes-policy: ${needsApproval(t, "writes") ? "ASK" : "auto"}`,
  );
}
```

Expected output:

```text
get_design_tokens  writes-policy: auto
run_tests          writes-policy: auto
create_branch      writes-policy: ASK
delete_branch      writes-policy: ASK
mystery_tool       writes-policy: ASK
```

| Policy | Behavior | When it fits |
| --- | --- | --- |
| Ask for tools that change data | Read-only tools run; others ask | The usual default |
| Ask for every tool | Every call asks | Untrusted or new servers |
| Never ask | Every call runs | Only in throwaway sandboxes |

Treat a tool with **no** annotations as "may change data." The hints come from the server, so they are **hints**, not guarantees. A host should trust them only as much as it trusts the server.

The approval dialog appears before anything reaches the server. Press **Deny** and look at the Inspector: no `tools/call` message was sent.

## Approval Dialogs

### What to Show

A clear approval dialog gives the user the information needed to make a real decision. The core question is not "Did the assistant use a tool?" It is "What exactly will happen if I approve this?"

```js runnable title="Build the dialog text from tool metadata"
// What the approval dialog should show, built from the tool's metadata.
function dialog(server, tool, args) {
  const a = tool.annotations;
  const lines = [
    `Allow "${tool.title}" from ${server}?`,
    `Arguments: ${JSON.stringify(args)}`,
  ];
  lines.push(a.readOnlyHint ? "Read-only" : "Changes data");
  if (a.destructiveHint) {
    lines.push("Destructive: cannot be undone");
  }
  const buttons = a.destructiveHint
    ? ["Allow once", "Deny"]
    : ["Allow once", "Always allow", "Deny"];
  lines.push(`Buttons: ${buttons.join(" | ")}`);
  return lines.join("\n");
}

console.log(
  dialog(
    "dev workbench",
    {
      title: "Delete a git branch",
      annotations: { readOnlyHint: false, destructiveHint: true },
    },
    { name: "feat/old-login" },
  ),
);
```

Expected output:

```text
Allow "Delete a git branch" from dev workbench?
Arguments: {"name":"feat/old-login"}
Changes data
Destructive: cannot be undone
Buttons: Allow once | Deny
```

A good dialog shows:

- **Who:** the tool's title and the server's name.
- **What:** the exact arguments, readable and not hidden or summarized away.
- **Risk:** read-only, changes data, destructive, or reaches outside systems.
- **Choices:** Allow once, Always allow (not for destructive tools), or Deny.

Never show a dialog that says "The assistant wants to use a tool" with an OK button. People click OK on anything they do not understand. The arguments are the most important part: "Delete branch feat/old-login" is a decision someone can make; "Run delete_branch" is not.

## Remembering Decisions

Saved approvals make tools easier to use, but they also affect future tool calls. Hosts need to balance convenience with safety.

"Always allow" saves clicks, but it must be **scoped** and **visible**. A host should not forget that saved approvals affect trust and safety.

```js runnable title="Scope remembered approvals"
// "Always allow" memory: scope it to server + tool, never to destructive tools.
const always = new Set();

function decide(server, tool, destructive, choice) {
  const key = `${server}/${tool}`;
  if (always.has(key) && !destructive) {
    return "auto-allowed (remembered)";
  }
  if (choice === "always" && !destructive) {
    always.add(key);
  }
  return choice === "deny" ? "denied" : "allowed";
}

console.log(decide("devtools", "create_branch", false, "always"));
console.log(decide("devtools", "create_branch", false, "deny"));
console.log(decide("other-server", "create_branch", false, "once"));
console.log(decide("devtools", "delete_branch", true, "always"));
console.log(decide("devtools", "delete_branch", true, "once"));
```

Expected output:

```text
allowed
auto-allowed (remembered)
allowed
allowed
allowed
```

- Scope the decision to **server + tool**, not to a whole server and not across servers that happen to use the same tool name.
- Never remember approvals for **destructive** tools.
- Show remembered approvals in settings, with a **Revoke** button.
- Reset them when a server's tools change in a way that matters, such as a new version or new annotations.

## Elicitation Forms

Sometimes a tool cannot continue until the user provides additional information. Instead of guessing, the host collects that information through an **elicitation** form.

When a server needs some information during a tool call, it sends an **elicitation** request with a schema (see **Elicitation**). The host then turns that schema into a form the user can complete.

```js runnable title="Schema fields to accessible controls"
// Elicitation form UX: turn the server's schema into accessible form fields.
const schema = {
  properties: {
    branch: {
      type: "string",
      title: "Branch name",
      description: "Lowercase, like feat/login",
    },
    notify: { type: "boolean", title: "Notify the team" },
    reviewer: { type: "string", enum: ["dana", "ravi", "mei"], title: "Reviewer" },
  },
  required: ["branch"],
};

for (const [key, f] of Object.entries(schema.properties)) {
  const control = f.enum
    ? `select (${f.enum.join("/")})`
    : f.type === "boolean"
      ? "checkbox"
      : "text input";
  const req = schema.required.includes(key) ? " *required" : "";
  console.log(
    `<label for="${key}">${f.title}${req}</label> -> ${control}${f.description ? ` · hint: ${f.description}` : ""}`,
  );
}
```

Expected output:

```text
<label for="branch">Branch name *required</label> -> text input · hint: Lowercase, like feat/login
<label for="notify">Notify the team</label> -> checkbox
<label for="reviewer">Reviewer</label> -> select (dana/ravi/mei)
```

- Every field gets a **visible label**, and required fields are marked.
- Enums become **selects** or radio buttons; booleans become checkboxes.
- Show the **server's name** and message so people know who is asking.
- Offer **Decline** and **Cancel** as clearly as Submit.
- Never use elicitation forms for passwords or secrets. Use the URL mode to send people to a trusted page instead.

## Progress, Cancel, and Errors

Long tools such as test runs, exports, and migrations should feel alive and stoppable. A user should always know whether a task is working, how far along it is, and how to stop it.

```js runnable title="Show progress and a Stop button"
// Progress and cancel: show progress messages and let the user stop a long tool.
const updates = [
  { progress: 1, total: 4, message: "Installing dependencies" },
  { progress: 2, total: 4, message: "Running unit tests" },
  { progress: 3, total: 4, message: "Running integration tests" },
];
for (const u of updates) {
  const pct = Math.round((u.progress / u.total) * 100);
  console.log(
    `[${"#".repeat(pct / 10).padEnd(10)}] ${String(pct).padStart(3)}% ${u.message}`,
  );
}
console.log("User pressed Stop -> client aborts the request -> server stops the work");
```

Expected output:

```text
[##        ]  25% Installing dependencies
[#####     ]  50% Running unit tests
[#######   ]  75% Running integration tests
User pressed Stop -> client aborts the request -> server stops the work
```

- Show **progress notifications** as a bar or a list of steps.
- A **Stop** button aborts the request, and the server should stop its work.
- Errors should say **what failed and what to do next**: "The test runner timed out after 60 seconds. Try running a smaller filter." Not "Error -32603".

## Several Servers

When a host connects several servers, people need to know which one did what:

- Show the **server name** next to every tool call in the activity list.
- Use **prefixed names** internally (for example, `devtools__run_tests`) to avoid collisions, but show friendly titles to people.
- Let people **disconnect** a server or turn off individual tools.

Add an **activity panel**: a simple list of every tool call in the conversation, with the server, tool, arguments, result status, and the approval decision. It answers "what did the assistant just do?" in one glance, and it is usually the first place people look when something unexpected happens.

## Common mistakes

- **Silent write tools.** Ask before anything that changes data.
- **Dialogs without arguments.** Show exactly what will happen.
- **"Always allow" for destructive tools.** Offer Allow once or Deny only.
- **Unscoped or invisible remembered approvals.** Scope to server + tool, and let people revoke them.
- **Unlabelled elicitation forms**, or forms that ask for secrets.
- **Progress with no Stop**, and errors shown as raw codes.

## Check Your Understanding

## Exercises

Each exercise ends with a value on the last line. Press **Check** to compare it with the expected answer.

## What to Remember

- The **host owns the UX** of MCP: approvals, forms, progress, and errors.
- The default policy is **ask before tools that change data**; treat missing hints as unsafe.
- Dialogs show **who, what (exact arguments), risk, and choices**.
- Remember approvals **per server + tool**, never for destructive tools, and let people **revoke** them.
- Elicitation forms are **labelled and accessible** and should never ask for secrets.
- Long tools show **progress** and a working **Stop** button; errors are written for people.

Next: **Developer Tool Servers**, where you connect design, frontend, and backend workflows through MCP.
