# connect() and client adapters

> Connect an MCP client to your server in memory, and get its tools and results in the shape Claude, OpenAI, LangChain or the Vercel AI SDK expects.

Source: https://frontmcp.dev/reference/sdk/connect

`connect()` builds your server inside your process and connects an MCP client to it in memory. The client does what a client over HTTP does, from the `initialize` handshake to every call, without a network. The adapters, `connectClaude()`, `connectOpenAI()`, `connectLangChain()` and `connectVercelAI()`, connect the same way, then convert the server's tools, and the results of calling them, to the shape each LLM API or framework takes. Use them when your own code is the MCP client: an agent loop that calls a model's API, a command-line tool, a test.

```ts
const client = await connect(config, options?)
const client = await connectClaude(config, options?)

const tools = await client.listTools()
const result = await client.callTool(name, args?, { onProgress }?)
await client.close()
```

---

## Reference

### `connect(config, options?)`

```ts agent.ts
import { connect } from "@frontmcp/sdk";
import { config } from "./config";

const client = await connect(config, {
  clientInfo: { name: "desk-agent", version: "1.0.0" },
  session: { user: { sub: "nour" } },
});
try {
  const result = await client.callTool("get_ticket", { id: "T-1" });
} finally {
  await client.close();
}
```

[See more examples below.](#usage)

#### Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| `config` | `FrontMcpConfigInput` | The configuration you give [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp#options), with `apps`. The decorated class works too. A `create()`-style configuration, with `tools` at the top level, fails with [`expected array, received undefined`](#invalid-input-expected-array-received-undefined). |
| `options.clientInfo` | `{ name, version }` | Who the client says it is. Becomes [`this.clientInfo`](https://frontmcp.dev/learn/reading-the-request) in your tools, and chooses [the format](#the-adapters) of `listTools()` and `callTool()`. Defaults to `{ name: "mcp-client", version: "1.0.0" }`. |
| `options.session.user` | `{ sub?, name?, email?, … }` | The signed-in user, as claims. [See below.](#who-is-calling) |
| `options.session.scopes` | `string[]` | The caller's scopes: `this.auth.scopes` and `this.context.authInfo.scopes`, so `this.auth.hasScope()` works. `[]` without it. New in 1.9.3: before, a `connect()` caller had no scopes. |
| `options.session.id` | `string` | The session id, `this.context.sessionId`. Defaults to `direct:` and a UUID. |
| `options.authToken` | `string` | The caller's token, `this.context.authInfo.token`. |
| `options.capabilities` | `ClientCapabilities` | The capabilities the client declares in `initialize`. |
| `options.onElicitation` | `(request) => { action, content? }` | Answers the tools' [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) questions, as the user would. Passing it declares the `elicitation` capability. [See below.](#answering-the-tools-questions) |
| `options.workerEnv` | `Record<string, unknown>` | Platform bindings, like a Cloudflare Worker's `env`, for every request of this client: the tools, resources, prompts, jobs and agents it calls read them as [`this.workerEnv`](https://frontmcp.dev/reference/sdk/contexts#thisworkerenv). Never copied into `process.env`. A client from [`server.connect()`](https://frontmcp.dev/reference/sdk/create#passing-a-workers-bindings) without its own gets the server's. The adapters take it too. New in 1.9.4. |
| `options.app` | `string` | The id of an app with an endpoint of its own: each app's with `splitByApp`, a `standalone` app's otherwise. The client connects to that endpoint instead of the main one. [See below.](#connecting-to-one-app) New in 1.9.3. |

#### Returns

A promise of a [`DirectClient`](#the-directclient), connected and past the handshake.

#### One server per configuration object

`connect()` builds the server the first time it sees a configuration object, and every `connect()` with the **same object** while one of its clients is open connects to that server, whichever `app` it names. Providers, and anything else the server keeps, are shared between those clients. A copy, like `{ ...config }`, builds a separate server. [The sharing example](#sharing-one-server-between-clients) shows both.

`close()` ends one client, and the server goes on serving the others. When the last client closes, the server is disposed, every endpoint of it, and the next `connect()` with that object builds a new one, with new providers. Keep a client open for as long as the server's state should last. (Changed in 1.9.2: before, the server stayed in memory after its last client closed.)

#### Who is calling

| Options | `this.auth` in your tools |
| --- | --- |
| No `session.user` | An anonymous caller: `isAnonymous` is `true`, and `user.sub` is `""`. |
| `session: { user: { sub: "nour", roles: ["lead"] } }` | `user.sub` is `"nour"`, `isAnonymous` is `false`, `hasRole("lead")` is `true`. `scopes` is `[]`. |
| `session: { user: { sub: "nour" }, scopes: ["tickets:write"] }` | `scopes` is `["tickets:write"]`, so `hasScope("tickets:write")` is `true`. A `scope` claim in `user` isn't read. |
| `authToken: "…"` alone | Still anonymous. The token is passed along as `this.context.authInfo.token`, never checked or decoded. |

Nothing checks the user or the scopes either: in-process, your code is the authentication, so pass only what your code has verified. `this.context.authInfo.clientId` is empty; read the caller from `this.auth`.

### The adapters

Each adapter is `connect()` with a preset `clientInfo`, which picks a format for `listTools()` and `callTool()`. They take the same options as `connect()`, except `clientInfo`.

| Function | Client name | `listTools()` returns | `callTool()` returns |
| --- | --- | --- | --- |
| `connect()` | `mcp-client` | MCP tools: `{ name, description, inputSchema, … }[]` | The MCP result: `content`, `structuredContent`, `isError`, `_meta`. |
| `connectClaude()` | `claude` | `{ name, description, input_schema }[]`, for the Messages API's `tools`. | The result's `content` blocks, for a `tool_result`. |
| `connectOpenAI()` | `openai` | `{ type: "function", function: { name, description, parameters, strict: true } }[]`, for Chat Completions' `tools`. | The text, parsed: an object for an object result, a string for a string. |
| `connectLangChain()` | `langchain` | `{ name, description, schema }[]`. | As `connectOpenAI()`. |
| `connectVercelAI()` | `vercel-ai` | `{ [name]: { description, parameters } }`, one object keyed by tool name. | As `connectOpenAI()`. |

The format follows the client's name, so `connect(config, { clientInfo: { name: "gpt-researcher", version: "1.0.0" } })` gets OpenAI's format too. A name containing `openai` or `gpt` means OpenAI; `claude` or `anthropic`, Claude; `langchain`, LangChain; `vercel` or `ai-sdk`, the Vercel AI SDK; anything else, plain MCP. `client.getDetectedPlatform()` says which one applies. The same name also sets `this.platform` in your tools, by FrontMCP's own rules: `claude` is `"claude"`, `mcp-client` is `"generic-mcp"`.

What the conversion changes:

- **A failed call rejects**, because these formats have nowhere to put `isError`. The error is a `ToolCallError`: its `message` is the tool's error text, `toolName` the tool's name, `code` `"TOOL_CALL_ERROR"`, and `result` the MCP result, with `isError` and the tool's `_meta.code`. [Catch it and tell the model.](#keeping-failures-visible-to-claude) Plain `connect()` returns the failure as a result. (Changed in 1.9.2: before, the adapters returned a failure exactly like a successful result.)
- **Every block but the text**, with OpenAI and LangChain: images are dropped, and several text blocks are joined with newlines. The Vercel format keeps images: a result with several blocks becomes `{ text: [...], images: [...] }`. Claude's keeps every block.
- **A predictable type.** An object result is returned as an object, except when it has a `text` property holding a string: then it's returned as its JSON text. A tool returning `{ text: "Call the customer back" }` gives you `'{"text":"Call the customer back"}'`.

`connectOpenAI()` also changes each schema: every object gets `additionalProperties: false`, and each tool gets `strict: true`. Its `required` list isn't changed, though, and OpenAI's strict mode requires every property to be in `required`, so a tool with an optional argument [may be refused](#openai-refuses-a-tools-schema).

### The `DirectClient`

`connect()`, the adapters and [`DirectMcpServer.connect()`](https://frontmcp.dev/reference/sdk/create#the-directmcpserver) all return one.

Tools, resources and prompts. Only the two tool methods are converted; the rest return MCP results as they are:

| Method | Returns |
| --- | --- |
| `listTools()` | The tools, in [the client's format](#the-adapters). Every page: a server with more than 40 tools returns them all. |
| `callTool(name, args?, options?)` | The result, in the client's format. With plain MCP results, a failure is a result too; with an adapter's format, it rejects with `ToolCallError`. `options.onProgress` gets the tool's [progress](#hearing-a-tools-progress). |
| `listResources()`, `listResourceTemplates()` | `{ resources }`, `{ resourceTemplates }`, every page of them. |
| `readResource(uri)` | `{ contents }`. An unknown URI throws `McpError` `-32002`. |
| `listPrompts()`, `getPrompt(name, args?)` | `{ prompts }`, every page of them, and `{ description, messages }`. An unknown prompt throws `McpError` `-32602`. |
| `complete({ ref, argument })` | Suggestions for a prompt or resource argument. |
| `subscribeResource(uri)`, `unsubscribeResource(uri)`, `onResourceUpdated(handler)` | Resource subscriptions. `subscribeResource()` fails as `readResource()` would for a URI the server can't serve, like one no resource matches (`-32002`). `onResourceUpdated` returns a function that removes the handler. |

Changed in 1.8.7: `listResources()`, `listResourceTemplates()` and `listPrompts()` follow every page, as `listTools()` already did; before, they returned one. [`create()`](https://frontmcp.dev/reference/sdk/create#listing-more-than-one-page-of-tools) shows paging with 45 tools.

Skills, jobs and workflows. These parse the result for you:

| Method | Returns |
| --- | --- |
| `listSkills(options?)` | `{ skills, total, hasMore }`. On a server without skills, it throws `McpError` `-32601` `Method not found`. |
| `searchSkills(query, options?)` | `{ skills, total, hasMore, guidance }`, each skill with a `score` and its tools' availability. |
| `loadSkills(ids, options?)` | `{ skills, summary, nextSteps }`, each skill with its instructions and tools, and with `activateSession: true` a `session`, like `{ activated: false }` (since 1.9.3). |
| `listSep2640Skills()`, `readSkillTextUri(uri)` | The `skill://index.json` entries, and the text of a `skill://` resource. |
| `listJobs()`, `executeJob(name, input?, options?)`, `getJobStatus(runId)` | `{ jobs, count }`; `{ runId, state, result?, logs }`; the run's status. |
| `listWorkflows()`, `executeWorkflow(name, input?, options?)`, `getWorkflowStatus(runId)` | The same for [workflows](https://frontmcp.dev/reference/sdk/workflow). |

About the connection:

| Method | Returns |
| --- | --- |
| `getSessionId()` | `session.id`, or the generated one. |
| `getClientInfo()`, `getServerInfo()`, `getCapabilities()` | The client's info, and the server's info and capabilities from the handshake. |
| `getDetectedPlatform()` | `"openai"`, `"claude"`, `"langchain"`, `"vercel-ai"` or `"raw"`. |
| `close()` | Ends the connection, and disposes the server when no other client is connected to it. A client from a [`DirectMcpServer`](https://frontmcp.dev/reference/sdk/create#the-directmcpserver)'s `connect()` never disposes that server: `server.dispose()` does. Calls after it throw `Not connected`; a second `close()` does nothing. |
| `onNotification(handler)` | Registers a handler for notifications from the server, like `notifications/tools/list_changed` when a tool is added with [`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool) or removed, and a tool's log messages once you've called `setLogLevel()`. Returns a function that removes it. See [Hearing that the tools changed](#hearing-that-the-tools-changed). |
| `setLogLevel(level)` | Sets the lowest level of log message the server sends this client, like `"info"`. Until you call it, a tool's [`this.notify()`](https://frontmcp.dev/reference/sdk/notify) sends this client nothing. See [Hearing a tool's log messages](#hearing-a-tools-log-messages). |
| `onElicitation(handler)` | Sets the handler that answers `this.elicit()`, replacing the one passed to `connect()`. It's only asked if the client declared elicitation when it connected. Returns a function that removes it. See [Answering the tools' questions](#answering-the-tools-questions). |
| `submitElicitationResult(id, response)` | Answers a question from [the fallback flow](#a-tool-that-asks-the-user-returns-this-tool-requires-user-input-to-continue), whose `elicitId` is in the result's `_meta.elicitationPending`. It calls the `sendElicitationResult` tool, which runs the waiting tool again with the answer, and returns that tool's result, in the client's format. Changed in 1.9.3: before, it failed with `McpError` `-32601` `Method not found`. |

#### Errors

With `connect()`, a tool that fails returns a result with `isError: true` and its `_meta.code`, exactly as over HTTP: `this.fail()` codes, `INVALID_INPUT` for bad arguments, `TOOL_NOT_FOUND` for an unknown tool. In development, `_meta` also has the error's `stack`. The adapters reject with a `ToolCallError` instead, whose `result` is that same result.

Resources and prompts throw: `McpError` with the JSON-RPC code, like `-32002` for an unknown resource.

#### Caveats

- **The adapters reject a failed call** with `ToolCallError`. Catch it around `callTool()`, or the first tool that fails ends your agent loop.
- **Nothing is checked.** `session.user`, `session.scopes` and `authToken` are taken as given; without a user, the caller is anonymous.
- **One server per configuration object**, shared by every client connected with it, and disposed when the last of them closes.
- **Requests carry no headers**: `this.context.metadata` is `{ customHeaders: {} }`, and every call starts a new trace.
- **What reaches back to the client.** `this.elicit()` asks the `onElicitation` handler, with `elicitation: { enabled: true }` on the server; without a handler, the tool returns FrontMCP's [fallback](#a-tool-that-asks-the-user-returns-this-tool-requires-user-input-to-continue). `this.notify()` sends once the client has called `setLogLevel()`. `this.progress()` sends to a call made with `onProgress`, and returns `false` in any other. The server's own notifications, like `notifications/tools/list_changed`, arrive.
- For a server with no client at all, where failures are exceptions, use [`create()` or `createDirect()`](https://frontmcp.dev/reference/sdk/create).

---

## Usage

### Keeping failures visible to Claude

An agent loop runs each `tool_use` block Claude asks for and sends back a `tool_result`. `connectClaude()` gives you the tools in Claude's shape, and its `callTool()` returns the content blocks a `tool_result` takes. When the tool fails, `callTool()` rejects with a `ToolCallError` instead. Catch it, and send the error's content blocks with `is_error: true`, so the model knows the call failed and can try something else:

```ts agent.ts active
import { ToolCallError, connectClaude } from "@frontmcp/sdk";
import { config } from "./config";

type ToolUse = { type: "tool_use"; id: string; name: string; input: Record<string, unknown> };

// The `tools` to send to Claude's Messages API.
export async function claudeTools() {
  const client = await connectClaude(config);
  try {
    return await client.listTools();
  } finally {
    await client.close();
  }
}

// The `tool_result` block to send back for one `tool_use` block.
export async function toolResult(block: ToolUse) {
  const client = await connectClaude(config);
  try {
    const content = await client.callTool(block.name, block.input);
    return { type: "tool_result", tool_use_id: block.id, content, is_error: false };
  } catch (err) {
    if (!(err instanceof ToolCallError)) throw err;
    return { type: "tool_result", tool_use_id: block.id, content: err.result.content, is_error: true };
  } finally {
    await client.close();
  }
}
```

```ts agent.test.ts
import { test, expect } from "@frontmcp/testing";
import { ToolCallError, connectClaude } from "@frontmcp/sdk";
import { claudeTools, toolResult } from "./agent";
import { config } from "./config";

test("tools come in the Messages API's shape", async () => {
  expect(await claudeTools()).toEqual([
    { name: "get_ticket", description: "Get one support ticket by its id", input_schema: expect.objectContaining({ type: "object", required: ["id"] }) },
  ]);
});

test("a good call is a tool_result with its content blocks", async () => {
  expect(await toolResult({ type: "tool_use", id: "toolu_1", name: "get_ticket", input: { id: "T-1" } })).toEqual({
    type: "tool_result",
    tool_use_id: "toolu_1",
    content: [{ type: "text", text: '{"id":"T-1","title":"Cannot log in","status":"open"}' }],
    is_error: false,
  });
});

test("a failed call is a tool_result with is_error", async () => {
  expect(await toolResult({ type: "tool_use", id: "toolu_2", name: "get_ticket", input: { id: "T-9" } })).toEqual({
    type: "tool_result",
    tool_use_id: "toolu_2",
    content: [{ type: "text", text: "There's no ticket T-9." }],
    is_error: true,
  });
});

test("connectClaude() rejects a failed call with ToolCallError", async () => {
  const client = await connectClaude(config);
  try {
    const error = await client.callTool("get_ticket", { id: "T-9" }).catch((err: unknown) => err);
    expect(error).toBeInstanceOf(ToolCallError);
    expect(error).toMatchObject({
      message: "There's no ticket T-9.",
      toolName: "get_ticket",
      code: "TOOL_CALL_ERROR",
      result: { isError: true, _meta: { code: "TICKET_NOT_FOUND" } },
    });
  } finally {
    await client.close();
  }
});
```

```ts config.ts
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
};
```

```ts help-desk.app.ts
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }];

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`, "TICKET_NOT_FOUND"));
    return ticket;
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
export class HelpDesk {}
```

The Messages API call itself needs a key and a network, so it isn't here; `claudeTools()` goes in its `tools`, and each `toolResult()` in the next user message. [Running FrontMCP Anywhere](https://frontmcp.dev/learn/running-frontmcp-anywhere) builds the same agent step by step.

Each function here connects and closes, and with no other client open, each `close()` disposes the server, so every call builds it again. That's fine for a few calls. An agent that runs for a while connects once, and closes when it's done.

### Giving tools to OpenAI

`connectOpenAI()` returns tools ready for Chat Completions' `tools`. Its `callTool()` results need care: a tool message's `content` is a string, and the adapter returns an object for most results, a string for others, and rejects a failed call with `ToolCallError`. Taking the text from `connect()` gives you the same string every time, failures included:

```ts openai.ts active
import { connect, connectOpenAI } from "@frontmcp/sdk";
import { config } from "./config";

type ToolCall = { id: string; function: { name: string; arguments: string } };
type CallToolResult = { content: { type: string; text?: string }[]; isError?: boolean };

export async function openAITools() {
  const client = await connectOpenAI(config);
  try {
    return await client.listTools();
  } finally {
    await client.close();
  }
}

// The `tool` message to send back for one of the model's tool calls.
export async function toolMessage(call: ToolCall) {
  const client = await connect(config);
  try {
    const result = (await client.callTool(call.function.name, JSON.parse(call.function.arguments))) as CallToolResult;
    const text = result.content.flatMap((block) => (block.type === "text" && block.text ? [block.text] : [])).join("\n");
    return { role: "tool", tool_call_id: call.id, content: result.isError ? `Error: ${text}` : text };
  } finally {
    await client.close();
  }
}
```

```ts openai.test.ts
import { test, expect } from "@frontmcp/testing";
import { connectOpenAI } from "@frontmcp/sdk";
import { openAITools, toolMessage } from "./openai";
import { config } from "./config";

test("tools are functions, with strict schemas", async () => {
  const tools = (await openAITools()) as { function: { name: string; parameters: Record<string, unknown>; strict: boolean } }[];
  const search = tools.find((tool) => tool.function.name === "search_tickets")!;
  expect(search.function.strict).toBe(true);
  expect(search.function.parameters).toMatchObject({
    type: "object",
    additionalProperties: false,
    required: ["query"], // 🚩 `status` is optional, so it isn't listed
  });
});

test("tool messages are always text", async () => {
  const call = (name: string, args: object) => ({ id: "call_1", function: { name, arguments: JSON.stringify(args) } });
  expect(await toolMessage(call("search_tickets", { query: "log" }))).toEqual({
    role: "tool",
    tool_call_id: "call_1",
    content: '{"tickets":[{"id":"T-1","title":"Cannot log in"}]}',
  });
  expect((await toolMessage(call("latest_note", {}))).content).toBe('{"text":"Call the customer back"}');
  expect((await toolMessage(call("search_tickets", {}))).content).toMatch(/^Error: /);
});

test("🚩 the adapter's results change type with the result's shape", async () => {
  const client = await connectOpenAI(config);
  try {
    expect(await client.callTool("search_tickets", { query: "log" })).toEqual({ tickets: [{ id: "T-1", title: "Cannot log in" }] });
    expect(await client.callTool("latest_note", {})).toBe('{"text":"Call the customer back"}');
  } finally {
    await client.close();
  }
});
```

```ts config.ts
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
};
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "search_tickets",
  description: "Search support tickets by title",
  inputSchema: { query: z.string(), status: z.enum(["open", "closed"]).optional() },
})
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [{ id: "T-1", title: "Cannot log in" }].filter((t) => t.title.toLowerCase().includes(query)) };
  }
}

@Tool({ name: "latest_note", description: "The newest internal note", inputSchema: {} })
class LatestNote extends ToolContext {
  async execute() {
    return { text: "Call the customer back" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, LatestNote] })
export class HelpDesk {}
```

### Tools for LangChain and the Vercel AI SDK

`connectLangChain()` and `connectVercelAI()` return plain data: tool names, descriptions and JSON Schemas, in each library's layout. Build the library's own tool objects from them. Their `callTool()` results follow the same rules as `connectOpenAI()`'s, so the `toolMessage()` approach above works for them too. Any client whose name contains `gpt` gets OpenAI's format:

```ts formats.test.ts active
import { test, expect } from "@frontmcp/testing";
import { connect, connectLangChain, connectVercelAI } from "@frontmcp/sdk";
import { config } from "./config";

test("LangChain: a list of { name, description, schema }", async () => {
  const client = await connectLangChain(config);
  try {
    expect(await client.listTools()).toEqual([
      { name: "get_ticket", description: "Get one support ticket by its id", schema: expect.objectContaining({ type: "object", required: ["id"] }) },
    ]);
    expect(await client.callTool("get_ticket", { id: "T-1" })).toEqual({ id: "T-1", title: "Cannot log in" });
  } finally {
    await client.close();
  }
});

test("Vercel AI SDK: one object keyed by tool name", async () => {
  const client = await connectVercelAI(config);
  try {
    expect(await client.listTools()).toEqual({
      get_ticket: { description: "Get one support ticket by its id", parameters: expect.objectContaining({ type: "object" }) },
    });
  } finally {
    await client.close();
  }
});

test("the client's name picks the format", async () => {
  const client = await connect(config, { clientInfo: { name: "gpt-researcher", version: "1.0.0" } });
  try {
    expect(client.getDetectedPlatform()).toBe("openai");
    expect(await client.listTools()).toEqual([expect.objectContaining({ type: "function" })]);
  } finally {
    await client.close();
  }
});
```

```ts config.ts
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
};
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
export class HelpDesk {}
```

### Reading resources and prompts

Only the tool methods are converted. Resources and prompts come back as MCP results, from every adapter, and failures throw an `McpError` with the JSON-RPC code:

```ts resources.test.ts active
import { test, expect } from "@frontmcp/testing";
import { connect } from "@frontmcp/sdk";
import { config } from "./config";

test("resources and prompts are MCP results", async () => {
  const client = await connect(config);
  try {
    expect(await client.readResource("tickets://open")).toEqual({
      contents: [{ uri: "tickets://open", mimeType: "application/json", text: '{"tickets":["T-1"]}' }],
    });
    expect((await client.getPrompt("triage", { id: "T-1" })).messages).toEqual([
      { role: "user", content: { type: "text", text: "Triage ticket T-1." } },
    ]);
  } finally {
    await client.close();
  }
});

test("failures throw, with the JSON-RPC code", async () => {
  const client = await connect(config);
  try {
    await expect(client.readResource("tickets://closed")).rejects.toMatchObject({ code: -32002 });
    await expect(client.subscribeResource("tickets://closed")).rejects.toMatchObject({ code: -32002 });
    await expect(client.getPrompt("escalate", {})).rejects.toMatchObject({ code: -32602 });
    await expect(client.listSkills()).rejects.toMatchObject({ code: -32601 }); // this server has no skills
    await expect(client.listJobs()).rejects.toThrow("is not valid JSON"); // …and no jobs
    expect(await client.callTool("reopen_ticket", {})).toMatchObject({ isError: true, _meta: { code: "TOOL_NOT_FOUND" } });
  } finally {
    await client.close();
  }
});
```

```ts config.ts
import { App, Prompt, PromptContext, Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({ uri: "tickets://open", name: "open-tickets", mimeType: "application/json" })
class OpenTickets extends ResourceContext {
  async execute() {
    return { tickets: ["T-1"] };
  }
}

@Prompt({ name: "triage", description: "Triage a ticket", arguments: [{ name: "id", required: true }] })
class Triage extends PromptContext {
  async execute({ id }: { id: string }) {
    return { messages: [{ role: "user" as const, content: { type: "text" as const, text: `Triage ticket ${id}.` } }] };
  }
}

@App({ id: "help-desk", name: "Help Desk", resources: [OpenTickets], prompts: [Triage] })
export class HelpDesk {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] };
```

### Choosing who the client is

`clientInfo` and `session` decide what your tools see. Without a user, the caller is anonymous, and without `session.scopes`, the caller has none. `workerEnv` hands them a Worker's bindings (since 1.9.4):

```ts whoami.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "whoami", description: "Who is calling, and from which client. For debugging.", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return {
      user: this.auth.user.sub,
      anonymous: this.auth.isAnonymous,
      scopes: this.auth.scopes,
      canWrite: this.auth.hasScope("tickets:write"),
      client: this.clientInfo?.name ?? null,
      platform: this.platform,
      session: this.context.sessionId,
      bindings: Object.keys(this.workerEnv ?? {}),
    };
  }
}
```

```ts whoami.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, connect } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";

@App({ id: "desk", name: "Desk", tools: [WhoAmI] })
class Desk {}

const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] };

async function whoami(options?: Parameters<typeof connect>[1]) {
  const client = await connect(config, options);
  try {
    return (await client.callTool("whoami", {})) as { structuredContent: Record<string, unknown> };
  } finally {
    await client.close();
  }
}

test("by default: anonymous, through a generic client", async () => {
  expect((await whoami()).structuredContent).toMatchObject({ user: "", anonymous: true, client: "mcp-client", platform: "generic-mcp" });
});

test("session.user signs the caller in, and clientInfo names the client", async () => {
  const result = await whoami({
    clientInfo: { name: "cursor", version: "1.4.0" },
    session: { id: "desk-42", user: { sub: "nour" } },
  });
  expect(result.structuredContent).toEqual({ user: "nour", anonymous: false, scopes: [], canWrite: false, client: "cursor", platform: "cursor", session: "desk-42", bindings: [] });
});

test("a token alone signs no one in", async () => {
  expect((await whoami({ authToken: "tok_123" })).structuredContent).toMatchObject({ user: "", anonymous: true });
});

test("session.scopes are the caller's scopes", async () => {
  expect((await whoami({ session: { user: { sub: "nour" } } })).structuredContent).toMatchObject({ scopes: [], canWrite: false });
  const result = await whoami({ session: { user: { sub: "nour", scope: "tickets:read" }, scopes: ["tickets:write"] } });
  expect(result.structuredContent).toMatchObject({ scopes: ["tickets:write"], canWrite: true });
});

test("workerEnv is what `this.workerEnv` reads", async () => {
  expect((await whoami({ workerEnv: { DESK: "berlin" } })).structuredContent).toMatchObject({ bindings: ["DESK"] });
});
```

### Sharing one server between clients

Every `connect()` with the same configuration object connects to one server, for as long as one of its clients is open. This tool counts calls in a provider, so the count shows which server answered:

```ts counter.ts active
import { App, Provider, Tool, ToolContext } from "@frontmcp/sdk";

@Provider({ name: "call-counter" })
export class CallCounter {
  private calls = 0;
  next() {
    return ++this.calls;
  }
}

@Tool({ name: "count_call", description: "Count this call", inputSchema: {} })
export class CountCall extends ToolContext {
  async execute() {
    return { call: this.get(CallCounter).next(), client: this.clientInfo?.name ?? null };
  }
}

@App({ id: "desk", name: "Desk", tools: [CountCall], providers: [CallCounter] })
export class Desk {}
```

```ts sharing.test.ts
import { test, expect } from "@frontmcp/testing";
import { connect } from "@frontmcp/sdk";
import { Desk } from "./counter";

type Result = { structuredContent: { call: number; client: string | null } };

test("the same object means the same server; a copy means another", async () => {
  const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] };
  const a = await connect(config);
  const b = await connect(config);
  const c = await connect({ ...config });
  expect(((await a.callTool("count_call")) as Result).structuredContent.call).toBe(1);
  expect(((await b.callTool("count_call")) as Result).structuredContent.call).toBe(2);
  expect(((await c.callTool("count_call")) as Result).structuredContent.call).toBe(1);
  for (const client of [a, b, c]) await client.close();
});

test("closing one client leaves the server to the others", async () => {
  const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] };
  const a = await connect(config, { clientInfo: { name: "desk-agent", version: "1.0.0" } });
  const b = await connect(config, { clientInfo: { name: "desk-cli", version: "1.0.0" } });
  expect(((await b.callTool("count_call")) as Result).structuredContent).toEqual({ call: 1, client: "desk-cli" });
  await a.close();
  expect(((await b.callTool("count_call")) as Result).structuredContent).toEqual({ call: 2, client: "desk-cli" });
  await b.close();
  await expect(b.callTool("count_call")).rejects.toThrow("Not connected");
});

test("after the last client closes, the next connect() builds a new server", async () => {
  const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] };
  const first = await connect(config);
  await first.callTool("count_call");
  expect(((await first.callTool("count_call")) as Result).structuredContent.call).toBe(2);
  await first.close();

  const second = await connect(config);
  expect(((await second.callTool("count_call")) as Result).structuredContent.call).toBe(1);
  await second.close();
});
```

Share a server when clients should see the same state, and keep a client open for as long as that state matters. When they should be independent, give each a copy of the configuration.

### Connecting to one app

With [`splitByApp: true`](https://frontmcp.dev/reference/server/apps#endpoints-standalone-and-splitbyapp), each app is an endpoint of its own, and so is a `standalone` app. Over HTTP each is at `<entryPath>/<app id>`. In-process, `app` picks one; without it, the client gets the main endpoint, which with `splitByApp` is the first app's:

```ts apps.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "open" };
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice", inputSchema: { invoice: z.string() } })
class RefundInvoice extends ToolContext {
  async execute({ invoice }: { invoice: string }) {
    return { invoice, refunded: true };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
export class HelpDesk {}

@App({ id: "billing", name: "Billing", tools: [RefundInvoice] })
export class Billing {}

export const config = { info: { name: "support", version: "1.0.0" }, apps: [HelpDesk, Billing], splitByApp: true };
```

```ts apps.test.ts
import { test, expect } from "@frontmcp/testing";
import { connect, connectClaude } from "@frontmcp/sdk";
import { config } from "./apps";

const toolNames = (tools: unknown) => (tools as { name: string }[]).map((tool) => tool.name);

test("`app` picks the endpoint; without it, the first app's", async () => {
  const desk = await connect(config);
  const billing = await connect(config, { app: "billing" });
  const claude = await connectClaude(config, { app: "billing" });
  try {
    expect(toolNames(await desk.listTools())).toEqual(["get_ticket"]);
    expect(toolNames(await billing.listTools())).toEqual(["refund_invoice"]);
    expect(toolNames(await claude.listTools())).toEqual(["refund_invoice"]);
  } finally {
    for (const client of [desk, billing, claude]) await client.close();
  }
});

test("an app without an endpoint of its own is refused", async () => {
  const shared = { info: config.info, apps: config.apps };
  await expect(connect(shared, { app: "billing" })).rejects.toMatchObject({
    name: "ScopeConfigurationError",
    message: expect.stringContaining('No endpoint serves app "billing" on its own.'),
  });
});
```

The clients share one server, as clients of one configuration object always do, whichever endpoint each is on. The adapters take `app` too. [`createDirect()`](https://frontmcp.dev/reference/sdk/create#frontmcpinstancecreatedirectconfig) takes the same option. New in 1.9.3: before, `connect()` reached only the main endpoint, so with `splitByApp` only the first app's tools could be called in-process.

### Answering the tools' questions

A tool that asks the user with [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) needs a client that answers. Pass `onElicitation` to `connect()`: FrontMCP calls it with each question, and the tool gets what it returns, as if the user had filled in the form. The server needs `elicitation: { enabled: true }`, as over HTTP:

```ts close-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { id, status: answer.status === "accept" && answer.content?.confirm ? "closed" : "open" };
  }
}
```

```ts elicit.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, Tool, ToolContext, connect, z } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";

@Tool({ name: "connect_billing", description: "Connect the user's billing account", inputSchema: {} })
class ConnectBilling extends ToolContext {
  async execute() {
    const url = "https://billing.example/connect?state=abc123";
    const answer = await this.elicit("Sign in to your billing account", z.object({}), { mode: "url", url, elicitationId: "billing-abc123" });
    return { connected: answer.status === "accept" };
  }
}

@App({ id: "desk", name: "Desk", tools: [CloseTicket, ConnectBilling] })
class Desk {}

const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk], elicitation: { enabled: true } };

test("the handler gets the question, and the tool gets the answer", async () => {
  const questions: unknown[] = [];
  const client = await connect(config, {
    onElicitation: (question) => {
      questions.push(question);
      return { action: "accept", content: { confirm: true } };
    },
  });
  try {
    expect(await client.callTool("close_ticket", { id: "T-1" })).toMatchObject({ structuredContent: { id: "T-1", status: "closed" } });
    expect(questions).toEqual([
      {
        mode: "form",
        message: "Close ticket T-1?",
        requestedSchema: { type: "object", properties: { confirm: { type: "boolean" } }, required: ["confirm"] },
        elicitId: expect.stringMatching(/^elicit-/),
        expiresAt: expect.any(Number),
      },
    ]);
    const [{ expiresAt }] = questions as { expiresAt: number }[];
    expect(expiresAt - Date.now()).toBeGreaterThan(290_000); // five minutes
  } finally {
    await client.close();
  }
});

test("in URL mode, the handler gets the page to open", async () => {
  const questions: unknown[] = [];
  const client = await connect(config, {
    onElicitation: (question) => {
      questions.push(question);
      return { action: "accept" };
    },
  });
  try {
    expect(await client.callTool("connect_billing")).toMatchObject({ structuredContent: { connected: true } });
    expect(questions).toEqual([
      expect.objectContaining({ mode: "url", url: "https://billing.example/connect?state=abc123", elicitId: "billing-abc123" }),
    ]);
  } finally {
    await client.close();
  }
});

test("an answer that doesn't fit the schema fails the call", async () => {
  const client = await connect(config, { onElicitation: () => ({ action: "accept", content: { confirm: "yes" } }) });
  try {
    expect(await client.callTool("close_ticket", { id: "T-3" })).toMatchObject({
      isError: true,
      content: [{ type: "text", text: expect.stringContaining("Invalid elicitation result content") }],
      _meta: { code: "INVALID_INPUT" },
    });
  } finally {
    await client.close();
  }
});

test("a decline, or a handler that throws, reaches the tool as a decline", async () => {
  for (const onElicitation of [() => ({ action: "decline" as const }), () => Promise.reject(new Error("no one at the desk"))]) {
    const client = await connect(config, { onElicitation });
    try {
      expect(await client.callTool("close_ticket", { id: "T-2" })).toMatchObject({ structuredContent: { id: "T-2", status: "open" } });
    } finally {
      await client.close();
    }
  }
});
```

The handler gets the question's `mode`, `message` and `requestedSchema`, its `elicitId`, and `expiresAt`, when the tool stops waiting, in milliseconds since 1970 (five minutes from now, unless the tool set a `ttl`). In URL mode it also gets the `url` to send the user to, and `elicitId` is the tool's `elicitationId`. (Changed in 1.9.3: before, `elicitId` and `expiresAt` were missing.) An accepted answer is checked against the tool's schema: `content` that doesn't fit fails the call with `INVALID_INPUT` and `Invalid elicitation result content`. `client.onElicitation(handler)` replaces the handler later, as long as the client declared elicitation when it connected, with `onElicitation` or `capabilities: { elicitation: { form: {} } }`. Without either, the tool gets [FrontMCP's fallback](#a-tool-that-asks-the-user-returns-this-tool-requires-user-input-to-continue) instead of asking. The `elicitation:request` and `elicitation:result` [flows](https://frontmcp.dev/reference/server/flows#other-flows) run for each question, so their hooks see it. (New in 1.9.2: before, `this.elicit()` couldn't reach a client connected with `connect()`.)

### Hearing a tool's log messages

A tool's [`this.notify()`](https://frontmcp.dev/reference/sdk/notify) sends a log message only to a client that asked for them. Call `setLogLevel()` with the lowest level you want, and the messages arrive through `onNotification`:

```ts close-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    await this.notify(`Closing ${id}`);
    await this.notify(`Loaded ${id} from the database`, "debug");
    return { id, status: "closed" };
  }
}
```

```ts log.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, connect } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";

@App({ id: "desk", name: "Desk", tools: [CloseTicket] })
class Desk {}

const settle = () => new Promise((resolve) => setTimeout(resolve, 20));

test("after setLogLevel(), messages at that level and up arrive", async () => {
  const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
  const messages: unknown[] = [];
  client.onNotification((notification: { method: string; params?: unknown }) => {
    if (notification.method === "notifications/message") messages.push(notification.params);
  });
  try {
    await client.callTool("close_ticket", { id: "T-1" }); // no level yet: nothing is sent
    await client.setLogLevel("info");
    await client.callTool("close_ticket", { id: "T-2" });
    await settle();
    expect(messages).toEqual([{ level: "info", logger: "close_ticket", data: { message: "Closing T-2" } }]);
  } finally {
    await client.close();
  }
});
```

`this.notify()` returns `false` for each message it didn't send. (Changed in 1.9.2: before, `setLogLevel()` always failed.)

### Hearing a tool's progress

A tool's [`this.progress()`](https://frontmcp.dev/reference/sdk/progress) reports only to a call that asks for it. Pass `onProgress` in `callTool()`'s third argument: the call then carries a progress token, and the function gets each update as `{ progress, total, message }`. A call without it gets none, and `this.progress()` returns `false`:

```ts bulk-close.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "bulk_close", description: "Close several tickets", inputSchema: { ids: z.array(z.string()) } })
export class BulkClose extends ToolContext {
  async execute({ ids }: { ids: string[] }) {
    let progressSent = 0;
    for (const [i, id] of ids.entries()) {
      if (await this.progress(i + 1, ids.length, `Closed ${id}`)) progressSent++;
    }
    return { closed: ids, progressSent };
  }
}
```

```ts progress.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, connect } from "@frontmcp/sdk";
import { BulkClose } from "./bulk-close.tool";

@App({ id: "desk", name: "Desk", tools: [BulkClose] })
class Desk {}

test("onProgress gets each update", async () => {
  const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
  const updates: unknown[] = [];
  try {
    const result = await client.callTool("bulk_close", { ids: ["T-1", "T-2"] }, { onProgress: (update) => updates.push(update) });
    expect(result).toMatchObject({ structuredContent: { closed: ["T-1", "T-2"], progressSent: 2 } });
    expect(updates).toEqual([
      { progress: 1, total: 2, message: "Closed T-1" },
      { progress: 2, total: 2, message: "Closed T-2" },
    ]);
    expect(await client.callTool("bulk_close", { ids: ["T-3"] })).toMatchObject({ structuredContent: { progressSent: 0 } });
  } finally {
    await client.close();
  }
});
```

Changed in 1.9.3: before, `callTool()` took no options and sent no progress token, so `this.progress()` always returned `false` under `connect()`.

### Hearing that the tools changed

A server from [`create()`](https://frontmcp.dev/reference/sdk/create) can add a tool while it runs, with `registerTool()` (new in 1.9). Each client connected to it with `server.connect()` gets `notifications/tools/list_changed`, then and when the tool is removed, so it knows to list the tools again:

```ts ping.tool.ts active
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "ping", description: "Check that the server is up", inputSchema: {} })
export class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}
```

```ts list-changed.test.ts
import { test, expect } from "@frontmcp/testing";
import { create } from "@frontmcp/sdk";
import { Ping } from "./ping.tool";

const settle = () => new Promise((resolve) => setTimeout(resolve, 20));

test("the client hears when a tool is added and removed", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [Ping] });
  const client = await server.connect();
  const heard: string[] = [];
  client.onNotification((notification: { method: string }) => {
    heard.push(notification.method);
  });
  try {
    const remove = await server.registerTool({
      name: "get_cart",
      description: "The items in the shopping cart",
      inputSchema: { type: "object", properties: {} },
      execute: async () => ({ content: [{ type: "text", text: "[]" }] }),
    });
    await settle();
    expect(heard).toEqual(["notifications/tools/list_changed"]);
    expect(((await client.listTools()) as { name: string }[]).map((tool) => tool.name).sort()).toEqual(["get_cart", "ping"]);

    remove();
    await settle();
    expect(heard).toEqual(["notifications/tools/list_changed", "notifications/tools/list_changed"]);
  } finally {
    await client.close();
    await server.dispose();
  }
});

test("closing one client leaves the server, and the other clients, as they were", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [Ping] });
  const first = await server.connect();
  const second = await server.connect();
  const heard: string[] = [];
  second.onNotification((notification: { method: string }) => {
    heard.push(notification.method);
  });
  try {
    await first.close();
    await server.registerTool({
      name: "get_cart",
      description: "The items in the shopping cart",
      inputSchema: { type: "object", properties: {} },
      execute: async () => ({ content: [{ type: "text", text: "[]" }] }),
    });
    await settle();
    expect(heard).toEqual(["notifications/tools/list_changed"]);
  } finally {
    await second.close();
    await server.dispose();
  }
});
```

[`registerTool()`](https://frontmcp.dev/reference/sdk/register-tool) covers what a tool added this way can do. A client from `server.connect()` can close at any time: only `server.dispose()` ends the server. (Changed in 1.9.3: before, closing such a client disposed part of the server, so `registerTool()` then failed with `the server has been disposed`, and the other clients stopped getting notifications.)

---

## Troubleshooting

### `ToolCallError`

An adapter's `callTool()` rejects when the tool fails, because its format has no place for `isError`. The error's `message` is the tool's error text, and `result` is the MCP result, `_meta.code` included. Catch it and pass the failure to the model, as in [Keeping failures visible to Claude](#keeping-failures-visible-to-claude), or call the tool through plain `connect()`, which returns the failure as a result with `isError: true`.

### OpenAI refuses a tool's schema

`connectOpenAI()` marks every tool `strict: true` but leaves optional arguments out of `required`, and OpenAI's strict mode requires every property there. Either turn strict mode off for those tools, or make every argument required (`.nullable()` instead of `.optional()` for the ones that may be empty):

```ts
const tools = ((await client.listTools()) as { function: { strict: boolean } }[]).map((tool) => ({
  ...tool,
  function: { ...tool.function, strict: false },
}));
```

### A tool that asks the user returns "This tool requires user input to continue"

The client didn't declare elicitation when it connected, so FrontMCP took its fallback for clients without forms: the tool's result asks the model to collect the answer and call the `sendElicitationResult` tool. A handler registered with `client.onElicitation()` after connecting isn't asked. Pass `onElicitation` to `connect()` instead, as in [Answering the tools' questions](#answering-the-tools-questions), or declare `capabilities: { elicitation: { form: {} } }` there. To answer the fallback from your code, pass the `elicitId` from the result's `_meta.elicitationPending` to `submitElicitationResult()`, which returns the tool's result:

```ts close-ticket.tool.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { id, status: answer.status === "accept" && answer.content?.confirm ? "closed" : "open" };
  }
}
```

```ts elicit.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, connect } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";

@App({ id: "desk", name: "Desk", tools: [CloseTicket] })
class Desk {}

const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [Desk], elicitation: { enabled: true } };
const yes = () => ({ action: "accept" as const, content: { confirm: true } });

test("🚩 a handler added after connecting, without the capability, isn't asked", async () => {
  const client = await connect(config);
  client.onElicitation(yes);
  try {
    expect(await client.callTool("close_ticket", { id: "T-1" })).toMatchObject({
      content: [{ type: "text", text: expect.stringContaining("This tool requires user input to continue") }],
      _meta: { elicitationPending: { elicitId: expect.any(String), message: "Close ticket T-1?" } },
    });
  } finally {
    await client.close();
  }
});

test("✅ declaring the capability at connect time lets it answer", async () => {
  const client = await connect(config, { capabilities: { elicitation: { form: {} } } });
  client.onElicitation(yes);
  try {
    expect(await client.callTool("close_ticket", { id: "T-1" })).toMatchObject({ structuredContent: { status: "closed" } });
  } finally {
    await client.close();
  }
});

test("submitElicitationResult() answers the fallback, and returns the tool's result", async () => {
  const client = await connect(config);
  try {
    const pending = (await client.callTool("close_ticket", { id: "T-2" })) as { _meta: { elicitationPending: { elicitId: string } } };
    const { elicitId } = pending._meta.elicitationPending;
    const answered = await client.submitElicitationResult(elicitId, { action: "accept", content: { confirm: true } });
    expect(answered).toMatchObject({ structuredContent: { id: "T-2", status: "closed" } });
  } finally {
    await client.close();
  }
});
```

`submitElicitationResult()` calls the `sendElicitationResult` tool for you, with the `elicitId`, the `action` and the `content`, and FrontMCP runs the tool again with the answer. Changed in 1.9.3: before, it sent an `elicitation/result` request, which failed with `McpError` `-32601` `Method not found`, and the `sendElicitationResult` tool had to be called by hand.

### `Elicitation is disabled in server configuration`

The tool called `this.elicit()` on a server without `elicitation: { enabled: true }`, and the call failed with code `ELICITATION_DISABLED`. Turn it on in the configuration you pass to `connect()`.

### `this.notify()` sends nothing

The client hasn't called `setLogLevel()`, or the message's level is below the one it set. See [Hearing a tool's log messages](#hearing-a-tools-log-messages).

### `No endpoint serves app "…" on its own`

`connect()` was given an `app` that has no endpoint of its own, so it rejected with a `ScopeConfigurationError`. Only a server with `splitByApp: true` gives every app one, and otherwise only a `standalone` app has one. The rest of the message lists the apps that do, like `Apps with an endpoint of their own (splitByApp or standalone): "billing"`. Leave `app` out to reach the main endpoint. See [Connecting to one app](#connecting-to-one-app).

### `Not connected`

The client was used after `close()`. Connect again. With the same configuration object, you get the same server if another of its clients is still open, and a new one otherwise.

### `Invalid input: expected array, received undefined`

The error's path is `apps`. `connect()` needs the configuration you give `@FrontMcp`, with your tools in an `@App`. `connect({ info, tools })` doesn't work; for a server built from bare tools, use [`create()`](https://frontmcp.dev/reference/sdk/create).

### A provider's state is gone after connecting again

The last client of that configuration object closed, which disposed the server, so `connect()` built a new one with new providers. Keep one client open while the state matters, or keep the state outside the server. See [one server per configuration object](#one-server-per-configuration-object).

### `Unexpected token 'T', "Tool "list"... is not valid JSON`

`listJobs()` or `listWorkflows()` on a server without jobs or workflows. The tool they call doesn't exist, and the client tries to parse the error message as JSON. [Reading resources and prompts](#reading-resources-and-prompts) shows it.
