SmartGateSmartGate

MCP Logging and Observability: Audit Rows, Retention

MCP logging is one audit row per tool call: the tool name, the identity the key resolved to, the parameters that were passed, and the token count. Everything else is policy around that row — redaction before the value is serialized, a per-plan retention window enforced by a job, and an aggregate that turns rows into usage per day, per tool and per route.

Short answer: MCP logging is one audit row per tool call: the tool name, the identity the key resolved to, the parameters that were passed, and the token count. Everything else is policy around that row — redaction before the value is serialized, a per-plan retention window enforced by a job, and an aggregate that turns rows into usage per day, per tool and per route.

Key takeaways

  • One exit path writes every row. The tool functions return through the same audited wrapper, so the shape of an entry does not depend on which tool ran.
  • Sensitive values are masked before serialization, not after. Redaction walks nested objects and lists, because a credential nested inside a tool result is still a credential.
  • Retention is an entitlement, not a setting. Each plan carries its own window and the retention job deletes per team against it.
  • The log records request parameters, not payloads. A fetched document and a tool's answer are passed through to the caller; the row keeps the arguments and the cost.
  • Attribution is a header, so it can be missing. A host that omits the platform header still produces rows — they are just rows you cannot attribute to a client.
  • Do this next: open the logs view, find one call, and check its tool, route and identity before you build a dashboard on it.

What an mcp logging layer has to answer

Most teams discover the difference between protocol logging and an audit trail on the day somebody asks a question the log cannot answer: who spent this, which client made this call, was this action authorised at the time. The Model Context Protocol has its own logging utility — server-sent log messages delivered to the client — and that is a debugging channel, not a record of who did what (MCP logging utility).

That channel is a capability the client opts into, which the mcp specification makes explicit: a server sends log messages, a client decides whether to listen, and neither side is obliged to keep them. That is exactly why it cannot stand in for the record — an audit row has to outlive the client that produced it.

An audit trail is a different object with four jobs. It has to record the call with the identity that authenticated. It has to hold enough of the request to reconstruct what happened without holding secrets or whole documents. It has to expire on a schedule somebody chose deliberately. And it has to aggregate into a number a human can act on. The rest of this page takes those four in order, quoting the code that does them.

The audit row: what an entry keeps, and why mcp prompts are not in it

An entry is written by the wrapper every tool returns through, and the shape of what it adds is visible in the one tool that has extra fields to add:

# backend/smartgate/api/mcp.py — source lines 370–381 (register_mcp_tools)
        tool_result = ToolResult(
            success=pipeline_ok,
            data=merged_data,
            error=None if pipeline_ok else (step_error or "pipeline step failed"),
            meta={},
        )
        audit_extra = {
            **params,
            "trace_kind": "pipeline",
            "pipeline_template": template or None,
            "pipeline_steps": payload.get("pipeline_steps") or [],
        }

The ToolResult is the value the client receives; audit_extra is what the row gains beyond the tool name and the parameters. Read the three additions as a taxonomy. trace_kind marks the row as a pipeline run rather than a single tool call, so a usage report can count the run once instead of counting its four steps as four separate decisions. pipeline_template keeps the name of the template that was run. pipeline_steps keeps the per-step record, which is what makes a failed run diagnosable after the fact. What is absent is as deliberate as what is present: the prompt text you sent, and the answer you received, are not in the row. Nobody has to trust a log with their content in order to get attribution from it, and a log that never held the content cannot leak it later.

A vscode mcp client, a fetch call, and the row that ties them together

The simplest tool is the best illustration of what a row can and cannot tell you:

# backend/smartgate/api/mcp.py — source lines 109–126 (smart_fetch)
@server.tool(
        name="smart_fetch",
        description=TOOL_DESCRIPTIONS["smart_fetch"],
        annotations=tool_annotations("smart_fetch"),
    )
    async def smart_fetch(
        url: str = Field(description="Full HTTP or HTTPS URL to fetch."),
        timeout: int = Field(default=30, description="HTTP timeout in seconds."),
    ) -> str:
        _, registry = _app_state()
        module = registry.get("fetch")
        ctx = _tool_ctx()
        return await _run_with_audit(
            "fetch",
            ctx,
            module.process(ctx, url=url, timeout=timeout),
            {"url": url},
        )

