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 })
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 {}Options
| Option | Type | Default | Description |
|---|---|---|---|
level | LogLevel | FRONTMCP_LOG_LEVEL, else LogLevel.Info | The 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.) |
enableConsole | boolean | true | Write to the console. Set false when your own transport replaces it. |
prefix | string | none | A 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
| Level | Value | Method | What FrontMCP writes at it |
|---|---|---|---|
LogLevel.Debug | 0 | debug() | Request headers, with credentials redacted, and transport decisions. Everything is written. |
LogLevel.Verbose | 1 | verbose() | Every stage of every flow, registry setup. Hides debug() lines, because Verbose is the higher number. |
LogLevel.Info | 2 | info() | Startup, and a few lines per request. The default. |
LogLevel.Warn | 3 | warn() | Public errors (PublicMcpError), deployment warnings. |
LogLevel.Error | 4 | error() | Failures that aren't public, with the original error. |
LogLevel.Off | 100 | none | Nothing. |
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.
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
| Field | Type | Description |
|---|---|---|
name | string | Required. The transport's name. |
id | string | Optional identifier. |
description | string | What 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 field | Type | Description |
|---|---|---|
level | LogLevel | The line's level, as a number. |
levelName | string | The same, in lowercase: "debug", "verbose", "info", "warn" or "error". |
message | string | The first argument the logger was called with, converted with String(). An object as the first argument becomes "[object Object]": put objects after the message. |
args | unknown[] | Every other argument, as it was passed: objects, Errors, anything. |
timestamp | Date | When the line was written. |
prefix | string | The 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>withconsole.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 anasync 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:
| Logger | Prefix of its lines | Where |
|---|---|---|
this.logger | tool:<name>, resource:<name>, prompt:<name> | Every context class. |
this.contextLogger | [<request id>:<trace id>], the first eight characters of each | Every context class except PromptContext. The console shows it as [[1f0c2a9e:5d1b4c7e]]. |
logger.child(name) | name | A 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:
| Logger | Line |
|---|---|
HttpRequestFlow | [<request id>] ▶ POST /, with the request's details as an object |
SessionVerifyFlow | handlePublicMode: allowing anonymous access (public mode), for a public server |
HttpRequestFlow | [<request id>] ◀ POST / completed in 3ms |
call-tool-request-handler | tools/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 with | Level | Message |
|---|---|---|
A PublicMcpError | warn | Public error: <message> |
Anything else, like a plain Error | error | Internal error: Tool "<name>" execution failed: <message>, then Original error: and the original stack |
Where the lines go
| The server runs with | The console transport writes to | Also |
|---|---|---|
FrontMCP's HTTP server (@FrontMcp, bootstrap()) | stdout for debug, verbose and info (console.debug and console.info), stderr for warn and error | A 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 console | In 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 arequest_idon 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.
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.
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:
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:
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:
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):
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:
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:
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.
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.