Flows and stages

Every request FrontMCP handles runs through a flow: a named plan of stages. A tools/call runs the tools:call-tool flow, whose stages find the tool, check auth and limits, validate the input, run execute() and send the result. Flows are what hooks attach to: a hook names a flow and one of its stages. This page lists every flow FrontMCP 1.9.4 has, with its stages and the state each one sets, shows how several flows nest inside one request, and shows how to write a flow of your own.

// Hook a stage of a built-in flow (see Hook decorators)
const { Will, Did, Around, Stage } = FlowHooksOf("tools:call-tool");

// Or write a flow, register it, and run it
@Flow({ name, access, inputSchema, outputSchema, plan: { pre, execute, post, finalize, error } })
class MyFlow extends FlowBase<typeof name> {
  @Stage("stageName") async stageName() { /* this.input, this.state, this.respond(), this.fail() */ }
}

await this.scope.registryFlows(MyFlow);
const output = await this.scope.runFlowForOutput(name, input);

Reference

How a flow runs

A flow's plan puts its stage names into phases, which run in this order:

PhaseRunsWhen a stage answers (respond)When a stage fails
preFirst. Lookups and checks: finding the tool, auth, rate limits.The rest of pre and all of execute are skipped. post and finalize still run.error runs, then finalize, and the request fails with the error.
executeIf nothing in pre answered. The work itself.The rest of execute is skipped. post and finalize still run.As for pre.
postAfter execute, and after an answer from pre or execute.The rest of post still runs, and the last answer is the one sent.As for pre.
finalizeAlways, last, whether the request worked or not.The request fails. If it was already failing, it keeps its first error.
errorOnly when a stage in pre, execute or post fails, before finalize.Ignored: the request still fails.

Each stage runs its hooks around it: Will hooks, then Around hooks, then the stage's own step (with any Stage hooks beside it), then Did hooks. A Did hook runs after a stage that worked or answered, not after one that failed. Hook decorators has the order within a stage.

Every run starts fresh: a new flow object, with its own input and an empty state. A hook's ctx is that object, so ctx.state is the state the stages share, and ctx.respond() answers exactly as a stage would.

No built-in flow has error stages. The list flows put their lookups in execute and their formatting in post; the flows that run one entry (tools:call-tool, resources:read-resource, prompts:get-prompt, agents:call-agent, jobs:execute-job) have no post, and close with finalize stages instead. That's why ctx.respond() in a Will("findTool") hook ends a tool call but finalize still runs.

Flows in one request

One request runs several flows, one inside another. For a tools/call from a 2026-07-28 client over HTTP:

FlowWhat it doesWhere it runs
http:requestEvery HTTP request at the MCP endpoint: tracing, IP filter, server-wide rate limits, auth, then routing to a transport.The HTTP entry points.
session:verifyChecks the caller's credentials for the server's auth mode.Inside http:request's checkAuthorization stage.
handle:mcp-202607280728Reads a 2026-07-28 request (the name is spelled that way in 1.9.4) and answers it.Inside http:request's handleMcp2026 stage.
tools:call-toolThe tool call.Inside handle:mcp-202607280728's handleMessage stage.

Which flows a request runs depends on how it arrives:

RequestFlows
2026-07-28 over HTTP (createFetchHandler(), bootstrap(), the Playground)http:request → session:verify → handle:mcp-202607280728 → the method's flow
Older clients over Streamable HTTP (bootstrap())http:request → session:verify → handle:streamable-http → the method's flow
stdio, createDirect(), connect()The method's flow only. http:request hooks never run.
this.callTool() from a toolAnother tools:call-tool, inside the caller's execute stage.
invoke_<agent>, a call to an agenttools:call-tool, then agents:call-agent inside its execute stage.
An agent's model reading the agent's resources or promptsresources:list-resources and resources:list-resource-templates, resources:read-resource, prompts:list-prompts or prompts:get-prompt, in the agent's own scope, inside agents:call-agent's execute stage. Plugins in @Agent({ plugins }) hook them, and the app's and the server's only with execution.inheritPlugins. New in 1.9.4.
execute_job, execute_workflowtools:call-tool, and jobs:execute-job once per attempt of the job, or of each step: inside the call's execute stage while the client waits, on its own for a run in the background. New in 1.9.4.
server/discover, initializeNo method flow.
A 2026-07-28 call that asks the user with this.elicit()One tools:call-tool per round: the first ends when execute() asks, and finalize still runs.

