Skip to content

feat: nudge unidentified consumers to add ?consumer=yourname#95

Merged
klappy merged 4 commits into
mainfrom
feat/consumer-identification-nudge
Apr 16, 2026
Merged

feat: nudge unidentified consumers to add ?consumer=yourname#95
klappy merged 4 commits into
mainfrom
feat/consumer-identification-nudge

Conversation

@klappy
Copy link
Copy Markdown
Owner

@klappy klappy commented Apr 16, 2026

Implements the telemetry governance requirement that unidentified consumers see a one-line footer encouraging self-identification. Only shown when consumer source is "user-agent" or "unknown" — consumers who identified via query param, header, or MCP clientInfo never see it. This completes a requirement from canon/constraints/telemetry-governance.md.


Note

Low Risk
Small, additive response-format change (extra footer text) gated to unidentified consumers; no changes to auth, data writes, or telemetry recording logic.

Overview
Adds a stateless “consumer identification” nudge appended to assistant_text for oddkit and individual tool responses when the caller’s consumer label is resolved only from user-agent or falls back to unknown.

Threads consumerSource into createServer by calling parseConsumerLabel() at the /mcp entrypoint, and documents the deliberate choice to avoid any session or isolate-local caching for identification.

Reviewed by Cursor Bugbot for commit 0f04b04. Bugbot is set up for automated code reviews on this repo. Configure here.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 16, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
oddkit 0f04b04 Commit Preview URL

Branch Preview URL
Apr 16 2026, 02:29 AM

Comment thread workers/src/index.ts
Nudge now leads with what oddkit does NOT track (prompts, searches,
responses) before asking the consumer to self-identify.
Comment thread workers/src/index.ts Outdated
Comment thread workers/src/index.ts Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Session cache TTL not refreshed on hit
    • Added cached.ts = Date.now() on cache hit to implement a sliding-window TTL so active sessions maintain their consumer identification beyond the initial 30-minute window.
Preview (06336ad7be)
diff --git a/workers/src/index.ts b/workers/src/index.ts
--- a/workers/src/index.ts
+++ b/workers/src/index.ts
@@ -20,6 +20,7 @@
 import { handleUnifiedAction, type Env } from "./orchestrate";
 import { ZipBaselineFetcher } from "./zip-baseline-fetcher";
 import { RequestTracer } from "./tracing";
+import { parseConsumerLabel } from "./telemetry";
 import { renderNotFoundPage } from "./not-found-ui";
 import pkg from "../package.json";
 
@@ -27,7 +28,35 @@
 
 const BUILD_VERSION = pkg.version;
 
+const CONSUMER_NUDGE =
+  "Tip: oddkit tracks tool usage (which tools, how often) but never your prompts, searches, or responses. Add ?consumer=yourname to your oddkit URL to appear on the public transparency leaderboard. See telemetry_policy for details.";
+
 // ──────────────────────────────────────────────────────────────────────────────
+// Session-level consumer identification cache
+//
+// MCP clientInfo.name is only sent in the initialize handshake. This cache
+// persists the resolved consumer label by Mcp-Session-Id so subsequent
+// tools/call requests in the same session retain the identification.
+// ──────────────────────────────────────────────────────────────────────────────
+
+const sessionConsumerCache = new Map<string, { label: string; source: string; ts: number }>();
+const SESSION_CONSUMER_TTL_MS = 30 * 60 * 1000;
+const SESSION_CONSUMER_MAX_ENTRIES = 1000;
+
+function evictOldestSession(): void {
+  if (sessionConsumerCache.size <= SESSION_CONSUMER_MAX_ENTRIES) return;
+  let oldestKey: string | undefined;
+  let oldestTs = Infinity;
+  for (const [key, val] of sessionConsumerCache) {
+    if (val.ts < oldestTs) {
+      oldestTs = val.ts;
+      oldestKey = key;
+    }
+  }
+  if (oldestKey !== undefined) sessionConsumerCache.delete(oldestKey);
+}
+
+// ──────────────────────────────────────────────────────────────────────────────
 // Types
 // ──────────────────────────────────────────────────────────────────────────────
 
