Skip to content

Add support for logging capability - #521

Open
0xKoller wants to merge 12 commits into
canaryfrom
add-support-for-logging-capability-xmcp-409
Open

Add support for logging capability#521
0xKoller wants to merge 12 commits into
canaryfrom
add-support-for-logging-capability-xmcp-409

Conversation

@0xKoller

Copy link
Copy Markdown
Contributor

Summary

  • Implemented the MCP https://modelcontextprotocol.io/specification/draft/server/utilities/logging, enabling
    servers to send structured log messages to connected clients
  • Exposed an importable logger from xmcp with all RFC 5424 severity levels (debug, info, notice, warning, error,
    critical, alert, emergency)
  • Server declares logging capability by default, no configuration needed
  • Clients control verbosity via logging/setLevel; messages below the threshold are silently dropped

Usage

import { logger } from "xmcp";

export default async function handler({ source }) {
    logger.info(`Processing ${source}`, "my-tool");
    logger.debug({ step: "validate", source }, "my-tool");
}

Example

For the following code

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}`;
}

In the client with debug level logs
Screenshot 2026-03-17 at 16 34 02

In the client with info level logs
Screenshot 2026-03-17 at 16 35 11

@0xKoller
0xKoller requested a review from valebearzotti as a code owner March 17, 2026 19:36
@linear

linear Bot commented Mar 17, 2026

Copy link
Copy Markdown

@vercel

vercel Bot commented Mar 17, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
xmcp-website Ready Ready Preview, Comment Apr 28, 2026 10:02pm

@0xKoller 0xKoller linked an issue Mar 17, 2026 that may be closed by this pull request
@github-actions

github-actions Bot commented Mar 17, 2026

Copy link
Copy Markdown

Rspack Bundle Analysis

Build Assets Total Size (MB) Build Time
Main Compiler 4 5.28 5.16s
Runtime Compiler 6 4.34 5.38s

Main Compiler

Source: stats-main.json

Asset Chunk Size (KB) Size (MB)
cli.js cli 4942.91 4.83
index.js index 369.90 0.36
cloudflare.js cloudflare 81.70 0.08
detached-flush.js detached-flush 15.18 0.01

Total emitted JS: 5.28 MB

Runtime Compiler

Source: stats-runtime.json

Asset Chunk Size (KB) Size (MB)
http.js http 1042.98 1.02
adapter-nestjs.js adapter-nestjs 1019.30 1.00
adapter-nextjs.js adapter-nextjs 1013.85 0.99
adapter-express.js adapter-express 1011.61 0.99
stdio.js stdio 353.59 0.35
headers.js headers 1.95 0.00

Total emitted JS: 4.34 MB

Package Footprint (npm pack + npm install)

Item Size (KB) Size (MB)
Tarball (.tgz) 3385.88 3.31
dist/ 9927.68 9.70
node_modules/ 101316.79 98.94
dist + node_modules 111244.47 108.64

@greptile-apps

greptile-apps Bot commented Mar 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR implements the MCP logging capability, exposing a zero-config logger singleton that tools, prompts, and resources can import directly from xmcp. The logger resolves the active McpServer and session ID from AsyncLocalStorage context, enabling structured RFC 5424 log messages to be sent to the connected client. Clients control verbosity via logging/setLevel; the server stores the level per session (with a 30-minute sliding TTL) and silently drops messages below the threshold.

Key architectural changes:

  • New packages/xmcp/src/runtime/utils/logger.ts — per-session log-level store, isLogLevel guard, and a try/catch-wrapped sendLoggingMessage call so logging never crashes a handler
  • packages/xmcp/src/utils/context.ts — fallback store upgraded from a single .current reference to a stack to prevent the cross-request bleed identified in a previous review; however the synchronous-error path still does not pop the stack (the finally block is intentionally empty, relying solely on Promise.finally(), so a callback that throws before returning a promise will leave a stale entry permanently on the stack)
  • Both HTTP transports (stateless-streamable-http.ts, web-stateless-http.ts) — logging/setLevel is intercepted before messages are forwarded to the SDK, and notifications are now buffered per-request and prepended to the JSON-RPC response
  • All three transformer files (tool / prompt / resource) — handler execution is wrapped in loggerContextProvider so the ALS chain is always set up correctly
  • McpServer is declared with capabilities: { logging: {} } in all server-creation paths

Confidence Score: 3/5

  • Safe to merge with fixes — the core feature works correctly for the primary (async) path, but one latent stack-corruption bug in the context provider should be addressed before broader use.
  • The previous review's critical concerns (global log level, missing try/catch, missing isLogLevel validation, .current bleed) are all addressed. The remaining P1 issue — the empty finally block in context.ts not popping the fallback stack on synchronous throws — does not affect the current callers (all callbacks are async), but the provider API accepts synchronous callbacks and silently corrupts the stack if one throws. Two P2 performance issues in logger.ts (indexOf and O(n) cleanup on every log call) and missing import type annotations are minor but worth addressing.
  • packages/xmcp/src/utils/context.ts (synchronous error path leaks stack entry), packages/xmcp/src/runtime/utils/logger.ts (performance of shouldLog and cleanupExpiredSessionLogLevels)

Important Files Changed

Filename Overview
packages/xmcp/src/utils/context.ts Upgraded fallback store from a single .current reference to a stack to prevent cross-request bleed; however the provider finally block is empty, so a synchronous callback throw leaks the stack entry permanently.
packages/xmcp/src/runtime/utils/logger.ts New logger implementation with per-session TTL-based log levels, isLogLevel validation, and try/catch around sendLoggingMessage; two minor performance issues: indexOf used twice per log call and O(n) session cleanup on every log statement.
packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts Added per-request notification routing via _requestContextToCollectorMapping and notifications array per collector; logging/setLevel is validated with isLogLevel before being applied.
packages/xmcp/src/runtime/transports/http/web-stateless-http.ts Web transport mirrors the Node.js transport changes: per-request notification buffering and validated logging/setLevel interception.
packages/xmcp/src/runtime/utils/transformers/tool.ts Handler wrapped in loggerContextProvider so the logger's ALS context is available inside tool execution; McpServer import should be import type.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/xmcp/src/utils/context.ts
Line: 104-120

Comment:
**Stack not popped when callback throws synchronously**

The `finally` block is intentionally left empty, relying on the `Promise.finally()` path for async cleanup. However, if `context.run(initialValue, callback)` throws synchronously (e.g., when the callback itself throws before returning a promise), the code reaches neither the `isPromiseLike` branch nor the direct `stack.pop()` call — only the empty `finally` runs. The stack entry is permanently leaked.

Every subsequent call to `getContext()` outside the ALS chain will then see a stale context from the failed request, potentially routing log messages to the wrong session or server.

The fix is to always pop in the `finally` block for the non-async case:

```ts
const provider = <R>(initialValue: T, callback: () => R): R => {
  fallbackStoreWrapper.stack.push(initialValue);
  let asyncInFlight = false;
  try {
    const result = context.run(initialValue, callback);

    if (isPromiseLike(result)) {
      asyncInFlight = true;
      return Promise.resolve(result).finally(() => {
        fallbackStoreWrapper.stack.pop();
      }) as R;
    }

    return result;
  } finally {
    if (!asyncInFlight) {
      fallbackStoreWrapper.stack.pop();
    }
  }
};
```

In the current codebase all callers pass an `async () => {}` callback so the async path is always taken — but the function's type signature accepts synchronous callbacks and the fallback path is silently broken.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/xmcp/src/runtime/utils/logger.ts
Line: 87-92

Comment:
**Use a pre-built index Map instead of `indexOf` for level comparisons**

`shouldLog` calls `LOG_LEVELS.indexOf()` twice on every single log statement, making each check O(n) even though `n` is fixed. A module-level `Map<LogLevel, number>` built once at startup gives O(1) lookups at call time.

`LOG_LEVEL_SET` is already defined for membership checks — extend the same pattern to ordering:

```ts
const LOG_LEVEL_INDEX = new Map<LogLevel, number>(
  LOG_LEVELS.map((level, i) => [level, i])
);

