Skip to content
Open
100 changes: 100 additions & 0 deletions apps/website/content/docs/core-concepts/logging.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
title: "Logging"
metadataTitle: "Logging | xmcp Documentation"
publishedAt: "2026-03-17"
summary: "Send structured log messages from your MCP server to connected clients."
description: "The logging capability lets your tools, prompts, and resources send structured log messages to clients. Clients can control verbosity via log levels, and messages are automatically filtered based on the client's preference."
---

xmcp supports the MCP [logging capability](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), which provides a standardized way for servers to send structured log messages to clients.

## Usage

Import `logger` from `xmcp` and call any log method directly in your handlers:

```typescript title="src/tools/sync-data.ts"
import { z } from "zod";
import { type InferSchema, logger } from "xmcp";

export const schema = {
source: z.string().describe("Data source to sync from"),
};

export const metadata = {
name: "sync-data",
description: "Sync data from an external source",
};

export default async function syncData({ source }: InferSchema<typeof schema>) {
logger.info(`Starting sync from ${source}`, "sync-data");

try {
const records = await fetchRecords(source);
logger.debug({ recordCount: records.length }, "sync-data");

await processRecords(records);
logger.info("Sync completed successfully", "sync-data");

return `Synced ${records.length} records`;
} catch (error) {
logger.error({ error: String(error), source }, "sync-data");
return { isError: true, content: [{ type: "text", text: "Sync failed" }] };
}
}
```

No need to accept `extra` as a parameter, `logger` automatically resolves the current server session from async context.

## Log Levels

The logging levels follow [RFC 5424](https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1) severity levels, from least to most severe:

| Method | Description | Example Use Case |
| ------------- | -------------------------------- | -------------------------- |
| `debug` | Detailed debugging information | Function entry/exit points |
| `info` | General informational messages | Operation progress updates |
| `notice` | Normal but significant events | Configuration changes |
| `warning` | Warning conditions | Deprecated feature usage |
| `error` | Error conditions | Operation failures |
| `critical` | Critical conditions | System component failures |
| `alert` | Action must be taken immediately | Data corruption detected |
| `emergency` | System is unusable | Complete system failure |

## Sending a Message

Each log method has the same signature:

```typescript
logger.info(data: unknown, loggerName?: string): void
```

- **`data`**: The log payload. Can be a string, number, object, or any JSON-serializable value.
- **`loggerName`** _(optional)_: A label to identify the source of the message (e.g., your tool name or a subsystem).

## Client-Controlled Log Level

Clients can send a `logging/setLevel` request to control which messages they receive. When a client sets the level to `"warning"`, only messages at `warning` severity and above are delivered; `debug`, `info`, and `notice` messages are silently dropped.

If no level has been set by the client, all messages are sent.

## Works Everywhere

Logging is available in all handler types:

```typescript title="src/prompts/analyze.ts"
import { logger } from "xmcp";

export default async function analyze(args: any) {
logger.info("Running analysis prompt", "analyze");
return "Analysis result...";
}
```

```typescript title="src/resources/status.ts"
import { logger } from "xmcp";

export default async function status(args: any) {
logger.debug("Fetching system status", "status");
return "System OK";
}
```
1 change: 1 addition & 0 deletions apps/website/content/docs/core-concepts/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"tools",
"prompts",
"resources",
"logging",
"middlewares",
"css",
"external-clients"
Expand Down
3 changes: 3 additions & 0 deletions examples/logging/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.vercel
.xmcp
xmcp-env.d.ts
16 changes: 16 additions & 0 deletions examples/logging/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "Logging",
"description": "Learn how to send structured log messages to clients using the MCP logging capability",
"keywords": [
"logging"
],
"scripts": {
"build": "xmcp build",
"dev": "xmcp dev",
"start": "node dist/http.js"
},
"dependencies": {
"xmcp": "workspace:*",
"zod": "^4.0.10"
}
}
22 changes: 22 additions & 0 deletions examples/logging/src/tools/process-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { z } from "zod";
import { type InferSchema, type ToolMetadata, logger } from "xmcp";

export const schema = {
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
};

export const metadata: ToolMetadata = {
name: "add",
description: "Add two numbers together",
};

export default async function add({ a, b }: InferSchema<typeof schema>) {
logger.info(`Adding ${a} + ${b}`, "add");

const result = a + b;

logger.debug({ a, b, result }, "add");

return `Result: ${result}`;
}
11 changes: 11 additions & 0 deletions examples/logging/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["xmcp-env.d.ts", "src/**/*.ts"]
}
14 changes: 14 additions & 0 deletions examples/logging/xmcp.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { XmcpConfig } from "xmcp";

const config: XmcpConfig = {
http: true,
paths: {
prompts: false,
resources: false,
},
typescript: {
skipTypeCheck: true,
},
};