@@ -111,7 +140,11 @@
  * (R2-cached) with module-level caching (5-minute TTL). Prompt content
  * is fetched lazily on prompts/get via the same R2 pipeline.
  */
-async function createServer(env: Env, tracer?: RequestTracer): Promise<McpServer> {
+async function createServer(
+  env: Env,
+  tracer?: RequestTracer,
+  consumerSource?: string,
+): Promise<McpServer> {
   const server = new McpServer(
     {
       name: "oddkit",
@@ -140,21 +173,75 @@
 - Validating completion: action="validate"
 - Listing available docs: action="catalog"`,
     {
-      action: z.enum([
-        "orient", "challenge", "gate", "encode", "search", "get",
-        "catalog", "validate", "preflight", "version", "cleanup_storage",
-      ]).describe("Which epistemic action to perform."),
-      input: z.string().describe("Primary input — query, claim, URI, goal, or completion claim depending on action."),
+      action: z
+        .enum([
+          "orient",
+          "challenge",
+          "gate",
+          "encode",
+          "search",
+          "get",
+          "catalog",
+          "validate",
+          "preflight",
+          "version",
+          "cleanup_storage",
+        ])
+        .describe("Which epistemic action to perform."),
+      input: z
+        .string()
+        .describe(
+          "Primary input — query, claim, URI, goal, or completion claim depending on action.",
+        ),
       context: z.string().optional().describe("Optional supporting context."),
-      mode: z.enum(["exploration", "planning", "execution"]).optional().describe("Optional epistemic mode hint."),
+      mode: z
+        .enum(["exploration", "planning", "execution"])
+        .optional()
+        .describe("Optional epistemic mode hint."),
       canon_url: z.string().optional().describe("Optional GitHub repo URL for canon override."),
-      include_metadata: z.boolean().optional().describe("When true, search/get responses include a metadata object with full parsed frontmatter. Default: false."),
-      section: z.string().optional().describe("For action='get': extract only the named ## section from the document. Returns section content or available sections if not found."),
-      sort_by: z.enum(["date", "path"]).optional().describe("For action='catalog': sort articles. 'date' returns newest first (requires frontmatter). 'path' returns all docs alphabetically, including undated."),
-      limit: z.number().min(1).max(500).optional().describe("For action='catalog': max articles to return when sort_by is provided. Default: 10, max: 500."),
-      offset: z.number().min(0).optional().describe("For action='catalog': skip this many articles before returning results. Use with limit for pagination. Default: 0."),
-      filter_epoch: z.string().optional().describe("For action='catalog': filter to articles with this epoch value in frontmatter (e.g. 'E0007')."),
-      state: z.record(z.string(), z.unknown()).optional().describe("Optional client-side conversation state, passed back and forth."),
+      include_metadata: z
+        .boolean()
+        .optional()
+        .describe(
+          "When true, search/get responses include a metadata object with full parsed frontmatter. Default: false.",
+        ),
+      section: z
+        .string()
+        .optional()
+        .describe(
+          "For action='get': extract only the named ## section from the document. Returns section content or available sections if not found.",
+        ),
+      sort_by: z
+        .enum(["date", "path"])
+        .optional()
+        .describe(
+          "For action='catalog': sort articles. 'date' returns newest first (requires frontmatter). 'path' returns all docs alphabetically, including undated.",
+        ),
+      limit: z
+        .number()
+        .min(1)
+        .max(500)
+        .optional()
+        .describe(
+          "For action='catalog': max articles to return when sort_by is provided. Default: 10, max: 500.",
+        ),
+      offset: z
+        .number()
+        .min(0)
+        .optional()
+        .describe(
+          "For action='catalog': skip this many articles before returning results. Use with limit for pagination. Default: 0.",
+        ),
+      filter_epoch: z
+        .string()
+        .optional()
+        .describe(
+          "For action='catalog': filter to articles with this epoch value in frontmatter (e.g. 'E0007').",
+        ),
+      state: z
+        .record(z.string(), z.unknown())
+        .optional()
+        .describe("Optional client-side conversation state, passed back and forth."),
     },
     {
       readOnlyHint: true,
@@ -179,6 +266,12 @@
         env,
         tracer,
       });
+      if (
+        (consumerSource === "user-agent" || consumerSource === "unknown") &&
+        result.assistant_text
+      ) {
+        result.assistant_text += "\n\n" + CONSUMER_NUDGE;
+      }
       return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
     },
   );
@@ -190,105 +283,206 @@
     description: string;
     action: string;
     schema: Record<string, z.ZodTypeAny>;
-    annotations: { readOnlyHint: boolean; destructiveHint: boolean; idempotentHint: boolean; openWorldHint: boolean };
+    annotations: {
+      readOnlyHint: boolean;
+      destructiveHint: boolean;
+      idempotentHint: boolean;
+      openWorldHint: boolean;
+    };
   }> = [
     {
       name: "oddkit_orient",
-      description: "Assess a goal, idea, or situation against epistemic modes (exploration/planning/execution). Surfaces unresolved items, assumptions, and questions. Call proactively whenever context shifts, not just at session start.",
+      description:
+        "Assess a goal, idea, or situation against epistemic modes (exploration/planning/execution). Surfaces unresolved items, assumptions, and questions. Call proactively whenever context shifts, not just at session start.",
       action: "orient",
       schema: {
         input: z.string().describe("A goal, idea, or situation description to orient against."),
         canon_url: z.string().optional().describe("Optional: GitHub repo URL for canon override."),
       },
-      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: true,
+      },
     },
     {
       name: "oddkit_challenge",
-      description: "Pressure-test a claim, assumption, or proposal against canon constraints. Surfaces tensions, missing evidence, and contradictions. Challenge proactively before encoding consequential decisions.",
+      description:
+        "Pressure-test a claim, assumption, or proposal against canon constraints. Surfaces tensions, missing evidence, and contradictions. Challenge proactively before encoding consequential decisions.",
       action: "challenge",
       schema: {
         input: z.string().describe("A claim, assumption, or proposal to challenge."),
-        mode: z.enum(["exploration", "planning", "execution"]).optional().describe("Optional epistemic mode for proportional challenge."),
+        mode: z
+          .enum(["exploration", "planning", "execution"])
+          .optional()
+          .describe("Optional epistemic mode for proportional challenge."),
         canon_url: z.string().optional().describe("Optional: GitHub repo URL for canon override."),
       },
-      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: true,
+      },
     },
     {
       name: "oddkit_gate",
-      description: "Check transition prerequisites before changing epistemic modes. Validates readiness and blocks premature convergence. Gate at every implicit mode transition, not just formal ones.",
+      description:
+        "Check transition prerequisites before changing epistemic modes. Validates readiness and blocks premature convergence. Gate at every implicit mode transition, not just formal ones.",
       action: "gate",
       schema: {
-        input: z.string().describe("The proposed transition (e.g., 'ready to build', 'moving to planning')."),
-        context: z.string().optional().describe("Optional context about what's been decided so far."),
+        input: z
+          .string()
+          .describe("The proposed transition (e.g., 'ready to build', 'moving to planning')."),
+        context: z
+          .string()
+          .optional()
+          .describe("Optional context about what's been decided so far."),
         canon_url: z.string().optional().describe("Optional: GitHub repo URL for canon override."),
       },
-      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: true,
+      },
     },
     {
       name: "oddkit_encode",
-      description: "Structure a decision, insight, or boundary as a durable record. IMPORTANT: This tool returns the structured artifact in the response — it does NOT persist or save it. The caller must save the output to storage. Standard artifact types: Observations (O), Learnings (L), Decisions (D), Constraints (C), Handoffs (H) — OLDC+H. Track OLDC+H continuously — encode what the user shared, encode what you did. Persist at natural breakpoints.",
+      description:
+        "Structure a decision, insight, or boundary as a durable record. IMPORTANT: This tool returns the structured artifact in the response — it does NOT persist or save it. The caller must save the output to storage. Standard artifact types: Observations (O), Learnings (L), Decisions (D), Constraints (C), Handoffs (H) — OLDC+H. Track OLDC+H continuously — encode what the user shared, encode what you did. Persist at natural breakpoints.",
       action: "encode",
       schema: {
         input: z.string().describe("A decision, insight, or boundary to capture."),
         context: z.string().optional().describe("Optional supporting context."),
         canon_url: z.string().optional().describe("Optional: GitHub repo URL for canon override."),
       },
-      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: false,
+      },
     },
     {
       name: "oddkit_search",
-      description: "Search canon and baseline docs by natural language query or tags. Returns ranked results with citations and excerpts. Search before claiming — not just when asked.",
+      description:
+        "Search canon and baseline docs by natural language query or tags. Returns ranked results with citations and excerpts. Search before claiming — not just when asked.",
       action: "search",
       schema: {
         input: z.string().describe("Natural language query or tags to search for."),
         canon_url: z.string().optional().describe("Optional: GitHub repo URL for canon override."),
-        include_metadata: z.boolean().optional().describe("When true, each hit includes a metadata object with full parsed frontmatter. Default: false."),
+        include_metadata: z
+          .boolean()
+          .optional()
+          .describe(
+            "When true, each hit includes a metadata object with full parsed frontmatter. Default: false.",
+          ),
       },
-      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: true,
+      },
     },
     {
       name: "oddkit_get",
-      description: "Fetch a canonical document by klappy:// URI. Returns full content, commit, and content hash. Use section parameter to extract a specific ## section.",
+      description:
+        "Fetch a canonical document by klappy:// URI. Returns full content, commit, and content hash. Use section parameter to extract a specific ## section.",
       action: "get",
       schema: {
         input: z.string().describe("Canonical URI (e.g., klappy://canon/values/orientation)."),
         canon_url: z.string().optional().describe("Optional: GitHub repo URL for canon override."),
-        include_metadata: z.boolean().optional().describe("When true, response includes a metadata object with full parsed frontmatter. Default: false."),
-        section: z.string().optional().describe("Extract only the named ## section from the document. Returns available sections if not found."),
+        include_metadata: z
+          .boolean()
+          .optional()
+          .describe(
+            "When true, response includes a metadata object with full parsed frontmatter. Default: false.",
+          ),
+        section: z
+          .string()
+          .optional()
+          .describe(
+            "Extract only the named ## section from the document. Returns available sections if not found.",
+          ),
       },
-      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: true,
+      },
     },
     {
       name: "oddkit_catalog",
-      description: "Lists available documentation with categories, counts, and start-here suggestions. Supports temporal discovery: use sort_by='date' to get recent articles with full frontmatter metadata.",
+      description:
+        "Lists available documentation with categories, counts, and start-here suggestions. Supports temporal discovery: use sort_by='date' to get recent articles with full frontmatter metadata.",
       action: "catalog",
       schema: {
         canon_url: z.string().optional().describe("Optional: GitHub repo URL for canon override."),
-        sort_by: z.enum(["date", "path"]).optional().describe("Sort articles. 'date' returns newest first (requires frontmatter). 'path' returns all docs alphabetically, including undated."),
-        limit: z.number().min(1).max(500).optional().describe("Max articles to return when sort_by is provided. Default: 10, max: 500."),
-        offset: z.number().min(0).optional().describe("Skip this many articles before returning results. Use with limit for pagination. Default: 0."),
-        filter_epoch: z.string().optional().describe("Filter to articles with this epoch value in frontmatter (e.g. 'E0007')."),
+        sort_by: z
+          .enum(["date", "path"])
+          .optional()
+          .describe(
+            "Sort articles. 'date' returns newest first (requires frontmatter). 'path' returns all docs alphabetically, including undated.",
+          ),
+        limit: z
+          .number()
+          .min(1)
+          .max(500)
+          .optional()
+          .describe("Max articles to return when sort_by is provided. Default: 10, max: 500."),
+        offset: z
+          .number()
+          .min(0)
+          .optional()
+          .describe(
+            "Skip this many articles before returning results. Use with limit for pagination. Default: 0.",
+          ),
+        filter_epoch: z
+          .string()
+          .optional()
+          .describe("Filter to articles with this epoch value in frontmatter (e.g. 'E0007')."),
       },
-      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: true,
+      },
     },
     {
       name: "oddkit_validate",
-      description: "Validates completion claims against required artifacts. Returns VERIFIED or NEEDS_ARTIFACTS. Validate proactively before claiming any task complete.",
+      description:
+        "Validates completion claims against required artifacts. Returns VERIFIED or NEEDS_ARTIFACTS. Validate proactively before claiming any task complete.",
       action: "validate",
       schema: {
         input: z.string().describe("The completion claim with artifact references."),
       },
-      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: false,
+      },
     },
     {
       name: "oddkit_preflight",
-      description: "Pre-implementation check. Returns relevant docs, constraints, definition of done, and pitfalls. Preflight before any execution that produces an artifact.",
+      description:
+        "Pre-implementation check. Returns relevant docs, constraints, definition of done, and pitfalls. Preflight before any execution that produces an artifact.",
       action: "preflight",
       schema: {
         input: z.string().describe("Description of what you're about to implement."),
         canon_url: z.string().optional().describe("Optional: GitHub repo URL for canon override."),
       },
-      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: true,
+      },
     },
     {
       name: "oddkit_version",
@@ -297,16 +491,27 @@
       schema: {
         canon_url: z.string().optional().describe("Optional: GitHub repo URL for canon override."),
       },
-      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: false,
+      },
     },
     {
       name: "oddkit_cleanup_storage",
-      description: "Storage hygiene: clears orphaned cached data. NOT required for correctness — content-addressed caching ensures fresh content is served automatically when the baseline changes.",
+      description:
+        "Storage hygiene: clears orphaned cached data. NOT required for correctness — content-addressed caching ensures fresh content is served automatically when the baseline changes.",
       action: "cleanup_storage",
       schema: {
         canon_url: z.string().optional().describe("Optional: GitHub repo URL for canon override."),
       },
-      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
+      annotations: {
+        readOnlyHint: false,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: false,
+      },
     },
   ];
 
@@ -332,6 +537,12 @@
           env,
           tracer,
         });
+        if (
+          (consumerSource === "user-agent" || consumerSource === "unknown") &&
+          result.assistant_text
+        ) {
+          result.assistant_text += "\n\n" + CONSUMER_NUDGE;
+        }
         return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
       },
     );
@@ -371,13 +582,22 @@
     async ({ sql }) => {
       if (!env.CF_ACCOUNT_ID || !env.CF_API_TOKEN) {
         return {
-          content: [{
-            type: "text" as const,
-            text: JSON.stringify({
-              action: "telemetry_public",
-              result: { error: "Telemetry queries not configured. CF_ACCOUNT_ID and CF_API_TOKEN required." },
-            }, null, 2),
-          }],
+          content: [
+            {
+              type: "text" as const,
+              text: JSON.stringify(
+                {
+                  action: "telemetry_public",
+                  result: {
+                    error:
+                      "Telemetry queries not configured. CF_ACCOUNT_ID and CF_API_TOKEN required.",
+                  },
+                },
+                null,
+                2,
+              ),
+            },
+          ],
         };
       }
 
@@ -392,10 +612,12 @@
       try {
         result = await queryTelemetry(env, sql);
         // Check for CF API error responses
-        if (typeof result === 'object' && result !== null && 'success' in result) {
+        if (typeof result === "object" && result !== null && "success" in result) {
           const r = result as Record<string, unknown>;
           if (r.success === false) {
-            result = { error: "Query failed. Check SQL syntax against the schema in the tool description." };
+            result = {
+              error: "Query failed. Check SQL syntax against the schema in the tool description.",
+            };
           }
         }
       } catch {
@@ -403,13 +625,19 @@
       }
 
       return {
-        content: [{
-          type: "text" as const,
-          text: JSON.stringify({
-            action: "telemetry_public",
-            result: { data: result, generated_at: new Date().toISOString() },
-          }, null, 2),
-        }],
+        content: [
+          {
+            type: "text" as const,
+            text: JSON.stringify(
+              {
+                action: "telemetry_public",
+                result: { data: result, generated_at: new Date().toISOString() },
+              },
+              null,
+              2,
+            ),
+          },
+        ],
       };
     },
   );
@@ -427,7 +655,8 @@
     async () => {
       // Fetch the governance doc from canon
       const fetcher = new ZipBaselineFetcher(env);
-      let policyContent = "Governance document not found. See https://github.com/klappy/klappy.dev/blob/main/canon/constraints/telemetry-governance.md";
+      let policyContent =
+        "Governance document not found. See https://github.com/klappy/klappy.dev/blob/main/canon/constraints/telemetry-governance.md";
 
       try {
         const content = await fetcher.getFile("canon/constraints/telemetry-governance.md");
@@ -437,27 +666,33 @@
       }
 
       return {
-        content: [{
-          type: "text" as const,
-          text: JSON.stringify({
-            action: "telemetry_policy",
-            result: {
-              policy: policyContent,
-              governance_uri: "klappy://canon/constraints/telemetry-governance",
-              self_report_headers: {
-                "x-oddkit-client": "Your client name (highest priority identifier)",
-                "x-oddkit-client-version": "Your client version",
-                "x-oddkit-agent-name": "The AI agent name",
-                "x-oddkit-agent-version": "The AI agent version",
-                "x-oddkit-surface": "Where this is running (e.g. claude.ai, vscode)",
-                "x-oddkit-contact-url": "URL for your project or org",
-                "x-oddkit-policy-url": "Your privacy/telemetry policy URL",
-                "x-oddkit-capabilities": "Comma-separated capability list",
+        content: [
+          {
+            type: "text" as const,
+            text: JSON.stringify(
+              {
+                action: "telemetry_policy",
+                result: {
+                  policy: policyContent,
+                  governance_uri: "klappy://canon/constraints/telemetry-governance",
+                  self_report_headers: {
+                    "x-oddkit-client": "Your client name (highest priority identifier)",
+                    "x-oddkit-client-version": "Your client version",
+                    "x-oddkit-agent-name": "The AI agent name",
+                    "x-oddkit-agent-version": "The AI agent version",
+                    "x-oddkit-surface": "Where this is running (e.g. claude.ai, vscode)",
+                    "x-oddkit-contact-url": "URL for your project or org",
+                    "x-oddkit-policy-url": "Your privacy/telemetry policy URL",
+                    "x-oddkit-capabilities": "Comma-separated capability list",
+                  },
+                  generated_at: new Date().toISOString(),
+                },
               },
-              generated_at: new Date().toISOString(),
-            },
-          }, null, 2),
-        }],
+              null,
+              2,
+            ),
+          },
+        ],
       };
     },
   );
@@ -468,8 +703,18 @@
     "oddkit_time",
     "Stateless time utility. Returns current UTC time, elapsed time since a reference timestamp, or the delta between two timestamps. No params = current time. One timestamp = elapsed. Two timestamps = delta. Accepts ISO 8601 strings or Unix epoch (seconds or milliseconds).",
     {
-      reference: z.union([z.string(), z.number()]).optional().describe("Reference timestamp (ISO 8601 string or Unix epoch in ms or seconds). When provided alone, returns elapsed time from reference to now."),
-      compare: z.union([z.string(), z.number()]).optional().describe("Second timestamp for delta calculation. Used with reference to compute the difference between two arbitrary timestamps."),
+      reference: z
+        .union([z.string(), z.number()])
+        .optional()
+        .describe(
+          "Reference timestamp (ISO 8601 string or Unix epoch in ms or seconds). When provided alone, returns elapsed time from reference to now.",
+        ),
+      compare: z
+        .union([z.string(), z.number()])
+        .optional()
+        .describe(
+          "Second timestamp for delta calculation. Used with reference to compute the difference between two arbitrary timestamps.",
+        ),
     },
     {
       readOnlyHint: true,
@@ -485,16 +730,27 @@
 
       if (compare !== undefined && reference === undefined) {
         return {
-          content: [{
-            type: "text" as const,
-            text: JSON.stringify({
-              action: "time",
-              result: { error: "\"compare\" requires \"reference\". Provide both timestamps for a delta, or just \"reference\" for elapsed time since now.", now: now.toISOString() },
-              server_time: new Date().toISOString(),
-              assistant_text: "Error: \"compare\" requires \"reference\". Provide both timestamps for a delta, or just \"reference\" for elapsed time since now.",
-              debug: { duration_ms: Date.now() - startTime },
-            }, null, 2),
-          }],
+          content: [
+            {
+              type: "text" as const,
+              text: JSON.stringify(
+                {
+                  action: "time",
+                  result: {
+                    error:
+                      '"compare" requires "reference". Provide both timestamps for a delta, or just "reference" for elapsed time since now.',
+                    now: now.toISOString(),
+                  },
+                  server_time: new Date().toISOString(),
+                  assistant_text:
+                    'Error: "compare" requires "reference". Provide both timestamps for a delta, or just "reference" for elapsed time since now.',
+                  debug: { duration_ms: Date.now() - startTime },
+                },
+                null,
+                2,
+              ),
+            },
+          ],
         };
       }
 
@@ -524,30 +780,42 @@
       } catch (err) {
         const message = err instanceof Error ? err.message : "Invalid timestamp";
         return {
-          content: [{
-            type: "text" as const,
-            text: JSON.stringify({
-              action: "time",
-              result: { error: message, now: now.toISOString() },
-              server_time: new Date().toISOString(),
-              assistant_text: `Error: ${message}`,
-              debug: { duration_ms: Date.now() - startTime },
-            }, null, 2),
-          }],
+          content: [
+            {
+              type: "text" as const,
+              text: JSON.stringify(
+                {
+                  action: "time",
+                  result: { error: message, now: now.toISOString() },
+                  server_time: new Date().toISOString(),
+                  assistant_text: `Error: ${message}`,
+                  debug: { duration_ms: Date.now() - startTime },
+                },
+                null,
+                2,
+              ),
+            },
+          ],
         };
       }
 
       return {
-        content: [{
-          type: "text" as const,
-          text: JSON.stringify({
-            action: "time",
-            result,
-            server_time: new Date().toISOString(),
-            assistant_text: assistantText,
-            debug: { duration_ms: Date.now() - startTime },
-          }, null, 2),
-        }],
+        content: [
+          {
+            type: "text" as const,
+            text: JSON.stringify(
+              {
+                action: "time",
+                result,
+                server_time: new Date().toISOString(),
+                assistant_text: assistantText,
+                debug: { duration_ms: Date.now() - startTime },
+              },
+              null,
+              2,
+            ),
+          },
+        ],
       };
     },
   );
@@ -718,7 +986,8 @@
   return {
     "Access-Control-Allow-Origin": origin,
     "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
-    "Access-Control-Allow-Headers": "Content-Type, Accept, Authorization, Mcp-Session-Id, Last-Event-ID",
+    "Access-Control-Allow-Headers":
+      "Content-Type, Accept, Authorization, Mcp-Session-Id, Last-Event-ID",
     "Access-Control-Expose-Headers": "Mcp-Session-Id",
   };
 }
@@ -738,7 +1007,10 @@
 
     // Redirect to getting started article
     if (url.pathname === "/" && request.method === "GET") {
-      return Response.redirect("https://klappy.dev/page/writings/getting-started-with-odd-and-oddkit", 302);
+      return Response.redirect(
+        "https://klappy.dev/page/writings/getting-started-with-odd-and-oddkit",
+        302,
+      );
     }
 
     // MCP server card
@@ -749,7 +1021,8 @@
             url: `${url.origin}/mcp`,
             name: "oddkit",
             version: env.ODDKIT_VERSION || BUILD_VERSION,
-            description: "Epistemic governance — policy retrieval, completion validation, and decision capture",
+            description:
+              "Epistemic governance — policy retrieval, completion validation, and decision capture",
             capabilities: { tools: {}, resources: {}, prompts: {} },
           },
         },
@@ -796,11 +1069,47 @@
 
       // Clone before handler consumes the body
       const telemetryClone =
-        env.ODDKIT_TELEMETRY && request.method === "POST"
-          ? request.clone()
-          : null;
+        env.ODDKIT_TELEMETRY && request.method === "POST" ? request.clone() : null;
 
-      const server = await createServer(env, tracer);
+      // Parse body for consumer identification (clientInfo.name lives in initialize)
+      let payload: unknown = {};
+      if (request.method === "POST") {
+        const bodyClone = request.clone();
+        try {
+          payload = await bodyClone.json();
+        } catch {
+          payload = {};
+        }
+      }
+
+      const sessionId = request.headers.get("mcp-session-id") || undefined;
+      let consumerSource: string;
+      const resolved = parseConsumerLabel(request, payload);
+
+      if (resolved.source !== "user-agent" && resolved.source !== "unknown") {
+        consumerSource = resolved.source;
+        if (sessionId) {
+          sessionConsumerCache.set(sessionId, {
+            label: resolved.label,
+            source: resolved.source,
+            ts: Date.now(),
+          });
+          evictOldestSession();
+        }
+      } else if (sessionId && sessionConsumerCache.has(sessionId)) {
+        const cached = sessionConsumerCache.get(sessionId)!;
+        if (Date.now() - cached.ts < SESSION_CONSUMER_TTL_MS) {
+          cached.ts = Date.now();
+          consumerSource = cached.source;
+        } else {
+          sessionConsumerCache.delete(sessionId);
+          consumerSource = resolved.source;
+        }
+      } else {
+        consumerSource = resolved.source;
+      }
+
+      const server = await createServer(env, tracer, consumerSource);
       const handler = createMcpHandler(server, {
         route: "/mcp",
         corsOptions: {
@@ -812,6 +1121,22 @@
       });
       const response = await handler(request, env, ctx);
 
+      // The initialize handshake carries clientInfo.name but no Mcp-Session-Id
+      // in the request — the server assigns it in the response. Backfill the
+      // session cache using the response header so later tools/call requests
+      // find the cached identification and suppress the nudge.
+      if (!sessionId && resolved.source !== "user-agent" && resolved.source !== "unknown") {
+        const assignedSessionId = response.headers.get("mcp-session-id");
+        if (assignedSessionId) {
+          sessionConsumerCache.set(assignedSessionId, {
+            label: resolved.label,
+            source: resolved.source,
+            ts: Date.now(),
+          });
... diff truncated: showing 800 of 807 lines

You can send follow-ups to the cloud agent here.

Comment thread workers/src/index.ts Outdated
Reverts bugbot's session cache additions. The server is stateless by
design — caching consumer identification across requests violates Vodka
Architecture and doesn't even work reliably on Cloudflare Workers'
distributed isolates. The query param is the stateless solution.

Inline comment prevents future regression.
@klappy klappy force-pushed the feat/consumer-identification-nudge branch from 06336ad to be30f6d Compare April 16, 2026 02:24
@klappy klappy merged commit 4b95fbe into main Apr 16, 2026
5 checks passed
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Nudge logic duplicated across two tool handler paths
    • Extracted the duplicated 3-line nudge condition into a shared maybeAppendNudge helper function called from both the unified oddkit tool handler and the individual tools loop.

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 0f04b04. Configure here.

Comment thread workers/src/index.ts
});
if ((consumerSource === "user-agent" || consumerSource === "unknown") && result.assistant_text) {
result.assistant_text += "\n\n" + CONSUMER_NUDGE;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nudge logic duplicated across two tool handler paths

Low Severity

The identical nudge-appending condition and mutation (consumerSource check + result.assistant_text += ...) is duplicated across the unified oddkit tool handler and the individual tools loop. If the nudge condition later changes (e.g., adding more source types or excluding error responses where result.action === "error"), one location could easily be updated while the other is missed, leading to inconsistent behavior. A small helper like maybeAppendNudge(result, consumerSource) would centralize this logic.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0f04b04. Configure here.

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.

1 participant