For 2026-07-28 requests whose response is streamed (the request asks for progress or log messages), the closing stages of http:request and handle:mcp-202607280728 run as soon as the stream starts, before the tool call has run. Don't time requests or release per-request resources in them.

The method's flow for each request:

RequestFlow
tools/call, including invoke_<agent> and FrontMCP's job toolstools:call-tool; for invoke_<agent> also agents:call-agent, and for execute_job and execute_workflow also jobs:execute-job
tools/listtools:list-tools
resources/readresources:read-resource
resources/listresources:list-resources
resources/templates/listresources:list-resource-templates
prompts/getprompts:get-prompt
prompts/listprompts:list-prompts
completion/completecompletion:complete
skills/search, skills/loadskills:search, skills:load, each with skills:filter inside it, which drops skills the caller may not see
skills/listskills:filter
logging/setLevel, resources/subscribe, resources/unsubscribe (clients before 2026-07-28)logging:set-level, resources:subscribe, resources:unsubscribe
tasks/get, tasks/result, tasks/cancel, tasks/list (clients before 2026-07-28)tasks:get, tasks:result, tasks:cancel, tasks:list

Stages of the request flows

Each table lists the stages in order, by phase, and the ctx.state fields each stage sets. A field stays set for the rest of the flow. Reading what the stages set shows them.

tools:call-tool

