Logging

A FrontMCP server keeps a log of its own: what it started, each request it handled, and each call that failed, with the error ID the client was given. this.logger in your tools adds to the same log. @FrontMcp({ logging }) sets how much is written and where it goes: to the console by default, and to any destination you add with a @LogTransport class. Nothing in this log reaches the client; messages meant for the client go through this.notify().

@FrontMcp({ info, apps, logging: { level, enableConsole, prefix, transports } })

@LogTransport({ name })
class MyTransport extends LogTransportInterface {
  log(record: LogRecord) { /* ... */ }
}

Reference

@FrontMcp({ logging })

main.ts
import "reflect-metadata";
import { FrontMcp, LogLevel } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
import { JsonLinesTransport } from "./json-lines.transport";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  logging: {
    level: process.env.NODE_ENV === "production" ? LogLevel.Warn : LogLevel.Info,
    enableConsole: false,
    transports: [JsonLinesTransport],
  },
})
export default class Server {}

See more examples below.

Options

OptionTypeDefaultDescription
levelLogLevelFRONTMCP_LOG_LEVEL, else LogLevel.InfoThe lowest level that's written. It applies to every destination, and it's fixed when the server starts. Without it, the FRONTMCP_LOG_LEVEL environment variable names the level: debug, verbose, info, warn, error or off. (FRONTMCP_LOG_LEVEL is new in 1.9.3.)
enableConsolebooleantrueWrite to the console. Set false when your own transport replaces it.
prefixstringnoneA name put in front of every logger's name, with a :: help-desk-eu:tool:close_ticket. The root logger's lines carry it alone. See tagging every line.
transports@LogTransport classes[]More destinations. Each one receives every line that passes level.

LogLevel

LevelValueMethodWhat FrontMCP writes at it
LogLevel.Debug0debug()Request headers, with credentials redacted, and transport decisions. Everything is written.
LogLevel.Verbose1verbose()Every stage of every flow, registry setup. Hides debug() lines, because Verbose is the higher number.
LogLevel.Info2info()Startup, and a few lines per request. The default.
LogLevel.Warn3warn()Public errors (PublicMcpError), deployment warnings.
LogLevel.Error4error()Failures that aren't public, with the original error.
LogLevel.Off100noneNothing.

level takes the enum, not a string: level: "info" stops the server with Invalid option: expected one of 0|1|2|3|4|100.

@LogTransport(metadata)

A transport is a class with the @LogTransport decorator that extends LogTransportInterface and implements log(record). List it in logging.transports.

json-lines.transport.ts
import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk";

@LogTransport({ name: "JsonLines", description: "One JSON object per line on stdout" })
export class JsonLinesTransport extends LogTransportInterface {
  log(record: LogRecord) {
    process.stdout.write(
      JSON.stringify({ time: record.timestamp, level: record.levelName, logger: record.prefix, message: record.message }) + "\n",
    );
  }
}

LogTransport is also exported as FrontMcpLogTransport.

Metadata

FieldTypeDescription
namestringRequired. The transport's name.
idstringOptional identifier.
descriptionstringWhat the transport does.

log(record)

FrontMCP calls log() once per line that passes level, in the order the transports are listed, then the console. It doesn't wait for a returned promise.

record fieldTypeDescription
levelLogLevelThe line's level, as a number.
levelNamestringThe same, in lowercase: "debug", "verbose", "info", "warn" or "error".
messagestringThe first argument the logger was called with, converted with String(). An object as the first argument becomes "[object Object]": put objects after the message.
argsunknown[]Every other argument, as it was passed: objects, Errors, anything.
timestampDateWhen the line was written.
prefixstringThe name of the logger that wrote it, like "tool:close_ticket", after logging.prefix and a : when that's set. "" for the root logger without logging.prefix.

Constructor

FrontMCP creates one instance of each transport per server, and passes the logging options to its constructor: { level, enableConsole, prefix? }. Two handlers or two servers in one process get separate instances.

Caveats

  • Don't throw from log(). FrontMCP catches the error, prints [Logger] Transport error: <message> with console.error, and goes on to the next transport, for every line. See the troubleshooting entry.
  • Keep log() synchronous, or catch inside it. FrontMCP doesn't wait for a promise, so an async log() that rejects is an unhandled rejection, which ends a Node process by default.
  • Keep log() fast: it runs inside the request that logged. Buffer lines and send them in batches if the destination is over the network.
  • Over stdio, stdout carries the protocol. A transport must not write to it.