Two parameters, one recorded — the URL is the audit field and the timeout is not, because a timeout is a property of the transport rather than of the action. That asymmetry is the whole design in miniature: the row answers "what was fetched", and it does not answer "by whom". The identity comes from the key that authenticated, and the client comes from a header the host sends, which is why a VS Code MCP setup that was configured by hand is the usual source of rows that are attributable to a team but not to a host. If your dashboard needs a per-client breakdown, verify the header before you build the chart — a missing header does not break the call, it only removes the dimension.

Streamable HTTP, sessions, and the correlation a reconnect breaks

Server-side logging has one structural problem the protocol makes worse: a Streamable HTTP client may connect, list, call and disconnect without a session, so there is no process to hang state on. The gateway handles that with a small, idempotent set of patches applied before the MCP app is mounted:

# backend/smartgate/api/mcp_session_compat.py — source lines 89–103 (apply_mcp_session_compat)
def apply_mcp_session_compat() -> None:
    """Idempotent patches applied before mounting MCP SSE."""
    global _PATCHED
    if _PATCHED:
        return

    ServerSession._received_request = _compat_received_request  # type: ignore[method-assign]
    ServerSession._received_notification = _compat_received_notification  # type: ignore[method-assign]

    if not hasattr(_stateless_server_run, "_orig"):
        _stateless_server_run._orig = lowlevel_server.Server.run  # type: ignore[attr-defined]
        lowlevel_server.Server.run = _stateless_server_run  # type: ignore[method-assign]

    _PATCHED = True
    logger.info("MCP session compat enabled (stateless SSE + relaxed init gate)")

Two properties are worth copying. The patches are idempotent — a module-level flag makes a second call a no-op — so whichever entry point mounts the app first, the effect is applied exactly once. And the log line at the end is the one piece of observability this function produces: it records that the compatibility mode is on, which is the difference between "our client's handshake is unusual" and "our server patches the handshake". For log correlation the consequence is that you should not expect a session identifier to exist for every call. Correlate on the key and the timestamp window, and treat a session id, where one exists, as a bonus rather than as the join key.

Which client sent this? The header behind a Claude Desktop MCP call

Attribution is the one log field a client has to cooperate on, and the configuration the gateway generates asks for it explicitly:

# lib/connect/mcp-config-templates.ts — source lines 348–365 (getMcpPlatformHints)
    case "Claude Desktop":
      return [
        docsLink,
        {
          kind: "text",
          children: `Merge into ${PLATFORM_CONFIG_PATH["Claude Desktop"]}. Set type: "streamable-http" for stateless POST transport.`,
        },
        {
          kind: "text",
          children:
            "Alternative: use the claude mcp add command below (Claude Code CLI). Restart Claude Desktop after editing JSON.",
        },
        {
          kind: "text",
          children: `Include ${AGENT_PLATFORM_HEADER}: ${PLATFORM_AGENT_ID["Claude Desktop"]} in headers (included in the snippet below) so Audit Logs shows the correct agent platform.`,
        },
        ...authAndUrlHints(),
      ];

Three hints, and the third one is the observability hint: the config block includes the platform header plus the agent identifier for that host, with the stated purpose that audit logs show the correct agent platform. The same line appears in the Cursor, Windsurf, OpenClaw and Hermes branches of this function, differing only in the identifier — so the header is a property of the setup the gateway hands out, not of one client. Read that as the contract it is: a host configured from the generated block produces attributable rows, and a host configured by hand produces rows whose client column is empty. That is a data-quality problem rather than a security one, and the fix is the generated block rather than a policy.

Attribution gets harder when the callers are not clients at all. The a2a protocol puts an agent on both ends of the call, so the platform header no longer names a product somebody installed; a gateway fronting both protocols needs one rule that identifies the originating agent rather than the framework it was written in.

