# Logging

> The server's own log — levels, where lines go, custom transports, request tags, and the line that matches an error ID.

Source: https://frontmcp.dev/reference/server/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()`](https://frontmcp.dev/reference/sdk/notify).

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

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

---

## Reference

### `@FrontMcp({ logging })`

```ts 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.](#usage)

#### 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](#tagging-every-line-with-loggingprefix). |
| `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](#seeing-request-headers), 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`](https://frontmcp.dev/reference/sdk/frontmcp#invalid-option-expected-one-of-01234100).

### `@LogTransport(metadata)`

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

```ts 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

| 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, `Error`s, 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>` with `console.error`, and goes on to the next transport, for every line. See [the troubleshooting entry](#logger-transport-error-).
- **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](#a-stdio-client-cant-parse-the-servers-output).

### 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](https://frontmcp.dev/reference/sdk/contexts#thislogger-and-thiscontextlogger). |
| `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`](#tagging-every-line-with-loggingprefix), 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](#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()`](https://frontmcp.dev/reference/sdk/frontmcp-instance)) | 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()`](https://frontmcp.dev/reference/sdk/frontmcp-instance)) | 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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) | 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()`](https://frontmcp.dev/reference/sdk/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](https://frontmcp.dev/reference/server/observability#structured-logs), which put a `request_id` on every line written during a request. [`observability: { requestLogs }`](https://frontmcp.dev/reference/server/observability#request-logs) 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.

```ts main.ts active
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 {}
```

```ts memory.transport.ts
import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk";

export const records: LogRecord[] = [];

@LogTransport({ name: "MemoryTransport", description: "Keeps log records in memory, for tests" })
export class MemoryTransport extends LogTransportInterface {
  log(record: LogRecord) {
    records.push(record);
  }
}
```

```ts level.test.ts
import { test, expect } from "@frontmcp/testing";
import { LogLevel } from "@frontmcp/sdk";
import { records } from "./memory.transport";

test("at Warn, nothing below Warn is written", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(records.filter((r) => r.level < LogLevel.Warn)).toEqual([]);
});

test("warnings are still written", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-3" });
  const line = records.find((r) => r.message === "T-3 was already closed");
  expect(line).toMatchObject({ level: LogLevel.Warn, levelName: "warn", prefix: "tool:close_ticket" });
});
```

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`](https://frontmcp.dev/reference/testing/fixtures)'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.

```ts json-lines.transport.ts active
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.
    }
  }
}
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { JsonLinesTransport } from "./json-lines.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("ticket closed", { ticketId: id, closedBy: "agent" });
    this.logger.error("couldn't email the customer", new Error("SMTP timeout"), { ticketId: 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: { enableConsole: false, transports: [JsonLinesTransport] },
})
export default class Server {}
```

```ts json-lines.test.ts
import { test, expect } from "@frontmcp/testing";
import { written } from "./json-lines.transport";

test("every line is one JSON object", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  const lines = written.map((line) => JSON.parse(line));
  expect(lines.find((l) => l.message === "ticket closed")).toEqual({
    time: expect.any(String),
    level: "info",
    logger: "tool:close_ticket",
    message: "ticket closed",
    ticketId: "T-1",
    closedBy: "agent",
  });
  expect(lines.find((l) => l.message === "couldn't email the customer")).toMatchObject({
    level: "error",
    ticketId: "T-1",
    error: { name: "Error", message: "SMTP timeout" },
  });
});
```

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

```ts main.ts active
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 {}
```

```ts memory.transport.ts
import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk";

export const records: LogRecord[] = [];

@LogTransport({ name: "MemoryTransport", description: "Keeps log records in memory, for tests" })
export class MemoryTransport extends LogTransportInterface {
  log(record: LogRecord) {
    records.push(record);
  }
}
```

```ts error-id.test.ts
import { test, expect } from "@frontmcp/testing";
import { records } from "./memory.transport";

type Details = { toolName?: string; errorId?: string; code?: string };
const lineFor = (errorId: unknown) => records.find((r) => (r.args[0] as Details | undefined)?.errorId === errorId);

test("the log has the error ID the client got, with the original message", async ({ mcp }) => {
  const result = await mcp.tools.call("reopen_ticket", { id: "T-2" });
  const line = lineFor(result.raw._meta?.errorId);
  expect(line).toMatchObject({ levelName: "error", prefix: "call-tool-request-handler" });
  expect(line?.args[0]).toMatchObject({ toolName: "reopen_ticket", code: "TOOL_EXECUTION_ERROR" });
  expect(line?.message).toContain("tickets database unreachable while reopening T-2");
});

test("a public error is a warning, with its own ID", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-404" });
  const line = lineFor(result.raw._meta?.errorId);
  expect(line).toMatchObject({ levelName: "warn", message: "Public error: There's no ticket T-404." });
  expect(line?.args[0]).toMatchObject({ toolName: "get_ticket", code: "PUBLIC_ERROR" });
});
```

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](https://frontmcp.dev/reference/server/observability#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:

```ts main.ts active
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 {}
```

```ts memory.transport.ts
import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk";

export const records: LogRecord[] = [];

@LogTransport({ name: "MemoryTransport", description: "Keeps log records in memory, for tests" })
export class MemoryTransport extends LogTransportInterface {
  log(record: LogRecord) {
    records.push(record);
  }
}
```

```ts prefixes.test.ts
import { test, expect } from "@frontmcp/testing";
import { records } from "./memory.transport";