The loggers

Every context class has a logger, and they all write to this log:

LoggerPrefix of its linesWhere
this.loggertool:<name>, resource:<name>, prompt:<name>Every context class.
this.contextLogger[<request id>:<trace id>], the first eight characters of eachEvery context class except PromptContext. The console shows it as [[1f0c2a9e:5d1b4c7e]].
logger.child(name)nameA logger with its own name. It replaces the logger's name: this.logger.child("mailer") writes mailer, not tool:close_ticket.mailer.

With logging.prefix, every one of these names gets the prefix and a : in front: help-desk-eu:tool:close_ticket, help-desk-eu:mailer.

Each has debug(), verbose(), info(), warn() and error(), which take a message and any number of other values (they become record.args), and child().

What FrontMCP writes

At LogLevel.Info, a server writes about ten lines while it starts, like Initializing FrontMCP "help-desk v1.0.0"..., Help Desk: 3 tools and Scope ready — 1 app, 3 tools, and six for each tool call:

LoggerLine
HttpRequestFlow[<request id>] ▶ POST /, with the request's details as an object
SessionVerifyFlowhandlePublicMode: allowing anonymous access (public mode), for a public server
HttpRequestFlow[<request id>] ◀ POST / completed in 3ms
call-tool-request-handlertools/call: close_ticket
CallToolFlow(close_ticket)findTool: tool "close_ticket" found
CallToolFlow(close_ticket)finalize: sending response, with the size of the result

A call that fails adds one line from call-tool-request-handler, with an object after the message that holds toolName, errorId and code, and, in development, stack. The errorId is the one the client got in _meta.errorId, so a user's report can be found in the log. Finding the line for an error ID shows it.

The tool failed withLevelMessage
A PublicMcpErrorwarnPublic error: <message>
Anything else, like a plain ErrorerrorInternal error: Tool "<name>" execution failed: <message>, then Original error: and the original stack

Where the lines go

The server runs withThe console transport writes toAlso
FrontMCP's HTTP server (@FrontMcp, bootstrap())stdout for debug, verbose and info (console.debug and console.info), stderr for warn and errorA few lines skip the logger and go straight to the console, even with enableConsole: false or LogLevel.Off: MCP HTTP (Express) on 127.0.0.1:3000 when the server starts listening, and session initialized: <the start of the session id> for each session a client on a protocol version before 2026-07-28 opens.
stdio (FRONTMCP_STDIO=1, runStdio())stderr, for every level. stdout carries only MCP messages, and even console.log is sent to stderr.A log file per start, ~/.frontmcp/logs/frontmcp-<time>.log: FRONTMCP_LOG_DIR sets the folder, FRONTMCP_APP_NAME replaces frontmcp in the name, and FRONTMCP_LOGS_MAX sets how many files are kept (25; 0 turns the file off). Lines look like [2026-09-27T10:31:07.074Z] INFO [tool:close_ticket] closing T-1, without the extra arguments.
createFetchHandler()The runtime's consoleIn the Playground, the Logs tab.

The console format is [HH:MM:SS.mmm] [prefix] LEVEL message, in color when the terminal supports it, followed by the extra arguments as the console prints them.

None of this reaches the client. To send the client a message, use this.notify(). A client's log level, io.modelcontextprotocol/logLevel in a 2026-07-28 request or logging/setLevel in an older session, filters only those messages, never the server's log.

Caveats

  • The level is set once, when the server starts. Nothing changes it while the server runs.
  • To keep a request's lines together, use this.contextLogger, or structured logs, which put a request_id on every line written during a request. observability: { requestLogs } also hands you one summary per HTTP request.

Usage

Choosing a level

Info writes a few lines for every request, which is useful while you build and a lot in production. At Warn, a server that works writes almost nothing, and every failure is still there. The examples on this page add a transport that keeps each record in an array, so the tests can check what was written; the Logs tab shows the console.

Open
import { App, FrontMcp, LogLevel, Tool, ToolContext, z } from "@frontmcp/sdk";
import { MemoryTransport } from "./memory.transport";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.logger.info(`closing ${id}`);
    if (id === "T-3") this.logger.warn(`${id} was already closed`);
    return { id, status: "closed" };
  }
}

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  logging: { level: LogLevel.Warn, transports: [MemoryTransport] },
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Change LogLevel.Warn to LogLevel.Info and open the Logs tab after a call to see what Info writes. To pick the level where the server is deployed instead, leave level out and set the FRONTMCP_LOG_LEVEL environment variable: FRONTMCP_LOG_LEVEL=warn. Case doesn't matter, a value that isn't a level is ignored, and a level in the code wins over it. That was checked in Node: the Playground runs FrontMCP's browser build, which reads no environment variables. @frontmcp/testing's test.use({ logLevel }) sets it for the server a test starts.