function shouldLog(level: LogLevel, sessionId?: string): boolean {
  return (
    (LOG_LEVEL_INDEX.get(level) ?? 0) >=
    (LOG_LEVEL_INDEX.get(getCurrentLogLevel(sessionId)) ?? 0)
  );
}
```

**Rule Used:** advanced-typescript-google/references/runtime-use-... ([source](https://app.greptile.com/review/custom-context?memory=83961b3a-d9ef-4dd6-93fa-8a5cde219f99))

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/xmcp/src/runtime/utils/logger.ts
Line: 74-76

Comment:
**O(n) session cleanup on every log statement**

`cleanupExpiredSessionLogLevels` iterates the entire `sessionLogLevels` Map on every call to `getCurrentLogLevel`, which is invoked for every log statement (`logger.debug(...)`, `logger.info(...)`, etc.). As the number of tracked sessions grows, this becomes a linear scan on a hot path.

The cleanup is already triggered whenever `setLogLevel` is called (once per `logging/setLevel` request), which is a much lower-frequency path. Consider moving the cleanup there only, and keeping `getCurrentLogLevel` a pure read:

```ts
function getCurrentLogLevel(sessionId?: string): LogLevel {
  if (!sessionId) return "debug";

  const entry = sessionLogLevels.get(sessionId);
  if (!entry) return "debug";

  if (entry.expiresAt <= Date.now()) {
    sessionLogLevels.delete(sessionId);
    return "debug";
  }

  // Sliding TTL
  entry.expiresAt = Date.now() + SESSION_LOG_LEVEL_TTL_MS;
  return entry.level;
}
```

This checks only the current session's entry instead of scanning all sessions, and still handles expiry lazily on lookup.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/xmcp/src/runtime/utils/transformers/tool.ts
Line: 4

Comment:
**Use `import type` for type-only imports**

`McpServer` is only used as a type annotation for the `server` parameter — it has no runtime value reference. The same pattern applies to the equivalent imports in `transformers/prompt.ts` (line 4) and `transformers/resource.ts` (line 4). All three should use `import type` to keep the emitted JavaScript free of unnecessary side-effect imports and to make intent explicit:

```suggestion
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
```

Also applies at:
- `packages/xmcp/src/runtime/utils/transformers/prompt.ts:4`
- `packages/xmcp/src/runtime/utils/transformers/resource.ts:4`

**Rule Used:** advanced-typescript-google/references/module-use-t... ([source](https://app.greptile.com/review/custom-context?memory=a14c574a-72bb-4068-980b-d36a745a3784))

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: c6b8038

Comment thread packages/xmcp/src/runtime/utils/logger.ts Outdated
Comment thread packages/xmcp/src/runtime/utils/logger.ts Outdated
Comment thread packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts Outdated
Comment thread packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts Outdated
Comment thread packages/xmcp/src/utils/context.ts
@0xKoller

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread packages/xmcp/src/utils/context.ts

@valebearzotti valebearzotti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • solve merge conflicts

return "debug";
}

// Sliding TTL: keep active sessions alive.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does this session work? http requests are alive during the request alone, what does this do?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is tied to the mcp session id, not the raw http request lifetime. the request itself is stateless, but the client keeps sending the same mcp-session-id, and we use that to keep the chosen log level across handler executions. otherwise an exported logger would only respect logging/setLevel for the exact request that sent it, which would be wrong for actual handler usage. on the node/http path we do keep session lifecycle in memory already.

Comment thread packages/xmcp/src/runtime/utils/transformers/tool.ts Outdated
Comment thread packages/xmcp/src/runtime/utils/server.ts Outdated
@0xKoller

Copy link
Copy Markdown
Contributor Author

Implemented what was asked, and some comments about it:

  • i moved that wrapping up to the registration boundary instead, so the public logger still works in all handlers but the transformer signatures stay clean. so no prop drilling

  • SERVER_INFO is just the sdk Implementation payload, so logging capability does not belong there. i moved it into injected server options instead, and both server creation paths now consume the same generated options object.

also worth calling out: since logger is exported from xmcp, i treated it as a public handler api, not just an internal helper. so now it works consistently in tool/prompt/resource handlers, and if someone calls it outside an active xmcp handler context it just no-ops instead of throwing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Add support for logging capability

2 participants