export default config;
2 changes: 2 additions & 0 deletions packages/xmcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type {
ToolExtraArguments,
InferSchema,
} from "./types/tool";
export type { Logger } from "./runtime/utils/logger";
export { logger } from "./runtime/utils/logger";
export type { PromptMetadata } from "./types/prompt";
export type { ResourceMetadata } from "./types/resource";
export type { UIMetadata } from "./types/ui-meta";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export async function initializeMcpServer(): Promise<McpServer> {

await Promise.all([...toolPromises, ...promptPromises, ...resourcePromises]);

const server = new McpServer(INJECTED_CONFIG);
const server = new McpServer(INJECTED_CONFIG, {
capabilities: { logging: {} },
});

await configureServer(server, toolModules, promptModules, resourceModules);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ import { greenCheck } from "../../../utils/cli-icons";
import { findAvailablePort } from "../../../utils/port-utils";
import { cors } from "./cors";
import { Provider } from "@/runtime/middlewares/utils";
import { httpRequestContextProvider } from "@/runtime/contexts/http-request-context";
import {
getHttpRequestContext,
httpRequestContextProvider,
} from "@/runtime/contexts/http-request-context";
import {
extractToolNamesFromRequest,
storeToolNamesOnRequestHeaders,
} from "@/runtime/utils/request-tool-names";
import { CorsConfig, corsConfigSchema } from "@/compiler/config/schemas";
import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types";
import { isLogLevel, setLogLevel } from "@/runtime/utils/logger";

// Global type declarations for tool name context
declare global {
Expand All @@ -44,10 +48,33 @@ export class StatelessHttpServerTransport extends BaseHttpServerTransport {
res: ServerResponse;
requestIds: Set<string | number>;
responses: JsonRpcMessage[];
notifications: JsonRpcMessage[];
expectedCount: number;
requestContextId?: string;
}
> = new Map();
private _requestToCollectorMapping: Map<string | number, string> = new Map();
private _requestContextToCollectorMapping: Map<string, string> = new Map();

private getSessionIdFromRequest(
req: IncomingMessage
): string | undefined {
const rawValue = req.headers["mcp-session-id"];

if (Array.isArray(rawValue)) {
return rawValue[0];
}

return rawValue;
}

private getRequestContextId(): string | undefined {
try {
return getHttpRequestContext().id;
} catch {
return undefined;
}
}

constructor(debug: boolean, bodySizeLimit: string) {
super();
Expand Down Expand Up @@ -81,15 +108,23 @@ export class StatelessHttpServerTransport extends BaseHttpServerTransport {
});
this._singleResponseCollectors?.clear();
this._requestToCollectorMapping?.clear();
this._requestContextToCollectorMapping?.clear();
}

async send(message: JsonRpcMessage): Promise<void> {
const requestId = message.id;

if (requestId === undefined || requestId === null) {
// In stateless mode, we can't handle notifications without request IDs
if (this.debug) {
console.log("[StatelessHTTP] Dropping notification without request ID");
const requestContextId = this.getRequestContextId();
if (!requestContextId) return;

const collectorId =
this._requestContextToCollectorMapping.get(requestContextId);
if (!collectorId) return;

const collector = this._singleResponseCollectors?.get(collectorId);
if (collector) {
collector.notifications.push(message);
}
return;
}
Expand All @@ -109,16 +144,23 @@ export class StatelessHttpServerTransport extends BaseHttpServerTransport {
"Content-Type": "application/json",
};

const allMessages = [...collector.notifications, ...collector.responses];

const responseBody =
collector.responses.length === 1
? collector.responses[0]
: collector.responses;
allMessages.length === 1
? allMessages[0]
: allMessages;

collector.res
.writeHead(200, headers)
.end(JSON.stringify(responseBody));

this._singleResponseCollectors?.delete(collectorId);
if (collector.requestContextId) {
this._requestContextToCollectorMapping.delete(
collector.requestContextId
);
}
for (const response of collector.responses) {
if (response.id !== undefined && response.id !== null) {
this._requestToCollectorMapping?.delete(response.id);
Expand Down Expand Up @@ -206,6 +248,14 @@ export class StatelessHttpServerTransport extends BaseHttpServerTransport {
const messages: JsonRpcMessage[] = Array.isArray(rawMessage)
? rawMessage
: [rawMessage];
const sessionId = this.getSessionIdFromRequest(req);

// Capture logging/setLevel so the level persists across stateless requests
for (const msg of messages) {
if (msg.method === "logging/setLevel" && isLogLevel(msg.params?.level)) {
setLogLevel(msg.params.level, sessionId);
}
}

const hasRequests = messages.some(
(msg) => msg.method && msg.id !== undefined
Expand All @@ -228,7 +278,9 @@ export class StatelessHttpServerTransport extends BaseHttpServerTransport {
}

const responseCollector: JsonRpcMessage[] = [];
const notificationCollector: JsonRpcMessage[] = [];
const expectedResponses = requestIds.length;
const requestContextId = this.getRequestContextId();

const collectorId = randomUUID();
this._singleResponseCollectors =
Expand All @@ -237,8 +289,13 @@ export class StatelessHttpServerTransport extends BaseHttpServerTransport {
res,
requestIds: new Set(requestIds),
responses: responseCollector,
notifications: notificationCollector,
expectedCount: expectedResponses,
requestContextId,
});
if (requestContextId) {
this._requestContextToCollectorMapping.set(requestContextId, collectorId);
}

for (const requestId of requestIds) {
this._requestToCollectorMapping =
Expand Down
Loading
Loading