PhaseStageSets
preparseInputinput (the request's name, arguments and _meta), authInfo, jsonRpcRequestId, and progressToken when the request has one
ensureRemoteCapabilitiesLoads the tool lists of remote apps, if any.
findTooltool: the tool's entry, with metadata and owner
checkPublicAccessRefuses an anonymous caller a tool the server's publicAccess doesn't list, with PUBLIC_ACCESS_DENIED. New in 1.9.2.
checkToolAuthorization, checkEntryAuthoritiesRefuses callers the tool's authorities don't allow.
createTaskIfRequestedStarts a background task, for tools with execution.taskSupport.
createToolCallContexttoolContext (the tool instance execute() runs on) and executionAbort
checkToolCredentialsChecks the tool's authProviders.
acquireQuota, acquireSemaphoreThe tool's rateLimit and concurrency.
executevalidateInputValidates the arguments into toolContext.input.
executeRuns execute(), and puts what it returned in toolContext.output.
validateOutputrawOutput: a copy of toolContext.output
finalizereleaseSemaphore, releaseQuota, applyUIFrees the limits, and attaches the tool's UI.
finalizeChecks rawOutput against outputSchema (a mismatch is INVALID_OUTPUT), builds the result and sends it. What a hook put in resultMeta is merged into the result's _meta, not into its data (new in 1.8.6: the Cache plugin records cache: "hit" there).

tools:list-tools, resources:list-resources, resources:list-resource-templates, prompts:list-prompts

The four list flows share one plan:

PhaseStageSets
preparseInputTools only: authInfo, platformType, and cursor when the request has one
ensureRemoteCapabilities
executefindTools, findResources, findTemplates or findPromptstools, resources, templates or prompts: every entry on the endpoint. Tools also set foundTools, the same list, which the filters below leave as it is (new in 1.9.2).
filterByAuthoritiesDrops the entries the caller's authorities don't allow, from the same field.
filterByPublicAccessTools and prompts only: drops what publicAccess doesn't offer an anonymous caller. New in 1.9.2.
resolveConflictsresolvedTools, resolvedResources, resolvedTemplates or resolvedPrompts: the entries with clashing names prefixed
postparseTools, parseResources, parseTemplates or parsePromptsBuilds the list the client gets, one page of it for paged tools/list.

A hook on the find… stage sees and changes what every app contributes: see Changing what clients list.

resources:read-resource, prompts:get-prompt

PhaseStageSets
preparseInputinput (the request's params), authInfo
ensureRemoteCapabilities
findResource / findPromptresource with params (a template's variables) and resourceOwnerId; or prompt and promptOwnerId
checkPublicAccessPrompts only, as in tools:call-tool. New in 1.9.2.
checkEntryAuthoritiesRefuses callers the entry's authorities don't allow.
createResourceContext / createPromptContextresourceContext; or parsedArgs and promptContext
executeexecuteRuns execute(), into resourceContext.output or promptContext.output.
validateOutputrawOutput: a copy of that output
finalizefinalizeBuilds the result from rawOutput and sends it.

agents:call-agent

PhaseStageSets
preparseInputinput, authInfo
findAgentagent: the agent's entry
checkCallDepthRefuses a call that would nest agents deeper than swarm.maxCallDepth allows, with AGENT_CALL_DEPTH_EXCEEDED. New in 1.9.0.
checkEntryAuthorities, checkAgentAuthorizationRefuses callers the agent's authorities don't allow.
createAgentContextagentContext
acquireQuota, acquireSemaphoreThe agent's rateLimit and concurrency.
executevalidateInput, execute, validateOutputexecutionStartedAt, executionMeta (execute); rawOutput (validateOutput)
finalizereleaseSemaphore, releaseQuota, parseOutput, emitCompletion, finalizeparsedOutput (parseOutput). emitCompletion feeds agent-completion channels.

Under invoke_<agent>, checkEntryAuthorities, acquireQuota and acquireSemaphore pass straight through: the surrounding tools:call-tool has already applied them, so they count once. Changed in 1.8.5: before, this flow never ran, and hooks on it never fired.

jobs:execute-job

One run per attempt of a job: execute_job while the client waits or in the background, each retry, and each step of a workflow. Its hooks are JobHook.

PhaseStageSets
preparseInputjob (the job's entry), input (as given), attempt (1 for the first, 2 for the first retry), authInfo; runId for a run of execute_job, or workflow ({ name, stepId, runId }) for a workflow step
checkJobAuthorizationRefuses a caller the job's permissions don't let execute it, with JOB_NOT_AUTHORIZED. A refusal isn't retried.
validateInputparsedInput: input checked against the job's inputSchema. A hook before it can replace input.
createJobContextjobContext: the job instance execute() runs on. A @Job class's instance hooks join the run here.
executeexecuteoutput: what execute() returned, or passed to this.respond()
validateOutputanswer: { result, logs }, result checked against outputSchema (a mismatch is INVALID_OUTPUT, and isn't retried)
finalizeupdateRunStateRecords the attempt on the run as completed, retrying or failed, and notifies. A workflow step has no run of its own: its workflow run records it. flowError is set when the attempt failed.
finalizeAnswers with answer.

A hook that calls ctx.respond({ result, logs }) before execute answers for the job, which doesn't run: updateRunState records that answer as the run's result. An app's plugins hook that app's jobs; the server's plugins hook every app's. New in 1.9.4: before, jobs ran through no flow.

Open
import { DynamicPlugin, FlowCtxOf, JobHook, Plugin } from "@frontmcp/sdk";

export const attempts: object[] = [];

@Plugin({ name: "attempt-log" })
export class AttemptLog extends DynamicPlugin<object> {
  @JobHook.Did("updateRunState")
  async record(ctx: FlowCtxOf<"jobs:execute-job">) {
    const { job, attempt, runId, workflow, answer } = ctx.state;
    attempts.push({ job: job?.name, attempt, hasRunId: runId !== undefined, workflow, answer });
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

completion:complete, skills:filter

FlowStagesSets
completion:completepre: parseInput, findReference, checkPublicAccess, checkEntryAuthorities, findWidgetTools; execute: complete; finalize: finalize. checkPublicAccess and checkEntryAuthorities are new in 1.9.2.ref, argument (parseInput); prompt or the resource (findReference); output (complete)
skills:filterpre: parseInput; execute: filterSkills; finalize: finalizeskills

skills:search, skills:load, logging:set-level, resources:subscribe, resources:unsubscribe

FlowStagesSets
skills:searchpre: parseInput; execute: search; finalize: finalizequery, options (parseInput); results (search); output (finalize)
skills:loadpre: parseInput; execute: loadSkills, activateSessions; finalize: finalizeskillIds, format, activateSession, policyMode, warnings (parseInput); loadResults (loadSkills)
logging:set-levelpre: parseInput; execute: setLevel; finalize: finalizeinput ({ level }), sessionId
resources:subscribepre: parseInput, validateResource; execute: subscribe; finalize: finalizeinput ({ uri }), sessionId. validateResource refuses a URI resources/read would refuse, with the same error.
resources:unsubscribepre: parseInput; execute: unsubscribe; finalize: finalizeinput ({ uri }), sessionId

Changed in 1.9.3: before, these five flows were registered but never ran, so hooks on them never fired: skills/search and skills/load read the skill registry directly, and the other three were answered without a flow. Session requests and skills shows them running.

Stages of the HTTP and transport flows

FlowpreexecutefinalizeSets
http:requesttraceRequest, checkIpFilter, relayToSessionOwner, acquireQuota, acquireSemaphore, checkAuthorization, acquireIdentityQuota, routercheckPersistentSessionOwner, which answers 404 Session not found to anyone but the caller who opened a Durable Object's session (new in 1.8.4); then handleMcp2026, handleWebFetch, handleLegacySse, handleSse, handleStreamableHttp, handleStatefulHttp, handleStatelessHttp, applyDeleteNodeHeaders, handleDeleteSession: the one that matches the request answers it. applyDeleteNodeHeaders (new in 1.9.2) sets the session owner's X-FrontMCP-Machine-Id on a DELETE relayed to another nodeaudit, metrics, releaseSemaphore, releaseQuota, finalizeverifyResult (checkAuthorization), intent (router)
handle:mcp-202607280728parseInput, validate, routerhandleNotification, handleSubscriptions, handleMessagecleanupisAnonymous, version (validate), requestType (router)
handle:streamable-httpapplyNodeHeaders, parseInput, routeronInitialize, onMessage, onElicitResult, onSseListener, onExtAppscleanuptoken, session, requestType
handle:legacy-sseparseInput, routeronInitialize, onMessage, onElicitResultcleanupFor the old SSE transport; not shown here.
handle:stateless-httpapplyNodeHeaders, parseInput, routerhandleRequestcleanupFor stateless HTTP; not shown here.
session:verifyparseInput, handleStaticToken, handlePublicMode, handleAnonymousFallback, requireAuthorizationHeader, verifyIfJwtderiveUser, parseSessionHeader, buildAuthorizedOutputAnswers from the first stage that can decide: in public mode, handlePublicMode.

relayToSessionOwner is new in 1.9.0: on a distributed deployment, it passes a request for a session another node owns to that node, and does nothing elsewhere (read in FrontMCP's code; it needs Redis and two nodes, so it isn't run here). applyNodeHeaders (new in 1.8.7) sets the X-FrontMCP-Machine-Id response header on a distributed deployment. ctx.rawInput.request in an http:request hook is the request: method, path, headers, query and the parsed body. Seeing every HTTP request uses it.

Other flows

These run for HTTP endpoints outside the MCP endpoint, or for features you turn on. Through createFetchHandler(), they don't run inside http:request. The acquireQuota stage after checkIpFilter counts the request against throttle.global (new in 1.9.2).

FlowRuns forStages
well-known.oauth-protected-resourceGET /.well-known/oauth-protected-resourcepre: checkIpFilter, acquireQuota, parseInput; execute: collectData; post: validateOutput
well-known.oauth-authorization-serverGET /.well-known/oauth-authorization-serverpre: checkIpFilter, acquireQuota, parseInput; execute: collectData
well-known.jwksGET /.well-known/jwks.jsonpre: checkIpFilter, acquireQuota, parseInput, validateInput; execute: collectData
oauth:authorize, oauth:token, oauth:callback, oauth:register, oauth:userinfo, oauth:connect, oauth:provider-callback, oauth:auth-ui-extraFrontMCP's own OAuth endpoints (/oauth/authorize, /oauth/token, …), for local and remote authEach starts with checkIpFilter, acquireQuota, parseInput.
skills-http:llm-txt, skills-http:llm-full-txt, skills-http:apiGET /llm.txt, /llm_full.txt and /skills, with skillsConfig enabled; each runs skills:filterpre: checkIpFilter, acquireQuota, checkEnabled (skills-http:api adds parseRequest); execute: generateContent or handleRequest
http:ip-filterCustom routes in http.routes, and a channel's webhook path on the Node serverpre: checkIpFilter
elicitation:request, elicitation:resultthis.elicit() in a session of a client before 2026-07-28, connect()'s client included, and the answerrequest: parseInput, validateRequest; generateElicitId, storePendingRecord, buildRequestParams; finalize. result: parseInput; lookupPending, validateContent, buildResult, publishResult; finalize
tasks:get, tasks:result, tasks:cancel, tasks:listtasks/* requests from clients before 2026-07-28pre: parseInput; execute: one stage (fetchAndRespond, awaitTerminal, cancelAndRespond, listAndRespond)
channels:send-notification, channels:listEvery channel event, however it's sent, and the channels a session subscribes to. New in 1.9.0: before, they never ran.send-notification: pre: parseInput, resolveMeta; execute: send; finalize. list: execute: listChannels; finalize

The OAuth, well-known, custom-route and task flows need a real server or a client on an older protocol, so this page doesn't show them running. The elicitation flows run for connect()'s in-process client, which keeps a session as older clients do. A question from a 2026-07-28 client runs neither:

Open
import { App, DynamicPlugin, FlowHooksOf, FrontMcp, Plugin, Tool, ToolContext, z } from "@frontmcp/sdk";

export const started: string[] = [];

const ElicitRequest = FlowHooksOf("elicitation:request");
const ElicitResult = FlowHooksOf("elicitation:result");

@Plugin({ name: "elicit-log" })
export class ElicitLogPlugin extends DynamicPlugin<object> {
  @ElicitRequest.Will("parseInput")
  async asked() {
    started.push("elicitation:request");
  }

  @ElicitResult.Will("parseInput")
  async answered() {
    started.push("elicitation:result");
  }
}

@Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } })
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" };
  }
}

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

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

@FrontMcp(config)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Session requests and skills

logging/setLevel, resources/subscribe and resources/unsubscribe come from clients that keep a session: clients before 2026-07-28 and connect()'s in-process client. 2026-07-28 removed them, so they get -32601 Method not found: resources/subscribe was removed in protocol 2026-07-28 and run no flow. skills/search and skills/load run their flows for every client:

Open
import { App, DynamicPlugin, FlowHooksOf, FrontMcp, Plugin, Resource, ResourceContext, Skill, Tool, ToolContext, z } from "@frontmcp/sdk";

export const started: string[] = [];

const SetLevel = FlowHooksOf("logging:set-level");
const Subscribe = FlowHooksOf("resources:subscribe");
const Unsubscribe = FlowHooksOf("resources:unsubscribe");
const Search = FlowHooksOf("skills:search");
const Load = FlowHooksOf("skills:load");

@Plugin({ name: "request-log" })
export class RequestLogPlugin extends DynamicPlugin<object> {
  @SetLevel.Will("parseInput")
  async setLevel() {
    started.push("logging:set-level");
  }

  @Subscribe.Will("parseInput")
  async subscribe() {
    started.push("resources:subscribe");
  }

  @Unsubscribe.Will("parseInput")
  async unsubscribe() {
    started.push("resources:unsubscribe");
  }

  @Search.Will("parseInput")
  async search() {
    started.push("skills:search");
  }

  @Load.Will("parseInput")
  async load() {
    started.push("skills:load");
  }
}

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [], query };
  }
}

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

@Skill({ name: "triage", description: "Triage incoming tickets", instructions: "Search for duplicates, then set a priority.", tools: [SearchTickets] })
class Triage {}

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], resources: [OpenTickets], skills: [Triage] })
class HelpDeskApp {}

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

@FrontMcp(config)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The answers are the same as before 1.9.3, when the five requests were answered without their flows, except that skills/load with activateSession: true now gives each skill a session: { activated: false } here, where nothing starts a skill session.

Writing a flow

A flow is a class that extends FlowBase, decorated with @Flow (also exported as FrontMcpFlow). Each stage is a method decorated with Stage() from FlowHooksOf(name).

@Flow optionTypeDescription
namestringRequired. The flow's name, like "desk:triage".
inputSchemaZod objectRequired. Parses the input into this.input when a run starts. Input that doesn't match throws a ZodError from runFlow() before any stage or hook runs.
planFlowPlanRequired. Stage names by phase: { pre?, execute?, post?, finalize?, error? }. A name without a matching @Stage method is skipped.
outputSchemaZod schemaWhat this.respond() accepts: the answer is parsed with it, so fields it doesn't declare are dropped. Without one, this.respond() throws Cannot read properties of undefined (reading 'parse').
access"public" | "authorized"Required by the TypeScript type, defaults to "public" at run time, and never read by FrontMCP 1.9.4.
middleware{ path?, method?, canActivate? }Answer HTTP requests to path with this flow. See Answering HTTP requests with a flow.
dependsOnToken[]Providers the flow uses. Not checked for a flow you register yourself, and not needed: this.get() finds the server's providers either way.
descriptionstringFor your own documentation.

Inside a stage:

MemberDescription
this.inputThe input, parsed with inputSchema. this.rawInput is the input as it was passed.
this.stateThe run's state: read a field as this.state.title, set it with this.state.set("title", value).
this.respond(output)Answers with output, as described in How a flow runs.
this.fail(error)Fails the run with error, exactly like throwing it.
this.get(token)A provider of the server, from @FrontMcp({ providers }).
this.scope, this.scopeLoggerThe endpoint the flow runs on, and its logger.

For typed this.input, this.state and stage names, add the flow to the global ExtendFlows interface, as in Writing and running a flow.

Registering and running

No @FrontMcp or @App option registers a flow. Register it at run time on an endpoint with scope.registryFlows(MyFlow), from code that has the scope: this.scope in a tool, resource or prompt, or createForGraph().getScopes() in a script. Registering the same class again replaces the first registration.

MethodReturns
scope.runFlowForOutput(name, input)The answer. A run that ends without one throws FlowExitedWithoutOutputError (FLOW_EXITED_WITHOUT_OUTPUT).
scope.runFlow(name, input)The answer, or undefined.

Both throw what the run fails with, and FlowNotRegisteredError (Flow "desk:nope" is not registered) for a name that isn't registered on that endpoint. A run started from a tool is part of the tool's request: this.context in the flow is the tool's, with the same requestId.

Caveats

  • TypeScript rejects a plan written as const satisfies FlowPlan<string>: as const makes the stage lists read-only, and FlowPlan wants plain arrays. Type the plan as FlowPlan<Stages> instead, as in Writing and running a flow.
  • A flow's name must be unique on the endpoint. Registering a class with the name of a built-in flow, like tools:list-tools, replaces the built-in one.
  • Plugin hooks on your flow run for every run of it, whichever app the plugin is on: a flow of your own has no owner to limit them.

Usage

Seeing which flows a request runs

A server plugin with a Will hook on the first stage of several flows records each flow as it starts, and the librarian agent has a plugin of its own for the reads its model makes. The tests show the nesting for each kind of request:

Open
import { AgentCallHook, DynamicPlugin, FlowHooksOf, HttpHook, JobHook, ListToolsHook, Plugin, ResourceHook, ToolHook } from "@frontmcp/sdk";

export const started: string[] = [];

const SessionHook = FlowHooksOf("session:verify");
const Mcp2026Hook = FlowHooksOf("handle:mcp-202607280728");

@Plugin({ name: "flow-log" })
export class FlowLogPlugin extends DynamicPlugin<object> {
  @HttpHook.Will("traceRequest")
  async http() {
    started.push("http:request");
  }

  @SessionHook.Will("parseInput")
  async session() {
    started.push("session:verify");
  }

  @Mcp2026Hook.Will("parseInput")
  async transport() {
    started.push("handle:mcp-202607280728");
  }

  @ToolHook.Will("parseInput")
  async call() {
    started.push("tools:call-tool");
  }

  @ListToolsHook.Will("parseInput")
  async list() {
    started.push("tools:list-tools");
  }

  @AgentCallHook.Will("parseInput")
  async agent() {
    started.push("agents:call-agent");
  }

  @JobHook.Will("parseInput")
  async job() {
    started.push("jobs:execute-job");
  }

  @ResourceHook.Will("parseInput")
  async read() {
    started.push("resources:read-resource");
  }
}

// On an agent: hooks the agent's own flows
@Plugin({ name: "agent-flow-log" })
export class AgentFlowLogPlugin extends DynamicPlugin<object> {
  @ResourceHook.Will("parseInput")
  async read() {
    started.push("resources:read-resource, in the agent");
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A hook on http:request sees every request, but it runs before FrontMCP knows which tool is called. To act on tool calls, hook tools:call-tool, which runs whichever way the call arrives: over HTTP, over stdio, or from this.callTool().

Reading what the stages set

ctx.state.snapshot() returns a copy of the state. This plugin records which fields are set after five stages of tools:call-tool, and after the find… stage of tools:list-tools. It also sets resultMeta before finalize, which the second test reads from the result:

Open
import { DynamicPlugin, FlowCtxOf, ListToolsHook, Plugin, ToolHook } from "@frontmcp/sdk";

export const fieldsAfter: Record<string, string[]> = {};

function record(stage: string, state: { snapshot(): object }) {
  const snapshot = state.snapshot() as Record<string, unknown>;
  fieldsAfter[stage] = Object.keys(snapshot).filter((key) => snapshot[key] !== undefined);
}

@Plugin({ name: "state-log" })
export class StateLogPlugin extends DynamicPlugin<object> {
  @ToolHook.Did("parseInput")
  async parsed(ctx: FlowCtxOf<"tools:call-tool">) {
    record("parseInput", ctx.state);
  }

  @ToolHook.Did("findTool")
  async found(ctx: FlowCtxOf<"tools:call-tool">) {
    record("findTool", ctx.state);
  }

  @ToolHook.Did("createToolCallContext")
  async created(ctx: FlowCtxOf<"tools:call-tool">) {
    record("createToolCallContext", ctx.state);
  }

  @ToolHook.Did("execute")
  async executed(ctx: FlowCtxOf<"tools:call-tool">) {
    record("execute", ctx.state);
  }

  @ToolHook.Did("validateOutput")
  async validated(ctx: FlowCtxOf<"tools:call-tool">) {
    record("validateOutput", ctx.state);
  }

  @ToolHook.Will("finalize")
  async marked(ctx: FlowCtxOf<"tools:call-tool">) {
    ctx.state.set("resultMeta", { checked: true });
  }

  @ListToolsHook.Did("findTools")
  async listed(ctx: FlowCtxOf<"tools:list-tools">) {
    record("findTools", ctx.state);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Read fields by name, like ctx.state.tool or ctx.state.toolContext, rather than from a snapshot: a snapshot is a copy, and changing it changes nothing.

Writing and running a flow

A triage flow for tickets: loadTicket answers at once for closed tickets and fails for unknown ones, classify picks a priority, and answer sends it. audit, in finalize, runs every time, and onError only when a stage failed. A tool registers the flow the first time it's called, then runs it, and a plugin hooks one of its stages like any other flow's:

Open
import { Flow, FlowBase, FlowHooksOf, PublicMcpError, z, type FlowPlan, type FlowRunOptions } from "@frontmcp/sdk";

const name = "desk:triage" as const;
const inputSchema = z.object({ id: z.string() });
const outputSchema = z.object({ id: z.string(), priority: z.enum(["low", "high", "none"]) });
const stateSchema = z.object({ title: z.string(), vip: z.boolean(), priority: z.enum(["low", "high"]) });

type Stages = "loadTicket" | "classify" | "answer" | "audit" | "onError";
const plan: FlowPlan<Stages> = {
  pre: ["loadTicket"],
  execute: ["classify"],
  post: ["answer"],
  finalize: ["audit"],
  error: ["onError"],
};

// Makes the name known to FlowHooksOf, FlowCtxOf and runFlowForOutput, with typed input and state
declare global {
  interface ExtendFlows {
    "desk:triage": FlowRunOptions<TriageFlow, typeof plan, typeof inputSchema, typeof outputSchema, typeof stateSchema>;
  }
}

const tickets: Record<string, { title: string; status: "open" | "closed"; customer: string }> = {
  "T-1": { title: "Cannot log in", status: "open", customer: "globex" },
  "T-2": { title: "Printer is slow", status: "open", customer: "globex" },
  "T-3": { title: "Old invoice", status: "closed", customer: "globex" },
  "T-4": { title: "Printer is slow", status: "open", customer: "acme" },
};

export const auditLog: string[] = [];
const { Stage } = FlowHooksOf(name);

@Flow({ name, access: "authorized", inputSchema, outputSchema, plan })
export class TriageFlow extends FlowBase<typeof name> {
  private steps: string[] = [];

  @Stage("loadTicket")
  async loadTicket() {
    this.steps.push("loadTicket");
    const ticket = tickets[this.input.id];
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${this.input.id}.`));
    if (ticket.status === "closed") this.respond({ id: this.input.id, priority: "none" });
    this.state.set({ title: ticket.title, vip: ticket.customer === "acme" });
  }

  @Stage("classify")
  async classify() {
    this.steps.push("classify");
    this.state.set("priority", /log in|password/i.test(this.state.title ?? "") ? "high" : "low");
  }

  @Stage("answer")
  async answer() {
    this.steps.push("answer");
    if (!this.state.priority) return; // post runs after an early answer too
    this.respond({ id: this.input.id, priority: this.state.priority });
  }

  @Stage("onError")
  async onError() {
    this.steps.push("onError");
  }

  @Stage("audit")
  async audit() {
    auditLog.push(`${this.input.id}: ${this.steps.join(" > ")}`);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

answer checks for a priority because post runs after the early answer for T-3 too, and a second this.respond() there would replace it. The plugin is on the same app as the tool here, but it doesn't have to be: hooks on a flow of your own run whichever app's plugin declares them.

Answering HTTP requests with a flow

A flow with middleware: { method, path } answers HTTP requests to that path, once it's registered. Its input is the request, and its output an HTTP response: httpRespond.json(body), httpRespond.ok(text) and others build one. A tool registers this one here, so the endpoint appears after the first call:

Open
import { Flow, FlowBase, FlowHooksOf, httpInputSchema, httpOutputSchema, httpRespond, z, type FlowPlan, type FlowRunOptions } from "@frontmcp/sdk";

const name = "desk:export" as const;
const stateSchema = z.object({});
const plan: FlowPlan<"serve"> = { execute: ["serve"] };

declare global {
  interface ExtendFlows {
    "desk:export": FlowRunOptions<ExportFlow, typeof plan, typeof httpInputSchema, typeof httpOutputSchema, typeof stateSchema>;
  }
}

const { Stage } = FlowHooksOf(name);

@Flow({
  name,
  access: "public",
  inputSchema: httpInputSchema,
  outputSchema: httpOutputSchema,
  plan,
  middleware: { method: "GET", path: "/exports" },
})
export class ExportFlow extends FlowBase<typeof name> {
  @Stage("serve")
  async serve() {
    const { query } = this.input.request as { query: Record<string, string | undefined> };
    this.respond(httpRespond.json({ format: query.format ?? "csv", tickets: ["T-1", "T-2"] }));
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

With the Node server, the flow is mounted as an Express route on the same port. Because nothing registers a flow at startup, prefer http.routes for an endpoint that must exist from the first request; a flow is for an endpoint that should run hooks, or that code turns on later.


Troubleshooting

My hook on a flow never runs

Check that the request reaches the flow: stdio, createDirect() and connect() skip http:request, and a 2026-07-28 resources/subscribe or logging/setLevel never gets past the transport (Session requests and skills). Before 1.9.3, logging:set-level, resources:subscribe, resources:unsubscribe, skills:search and skills:load never ran at all. Hook decorators lists the other reasons.

Flow "desk:triage" is not registered

runFlow() or runFlowForOutput() was called with a name that isn't registered on that endpoint: the flow wasn't registered yet, its name is spelled differently, or it was registered on another endpoint (each standalone app is one). Register it with scope.registryFlows() first, on the same scope.

Flow exited without producing output

runFlowForOutput() ran the flow and no stage called this.respond(). Check that the stage that answers is in plan and has a @Stage() decorator with the same name: a plan entry without one is skipped without a warning. Use runFlow() if the flow isn't meant to answer.

Invalid input: expected Object, received undefined from @Flow

The path in the error is inputSchema: every flow needs one, even a flow that takes no input (z.object({})). The error comes when the class is decorated, so importing the file is enough to throw it.

Cannot read properties of undefined (reading 'parse') from this.respond()

The flow has no outputSchema, and respond() parses the answer with it. Add one, like z.object({ ... }).

TypeScript says Argument of type '"desk:triage"' is not assignable to parameter of type 'keyof ExtendFlows'

FlowHooksOf(), FlowCtxOf, FlowBase and @Flow only accept names in the global ExtendFlows interface. Add yours with declare global { interface ExtendFlows { ... } }, as in Writing and running a flow. Every built-in flow is in it, all 46 of them, jobs:execute-job (new in 1.9.4) included. (Changed in 1.9.3: before, 19 were missing, session:verify, handle:mcp-202607280728, handle:streamable-http, elicitation:request and elicitation:result, well-known.jwks and http:ip-filter among them, and code wrote FlowHooksOf<any>("…") with a ctx type of its own. prompts:get-prompt, prompts:list-prompts, completion:complete and the channel flows are typed since 1.9.0, with PromptHook, ListPromptsHook and CompletionHook exported for them.)