Writing a transport

A transport decides the format and the destination. This one writes one JSON object per line, the format most log collectors read, and merges the objects passed after the message into it. It writes with console.info so the Playground shows the lines in the Logs tab; a real server would call process.stdout.write(line + "\n"), or send batches to a collector. enableConsole: false turns off the default format, so each line appears once.

Open
import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk";

export const written: string[] = [];

@LogTransport({ name: "JsonLines", description: "One JSON object per line" })
export class JsonLinesTransport extends LogTransportInterface {
  log(record: LogRecord) {
    try {
      const error = record.args.find((a): a is Error => a instanceof Error);
      const fields = record.args.filter((a) => typeof a === "object" && a !== null && !Array.isArray(a) && a !== error);
      const line = JSON.stringify({
        time: record.timestamp.toISOString(),
        level: record.levelName,
        logger: record.prefix || undefined,
        message: record.message,
        ...Object.assign({}, ...fields),
        ...(error ? { error: { name: error.name, message: error.message } } : {}),
      });
      written.push(line);
      console.info(line);
    } catch {
      // A transport never throws: a line that can't be written is dropped.
    }
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The same kind of transport, keeping records in an array, is how you check what your code logs in tests: every example on this page does it.

Finding the line for an error ID

In production, a tool that throws a plain Error gives the client only "Internal FrontMCP error" and an error ID. The log has the same ID, on a line with the original message. Here the call fails; the test finds its line by _meta.errorId:

Open
import { App, FrontMcp, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { MemoryTransport } from "./memory.transport";

@Tool({ name: "reopen_ticket", description: "Reopen a closed ticket", inputSchema: { id: z.string() } })
class ReopenTicket extends ToolContext {
  async execute({ id }: { id: string }): Promise<{ id: string }> {
    throw new Error(`tickets database unreachable while reopening ${id}`);
  }
}

@Tool({ name: "get_ticket", description: "Get a ticket by id", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (id !== "T-1") this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return { id, title: "Cannot log in" };
  }
}

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  logging: { transports: [MemoryTransport] },
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The error line doesn't carry the request id. To find the other lines of the same request, log with this.contextLogger in the tool, or turn on structured logs, which put a request_id on every line written during a request.

Telling requests apart

With many requests at once, their lines interleave. this.contextLogger starts each line with the request id and the trace id, eight characters each, so one request's lines can be found together. child() gives a part of your code a name of its own:

Open
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { MemoryTransport } from "./memory.transport";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.logger.info(`closing ${id}`);
    this.contextLogger.info(`closing ${id}`);
    this.logger.child("mailer").info(`emailing the customer about ${id}`);
    return { id, requestId: this.context.requestId, traceId: this.context.traceContext.traceId };
  }
}

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  logging: { transports: [MemoryTransport] },
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

In the Logs tab, the console wraps each prefix in brackets, so the tagged line reads [[1f0c2a9e:5d1b4c7e]] INFO closing T-1. The request id is the one this.context.requestId returns in full, and the trace id continues the caller's trace when the request has a traceparent header (see Observability).

Tagging every line with logging.prefix

When several servers, or several regions of one, write to the same place, logging.prefix says which one a line came from. FrontMCP puts it in front of every logger's name, with a :, so a transport and the console both see it:

Open
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { MemoryTransport } from "./memory.transport";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.logger.info(`closing ${id}`);
    this.contextLogger.info(`closing ${id}, tagged`);
    this.logger.child("mailer").info(`telling the customer ${id} is closed`);
    return { id, status: "closed" };
  }
}

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  logging: { prefix: "help-desk-eu", transports: [MemoryTransport] },
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

In the Logs tab the console shows it the same way: [help-desk-eu:tool:close_ticket] INFO closing T-1. A child's name still replaces its parent's, as above, but never the prefix. Before 1.9.2, only the root logger's few lines carried it.

Seeing request headers