test("each logger has its own prefix", async ({ mcp }) => {
  const { requestId, traceId } = (await mcp.tools.call("close_ticket", { id: "T-1" })).json();
  const prefixes = records.filter((r) => r.message.includes("T-1")).map((r) => r.prefix);
  expect(prefixes).toEqual(["tool:close_ticket", `[${requestId.slice(0, 8)}:${traceId.slice(0, 8)}]`, "mailer"]);
});
```

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`](https://frontmcp.dev/reference/sdk/context) returns in full, and the trace id continues the caller's trace when the request has a `traceparent` header (see [Observability](https://frontmcp.dev/reference/server/observability#following-a-request-across-services)).

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

```ts main.ts active
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 {}
```

```ts memory.transport.ts
import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk";

export const records: LogRecord[] = [];

@LogTransport({ name: "MemoryTransport", description: "Keeps log records in memory, for tests" })
export class MemoryTransport extends LogTransportInterface {
  log(record: LogRecord) {
    records.push(record);
  }
}
```

```ts prefix.test.ts
import { test, expect } from "@frontmcp/testing";
import { records } from "./memory.transport";

test("the root logger's lines carry the prefix alone", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(records.find((r) => r.message.startsWith("Initializing FrontMCP"))?.prefix).toBe("help-desk-eu");
});

test("every other logger's name comes after it", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  const prefixOf = (message: string) => records.find((r) => r.message === message)?.prefix;
  expect(prefixOf("closing T-1")).toBe("help-desk-eu:tool:close_ticket");
  expect(prefixOf("closing T-1, tagged")).toMatch(/^help-desk-eu:\[[0-9a-f]{8}:[0-9a-f]{8}\]$/);
  expect(prefixOf("telling the customer T-1 is closed")).toBe("help-desk-eu:mailer");
  expect(records.find((r) => r.message.startsWith("tools/call:"))?.prefix).toBe("help-desk-eu:call-tool-request-handler");
});
```

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

```ts main.ts active
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 {}
```

```ts memory.transport.ts
import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk";

export const records: LogRecord[] = [];

@LogTransport({ name: "MemoryTransport", description: "Keeps log records in memory, for tests" })
export class MemoryTransport extends LogTransportInterface {
  log(record: LogRecord) {
    records.push(record);
  }
}
```

```ts headers.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";
import { records } from "./memory.transport";

test("credentials are redacted from the HEADERS line", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/list",
        authorization: "Bearer sk-live-4242",
        "x-desk-token": "desk-7",
        "mcp-session-id": "4f6c1d0e-9a2b-4c1f-8e3d-2b7a5c9d0e1f",
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  const line = records.find((r) => r.message.endsWith("] HEADERS") && "x-desk-token" in (r.args[0] as any).headers);
  expect((line?.args[0] as any).headers).toMatchObject({
    authorization: "[REDACTED]",
    "x-desk-token": "[REDACTED]",
    "mcp-session-id": "4f6c1d0e...",
    "mcp-method": "tools/list",
  });
});
```

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](https://frontmcp.dev/reference/server/observability#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:

```ts main.ts active
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 {}
```

```ts memory.transport.ts
import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk";

export const records: LogRecord[] = [];

@LogTransport({ name: "MemoryTransport", description: "Keeps log records in memory, for tests" })
export class MemoryTransport extends LogTransportInterface {
  log(record: LogRecord) {
    records.push(record);
  }
}
```

```ts names.test.ts
import { test, expect } from "@frontmcp/testing";
import { records } from "./memory.transport";

test("lines are named after the resource and the prompt", async ({ mcp }) => {
  await mcp.resources.read("tickets://T-1");
  await mcp.prompts.get("triage", {});
  expect(records.find((r) => r.message === "reading T-1")?.prefix).toBe("resource:ticket");
  expect(records.find((r) => r.message === "building the triage prompt")?.prefix).toBe("prompt:triage");
});
```

---

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

```ts main.ts active
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 {}
```

```ts memory.transport.ts
import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk";

export const records: LogRecord[] = [];

@LogTransport({ name: "MemoryTransport", description: "Keeps log records in memory, for tests" })
export class MemoryTransport extends LogTransportInterface {
  log(record: LogRecord) {
    records.push(record);
  }
}
```

```ts verbose.test.ts
import { test, expect } from "@frontmcp/testing";
import { records } from "./memory.transport";

test("at Verbose, debug() is dropped", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  const mine = records.filter((r) => r.message.startsWith("closing T-1")).map((r) => r.message);
  expect(mine).toEqual(["closing T-1: verbose"]);
});
```

### `[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.

```ts main.ts active
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 {}
```

```ts memory.transport.ts
import { LogRecord, LogTransport, LogTransportInterface } from "@frontmcp/sdk";

export const records: LogRecord[] = [];

@LogTransport({ name: "MemoryTransport", description: "Keeps log records in memory, for tests" })
export class MemoryTransport extends LogTransportInterface {
  log(record: LogRecord) {
    records.push(record);
  }
}
```

```ts transport-error.test.ts
import { test, expect } from "@frontmcp/testing";
import { records } from "./memory.transport";

test("the error is printed, and the next transport still gets the line", async ({ mcp }) => {
  const printed: string[] = [];
  const original = console.error;
  console.error = (...args: unknown[]) => void printed.push(args.map(String).join(" "));
  try {
    const result = await mcp.tools.call("close_ticket", { id: "T-1" });
    expect(result).toBeSuccessful();
  } finally {
    console.error = original;
  }
  expect(printed).toContain("[Logger] Transport error: collector unreachable");
  expect(records.some((r) => r.message === "closing T-1")).toBe(true);
});
```

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

```ts
// 🚩 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:

```ts
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()`](https://frontmcp.dev/reference/sdk/notify), which sends a `notifications/message` to clients that asked for log messages.