The row shape does not change with the surface: the same identity, policy and audit path serve the tool call and the plain HTTP request, which is what the mcp vs rest comparison is about when it says the difference is configuration rather than architecture.

openai mcp clients and the install command that returns null

Not every host can be configured by a command, and the gateway is honest about which ones can:

# lib/connect/mcp-config-templates.ts — source lines 213–225 (getMcpPlatformInstallCommand)
function getMcpPlatformInstallCommand(
  platform: McpPlatform,
  mcpUrl: string,
): string | null {
  switch (platform) {
    case "OpenClaw":
      return buildOpenClawMcpSetCommand(mcpUrl);
    case "Claude Desktop":
      return buildClaudeDesktopMcpAddCommand(mcpUrl);
    default:
      return null;
  }
}

Two hosts have a one-line installer — OpenClaw and Claude Desktop — and everything else, including an OpenAI-compatible MCP client, gets null and therefore falls back to pasting JSON from the connect page. The branch is small but the operational consequence is not: the hosts without an installer are exactly the hosts most likely to be configured by hand, and hand configuration is where the platform header gets dropped. If you operate a mixed fleet, treat the null branch as your list of clients to audit for attribution, and add the header to those config files yourself. This is also why a "which clients do we have?" question is answered by the config files rather than by the log — the log only knows about the clients that announced themselves.

Redaction: what an mcp oauth token looks like on the way into a log

A log is a copy of your request that lives somewhere else, for longer, and with fewer access controls. Redaction therefore runs before serialization:

# backend/smartgate/shared/utils.py — source lines 9–21 (mask_sensitive)
def mask_sensitive(data: Dict[str, Any], keys: set = {"api_key", "secret", "token"}) -> Dict[str, Any]:
    """脱敏敏感字段."""
    result = {}
    for k, v in data.items():
        if k in keys:
            result[k] = "***"
        elif isinstance(v, dict):
            result[k] = mask_sensitive(v, keys)
        elif isinstance(v, list):
            result[k] = [mask_sensitive(i, keys) if isinstance(i, dict) else i for i in v]
        else:
            result[k] = v
    return result

A default set of sensitive key names, a replacement constant, and three cases: the key is sensitive, the value is a nested object, or the value is a list of objects that each need the same walk. The recursion is the part that matters, because tool results are arbitrary JSON: a credential three levels down inside a fetch result or a memory payload is exactly the case a shallow implementation misses. Two honest caveats belong next to it. The match is on the key name, so a sensitive value stored under an unexpected name passes through; and the default set is a module-level literal shared by every call, which is the classic mutable-default footgun — nothing in the body mutates it today, but a future edit that adds a key in place would change every caller's behaviour at once. Copy the recursion, and pass the key set explicitly.

Why mcp resources never show up in an audit row

A response body is not a log field, and the code that consumes it says so in three lines:

# lib/api/parse-json-response.ts — source lines 3–5 (parseJsonResponse)
async function parseJsonResponse<T>(res: Response): Promise<T> {
  return (await res.json()) as T;
}

This is a cast, not a check: the transport hands back a parsed value and the caller decides what it means. For observability the implication is worth stating plainly — the gateway records the call, not the document. Ask "what did the agent read last week" and the audit row can tell you that a fetch happened, from which key, at what time, with which URL, and how many tokens it cost; it cannot show you the bytes, because it never held them as a record. The useful pattern is to log the locator and re-fetch the locator, understanding that a URL is not a stable document: the same URL returned different content last month, and a re-fetch is an observation rather than a reconstruction. Where you need the content itself for compliance, keep it in your own store with your own retention, and keep the row as the index into it.

mcp monitoring: retention is an entitlement, not a setting

The lifecycle end of the trail is a scheduled job, and it reads the window from the plan rather than from a checkbox:

