connect() and client adapters

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.

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?)

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.

Parameters

ParameterTypeDescription
configFrontMcpConfigInputThe configuration you give @FrontMcp, with apps. The decorated class works too. A create()-style configuration, with tools at the top level, fails with expected array, received undefined.
options.clientInfo{ name, version }Who the client says it is. Becomes this.clientInfo in your tools, and chooses the format 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.
options.session.scopesstring[]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.idstringThe session id, this.context.sessionId. Defaults to direct: and a UUID.
options.authTokenstringThe caller's token, this.context.authInfo.token.
options.capabilitiesClientCapabilitiesThe capabilities the client declares in initialize.
options.onElicitation(request) => { action, content? }Answers the tools' this.elicit() questions, as the user would. Passing it declares the elicitation capability. See below.
options.workerEnvRecord<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. Never copied into process.env. A client from server.connect() without its own gets the server's. The adapters take it too. New in 1.9.4.
options.appstringThe 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. New in 1.9.3.

Returns

A promise of a 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 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

Optionsthis.auth in your tools
No session.userAn 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: "…" aloneStill 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.

FunctionClient namelistTools() returnscallTool() returns
connect()mcp-clientMCP 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. 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.

The DirectClient

connect(), the adapters and DirectMcpServer.connect() all return one.

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

MethodReturns
listTools()The tools, in the client's format. 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.
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() shows paging with 45 tools.

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

MethodReturns
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.

About the connection:

MethodReturns
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'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() 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.
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() sends this client nothing. See Hearing a tool's 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.
submitElicitationResult(id, response)Answers a question from the fallback flow, 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. 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().

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:

Open
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();
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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:

Open
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();
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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();
  }
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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();
  }
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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):

Open
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 ?? {}),
    };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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, 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:

Open
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 };

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The clients share one server, as clients of one configuration object always do, whichever endpoint each is on. The adapters take app too. createDirect() 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() 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:

Open
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" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 instead of asking. The elicitation:request and elicitation:result 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() 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:

Open
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" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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() 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:

Open
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 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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() 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:

Open
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 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

registerTool() 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, 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):

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, 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:

Open
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" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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.

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().

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.

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 shows it.