feat: nudge unidentified consumers to add ?consumer=yourname#95
Conversation
Deploying with
|
| 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 |
Nudge now leads with what oddkit does NOT track (prompts, searches, responses) before asking the consumer to self-identify.
There was a problem hiding this comment.
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.
- Added
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 linesYou can send follow-ups to the cloud agent here.
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.
06336ad to
be30f6d
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
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
maybeAppendNudgehelper function called from both the unified oddkit tool handler and the individual tools loop.
- Extracted the duplicated 3-line nudge condition into a shared
You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 0f04b04. Configure here.
| }); | ||
| if ((consumerSource === "user-agent" || consumerSource === "unknown") && result.assistant_text) { | ||
| result.assistant_text += "\n\n" + CONSUMER_NUDGE; | ||
| } |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 0f04b04. Configure here.


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_textforoddkitand individual tool responses when the caller’s consumer label is resolved only fromuser-agentor falls back tounknown.Threads
consumerSourceintocreateServerby callingparseConsumerLabel()at the/mcpentrypoint, 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.