# lib/jobs/audit-retention.ts — source lines 6–24 (runAuditRetention)
async function runAuditRetention() {
  const teams = await prisma.team.findMany({
    select: { id: true, plan: true, features: true },
  });
  let deleted = 0;
  for (const team of teams) {
    const days = planEntitlements(
      team.plan as Plan,
      team.features as Record<string, unknown> | null,
    ).activity_log_retention_days;
    const cutoff = new Date();
    cutoff.setDate(cutoff.getDate() - days);
    const result = await prisma.auditLog.deleteMany({
      where: { teamId: team.id, timestamp: { lt: cutoff } },
    });
    deleted += result.count;
  }
  return { teams: teams.length, deleted };
}

Three decisions are visible. The window is per team and per plan — the plan sets a default, a team feature can override it — so one deployment serves a seven-day free tier and a longer enterprise contract without a second database. The deletion is one statement per team with a timestamp predicate, which makes the operation auditable in itself: the job returns how many teams it visited and how many rows it removed, and that pair is the only thing you need to alert on. And the cutoff is computed per team rather than once for the batch, which is the detail that keeps a team that upgraded last week from being pruned at the old window. Run it on a schedule you can see, and treat a sudden drop in the deleted count as a data problem before you treat it as a good month.

Usage reporting: from rows to a number finance accepts

Reporting is a separate pass over the same rows, and its shape is decided by what it refuses to count. Failed calls are excluded, because a rejected or errored call is not usage. Playground traffic is excluded by default, because a person clicking buttons in the dashboard is not the same demand signal as an agent running unattended — and it is a filter, not a deletion, because the route is still on the row. What remains is grouped three ways: by day, by tool, and by route, with token totals accumulating beside call counts.

Question Grouping What it is good for
How much did we use this month? Day Quota pacing against the monthly cap
Which tool costs us the most? Tool Deciding what to compress, cache or forbid
Which surface drives the traffic? Route Separating agent traffic from dashboard traffic
Did a specific call happen? Raw row Incident review, with the key and the timestamp

Two rules keep the numbers honest. Count calls and tokens in the same pass, or the average cost per call drifts. And never let a reporting change alter the rows: the aggregate is derived, the rows are the record, and a report that disagrees with the raw rows is a report nobody will trust in the meeting where it matters.

Alerting on the trail: four signals a row already carries

An audit trail that nobody reads is storage. The signals worth a page are the ones the rows already contain, and there are four of them.

  • The failed-call ratio. Success is on every row, so a spike in failures is visible without instrumenting anything new. It is the fastest signal that a provider, a key or a tool argument changed.
  • Tokens per day against the cap. The daily grouping divided by the monthly ceiling is the only number that tells you the cap will bite before the invoice does — and the day it crosses half is the day to look at which tool grew.
  • Rows without a client. A count of calls with no platform header is a configuration-debt metric: every one of them is a host whose config drifted from the generated block, and the number going up means onboarding is happening by hand.
  • The retention job's own counter. Teams visited and rows deleted, once per run. A sudden zero is a job that stopped or a predicate that stopped matching, which is exactly the failure mode where the log looks fine and the data is already gone.

Two rules make them usable. Alert on a rate rather than on a total, because a growing total is normal and a growing rate is not. And keep the alert next to the query that produces it, so the first responder is reading the same rows the alert did.

How SmartGate's logging surface compares

What it records Attribution Retention What you pay
SmartGate (hosted) One audited row per call: tool, route, params, tokens, success The key and team from the credential, plus the platform header when the generated config is used Per plan, enforced by a retention job: 7 days free, longer on paid plans Free tier: 2M tokens a month, 120 MCP requests a minute per key (pricing)
Local stdio server Whatever you print, to stderr The process, and the user who started it However long your terminal scrollback lasts Your own machine
Client-side tool logs What the host chose to show in its own panel The host's session Until you close the window Your own time
A generic API gateway's access log Requests, status codes, latency IP and credential The edge's own window, usually short Per-request pricing

The honest summary is that a request log and an audit trail answer different questions. An access log tells you the endpoint was busy; an audit trail tells you which team's agent fetched which URL and what it cost, from a row that cannot leak the document. If the only thing you need is to know whether the server was up, the framework's own logging is enough. The moment somebody asks "who spent this", you need the row.