At LogLevel.Debug, each HTTP request also gets a [<request id>] HEADERS line, with the headers as an object after the message. Headers that carry credentials are replaced with "[REDACTED]": authorization, proxy-authorization, cookie, set-cookie, x-api-key, and any header with a word like auth, token, key, secret, password or signature in its name. Headers whose name ends in session-id keep their first eight characters, and referer loses its query (checked in Node: a browser doesn't let a page set referer):

Open
import { App, FrontMcp, LogLevel, Tool, ToolContext, z } from "@frontmcp/sdk";
import { MemoryTransport } from "./memory.transport";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  logging: { level: LogLevel.Debug, transports: [MemoryTransport] },
};

@FrontMcp(config)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Changed in 1.8.3: before, only those first five headers were redacted and only mcp-session-id was shortened, so a token in a header of another name, like x-desk-token, or in the referer's query, was written as it was. What your own code logs isn't redacted; structured logs can redact fields by name.

Logging from resources and prompts

Resources and prompts have this.logger too, named after the resource or the prompt:

Open
import { App, FrontMcp, Prompt, PromptContext, ResourceContext, ResourceTemplate } from "@frontmcp/sdk";
import { MemoryTransport } from "./memory.transport";

@ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" })
class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    this.logger.info(`reading ${id}`);
    return { id, title: "Cannot log in" };
  }
}

@Prompt({ name: "triage", description: "Triage the newest tickets", arguments: [] })
class TriagePrompt extends PromptContext {
  async execute() {
    this.logger.info("building the triage prompt");
    return { messages: [{ role: "user" as const, content: { type: "text" as const, text: "Triage the newest tickets." } }] };
  }
}

@App({ id: "help-desk", name: "Help Desk", resources: [TicketResource], prompts: [TriagePrompt] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  logging: { transports: [MemoryTransport] },
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.


Troubleshooting

debug() lines don't appear

The level is Info unless you set it, so debug() and verbose() lines are dropped. LogLevel.Verbose doesn't show them all either: Debug is the lower number, so Verbose still drops debug() lines. Use LogLevel.Debug to see everything:

Open
import { App, FrontMcp, LogLevel, Tool, ToolContext, z } from "@frontmcp/sdk";
import { MemoryTransport } from "./memory.transport";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.logger.verbose(`closing ${id}: verbose`);
    this.logger.debug(`closing ${id}: debug`);
    return { id, status: "closed" };
  }
}

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  // 🚩 Verbose drops debug() lines
  logging: { level: LogLevel.Verbose, transports: [MemoryTransport] },
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

[Logger] Transport error: …

A transport's log() threw. FrontMCP prints this with console.error for each line that failed, and the other transports and the console still get the line. Catch errors inside log(): a destination that's down shouldn't fill the console, or fail anything else.

Open
import { App, FrontMcp, LogRecord, LogTransport, LogTransportInterface, Tool, ToolContext, z } from "@frontmcp/sdk";
import { MemoryTransport } from "./memory.transport";

// 🚩 Throws for every line
@LogTransport({ name: "Collector" })
class CollectorTransport extends LogTransportInterface {
  log(record: LogRecord) {
    throw new Error("collector unreachable");
  }
}

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.logger.info(`closing ${id}`);
    return { id, status: "closed" };
  }
}

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  logging: { transports: [CollectorTransport, MemoryTransport] },
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The process exits with an unhandled rejection from a transport

log() is async, or returns a promise, and it rejected. FrontMCP calls log() without waiting for it, so nothing handles the rejection, and Node ends the process. Catch inside log():

// 🚩 A rejected send is an unhandled rejection
async log(record: LogRecord) {
  await fetch(collectorUrl, { method: "POST", body: JSON.stringify(record) });
}

// ✅ Failures stay inside the transport
log(record: LogRecord) {
  fetch(collectorUrl, { method: "POST", body: JSON.stringify(record) }).catch(() => {});
}

Better still, buffer lines and send them in batches.

A stdio client can't parse the server's output

Over stdio, stdout is the connection: every line on it must be an MCP message. FrontMCP sends the console to stderr, but a transport that calls process.stdout.write() writes into the connection, and the client fails on the first log line. Write to stderr when process.env.FRONTMCP_STDIO is "1", which FrontMCP sets for stdio servers:

const out = process.env.FRONTMCP_STDIO === "1" ? process.stderr : process.stdout;
out.write(line + "\n");

My log lines don't reach the client

That's by design: the log is the server's. For a message the client and the user should see, call this.notify(), which sends a notifications/message to clients that asked for log messages.