How to get started

  1. Send one authenticated call. The connect page generates the block for your host; the platform header in it is what makes the row attributable.
  2. Read the row in the logs view and confirm the tool, the route, the identity and the token count are all there.
  3. Check your retention window before you rely on the trail — a seven-day free-tier window is generous for debugging and short for compliance.
  4. Decide what must never reach a log, then confirm the redaction set covers those key names; the security page lists what is masked by default.
  5. Then read the two sibling pages — MCP tools reference for the tool surface these rows describe, and MCP gateway for the limits the same rows are used to enforce.

Start on the free tier — 2M tokens a month, all seven tools, 120 MCP requests a minute per key — then compare log retention per plan on the pricing page.

Frequently Asked Questions

Limitations and what this does not do

  • An audit row is not a trace of your model calls. The gateway records the tool call it served; what your own application does with the answer, inside your process, is outside it.
  • Redaction matches key names, not value shapes. A credential sent under an unexpected field name reaches the row, so the parameter names you use are part of your security posture.
  • The client column is only as good as the host configuration. A hand-written config drops the platform header and produces a row nobody can attribute to a client.
  • Retention deletes, and deletion is not reversible. Export what you need inside the window; the job has no soft-delete step, because a soft delete is just a longer retention window.
  • Aggregates are derived views. A dashboard that disagrees with the raw rows is a dashboard bug; the rows are the record.

Sources

Method note

The code in this article is not transcribed. Every fenced block was cut directly out of the slice body returned by the SmartGate slice API and re-asserted byte-for-byte as a substring of that body before publication; the first line inside the fence records the file and the exact source lines. Symbols were pinned with whole-name containment (rule A level 2) and confirmed by the service's slot-proof endpoint. The register_mcp_tools excerpt keeps the result construction and the audit_extra block of the pipeline tool and drops the dispatch above it, which the tools reference documents; the getMcpPlatformHints excerpt is the Claude Desktop branch, and the prose names the other branches whose third hint is the same line. Two pinned sections are deliberately not quoted here. Section 8:1 (openclaw mcp, mcpConfigFilename) is already quoted in full by the published sibling /industry/mcp-server for the same argument, so the config-filename question stays there; section 10:1 (openclaw config, smart_search) is the same audited exit as the fetch example above with a different tool, and one example of the exit path is enough. Section 1:1 (mcp observability) pinned no slice, and section 4:1 (mcp server list) is recorded as an abstention; both are written from the public sources above with no code quoted. No third-party logging or SIEM vendor implementation is quoted anywhere on this page.

Slice provenance

# SERP keyword Symbol File Source lines How it was pinned sha256(12)
1 mcp prompts register_mcp_tools backend/smartgate/api/mcp.py 370–381 rule A L2 → slot-proof 9d4a1623b28c
2 vscode mcp smart_fetch backend/smartgate/api/mcp.py 109–126 rule A L2 → slot-proof fdc9a4259783
3 streamable http apply_mcp_session_compat backend/smartgate/api/mcp_session_compat.py 89–103 rule A L2 → slot-proof 2aec583c6238
4 claude desktop mcp getMcpPlatformHints lib/connect/mcp-config-templates.ts 348–365 rule A L2 → slot-proof 097aa9ab5578
5 openai mcp getMcpPlatformInstallCommand lib/connect/mcp-config-templates.ts 213–225 rule A L2 → slot-proof 8ee797e82a2b
6 mcp oauth mask_sensitive backend/smartgate/shared/utils.py 9–21 rule A L2 → slot-proof 1776f5449b8d
7 mcp resources parseJsonResponse lib/api/parse-json-response.ts 3–5 rule A L2 → slot-proof ce9219102cfa
8 mcp monitoring runAuditRetention lib/jobs/audit-retention.ts 6–24 rule A L2 → slot-proof 9e549a91c008

Every fenced block above was cut from the slice body and re-asserted against it byte-for-byte before publication. 10 of 12 sections pinned, 1 abstentions, 1 misses.