diff --git a/CHANGELOG.md b/CHANGELOG.md index 776f79d..82b9d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # @copilotkit/pathfinder +## 1.15.2 + +### Patch Changes + +- **Abuse blocklist**: hard-coded query-pattern blocklist short-circuits searches for off-topic terms confirmed via production analytics (box-office news, SCOTUS/Kalshi/CFTC certiorari, etc.). Matched queries return a structured 200 response with `blocked: true` and a teaching hint pointing at the server's actual domain (CopilotKit + AG-UI documentation). Zero false-positives on legitimate documentation queries. +- **`query_log` attribution**: added `client_ip`, `user_agent`, `blocked`, `block_reason` columns. Per-request IP/UA now stored alongside the query for cross-correlation; no more session-id join against external systems for IP attribution. Schema change is additive (nullable) — backward compatible. + ## 1.15.1 ### Patch Changes diff --git a/package.json b/package.json index de01ba2..175a1d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/pathfinder", - "version": "1.15.1", + "version": "1.15.2", "description": "The knowledge server for AI agents — index docs, code, Notion, Slack, and Discord into searchable, agent-accessible knowledge via MCP. Supports OpenAI, Ollama, and local transformers.js embeddings.", "type": "module", "repository": { diff --git a/src/__tests__/abuse-blocklist.test.ts b/src/__tests__/abuse-blocklist.test.ts new file mode 100644 index 0000000..27397b1 --- /dev/null +++ b/src/__tests__/abuse-blocklist.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "vitest"; +import { checkBlocklist } from "../mcp/abuse-blocklist.js"; + +// --------------------------------------------------------------------------- +// Positive matches — each pattern must catch its abuse string. +// +// Every pattern that ships in PATTERNS gets a positive test here. If a new +// pattern is added to the module without a corresponding positive test, the +// "every pattern has a positive test" sweep below will fail. +// --------------------------------------------------------------------------- + +describe("checkBlocklist — positive matches", () => { + const positives: Array<{ name: string; reason: string; query: string }> = [ + { + name: "movie-box-office", + reason: "pattern:movie-box-office", + query: "Toy Story 5 box office opening weekend", + }, + { + name: "movie-box-office (no space)", + reason: "pattern:movie-box-office", + query: "boxoffice numbers for this weekend", + }, + { + name: "toy-story-5", + reason: "pattern:toy-story-5", + query: "When does Toy Story 5 release in theaters", + }, + { + name: "disclosure-day", + reason: "pattern:disclosure-day", + query: "What is Disclosure Day", + }, + { + name: "obsession-2026", + reason: "pattern:obsession-2026", + query: "Obsession movie 2026 cast", + }, + { + name: "obsession-2026 (no 'movie')", + reason: "pattern:obsession-2026", + query: "Obsession 2026 trailer", + }, + { + name: "scary-movie-2026", + reason: "pattern:scary-movie-2026", + query: "Scary Movie 2026 release date", + }, + { + name: "scotus-kalshi (scotus then kalshi)", + reason: "pattern:scotus-kalshi", + query: "SCOTUS denies Kalshi appeal in election prediction case", + }, + { + name: "scotus-kalshi (kalshi then scotus)", + reason: "pattern:scotus-kalshi", + query: "Kalshi expects SCOTUS ruling next term", + }, + { + name: "scotus-kalshi (cftc then certiorari)", + reason: "pattern:scotus-kalshi", + query: "CFTC certiorari petition status", + }, + { + name: "sports-event-contracts", + reason: "pattern:sports-event-contracts", + query: "sports event contracts legality update", + }, + { + name: "sports-event-contracts (singular sport)", + reason: "pattern:sports-event-contracts", + query: "sport event contract market", + }, + ]; + + for (const { name, reason, query } of positives) { + it(`matches: ${name}`, () => { + const result = checkBlocklist(query); + expect(result.matched).toBe(true); + expect(result.reason).toBe(reason); + }); + } +}); + +// --------------------------------------------------------------------------- +// Negative matches — real CopilotKit / AG-UI documentation queries that +// contain near-miss phrasings must NOT trigger the blocklist. Each near-miss +// targets a specific pattern that could plausibly false-positive without the +// `\b` boundary or required co-occurrence — they're the regression guard for +// the "zero FP" guarantee in the module JSDoc. +// --------------------------------------------------------------------------- + +describe("checkBlocklist — legitimate queries do not match", () => { + const negatives: string[] = [ + // Near-miss for movie-box-office: contains "box" and "office" but not as + // an adjacent phrase. `\b...\b` + `\s*` between the two words gates this. + "useCopilotAction box for the office layout", + "how to render a checkbox in the office hours page", + // Near-miss for toy-story-5: contains the version-y "5" but no "toy story". + "version 5 of the agent toolkit", + // Near-miss for disclosure-day: "day" alone or "disclosure" in a privacy + // context. Pattern requires both adjacent. + "how to set disclosure on a tool result", + "what day does the agent run on", + // Near-miss for obsession-2026 / scary-movie-2026: bare "2026" must not + // match. The pattern requires the movie-title prefix. + "roadmap for 2026 release", + "agentic frameworks 2026 outlook", + // Near-miss for scotus-kalshi: "certiorari" alone, or "cftc" alone, in + // contexts that don't co-occur with the other half. The pattern requires + // BOTH a court/agency term AND a docket term. + "certiorari is a legal term but unrelated", + "what does CFTC stand for in documentation", + // Near-miss for sports-event-contracts: words present but not the phrase. + "sport in our user interface event contract", + // Plain CopilotKit / AG-UI documentation queries — these are the bread + // and butter of legitimate traffic; any false-positive here would be a + // direct user-visible regression. + "How do I install CopilotKit in a Next.js app", + "useCopilotAction onClick handler example", + "AG-UI event types for streaming responses", + "configure copilot runtime with anthropic", + "MCP server health endpoint", + "How to debug a langgraph agent", + ]; + + for (const query of negatives) { + it(`does not match: "${query}"`, () => { + const result = checkBlocklist(query); + expect(result.matched).toBe(false); + expect(result.reason).toBeUndefined(); + }); + } +}); + +// --------------------------------------------------------------------------- +// Shape / API contract +// --------------------------------------------------------------------------- + +describe("checkBlocklist — return shape", () => { + it("returns matched=false with no reason when nothing matches", () => { + const result = checkBlocklist("hello world"); + expect(result).toEqual({ matched: false }); + }); + + it("returns matched=true with a `pattern:` prefixed reason on match", () => { + const result = checkBlocklist("box office"); + expect(result.matched).toBe(true); + expect(result.reason).toMatch(/^pattern:/); + }); + + it("is case-insensitive", () => { + expect(checkBlocklist("BOX OFFICE").matched).toBe(true); + expect(checkBlocklist("Box Office").matched).toBe(true); + expect(checkBlocklist("box office").matched).toBe(true); + }); + + it("returns matched=false for the empty string", () => { + expect(checkBlocklist("")).toEqual({ matched: false }); + }); +}); diff --git a/src/__tests__/analytics.test.ts b/src/__tests__/analytics.test.ts index 7e3e34b..a1fb995 100644 --- a/src/__tests__/analytics.test.ts +++ b/src/__tests__/analytics.test.ts @@ -66,6 +66,15 @@ describe("logQuery", () => { "docs", "sess-123", "user", + // v1.15.2 attribution columns. The baseEntry above doesn't carry + // client_ip/user_agent/blocked/block_reason, so the writer persists the + // documented defaults: null for the two optional strings, false for the + // blocked flag, and null for the reason. Pinning these explicitly so any + // future drift in the column order or default coercion fails loudly. + null, + null, + false, + null, ]); }); @@ -87,6 +96,11 @@ describe("logQuery", () => { baseEntry.source_name, baseEntry.session_id, baseEntry.request_source, + // v1.15.2 attribution columns (see non-redacted path above). + null, + null, + false, + null, ]); // And pin the literal so the constant can never silently drift to a // different sentinel that downstream reads wouldn't recognize. @@ -146,6 +160,59 @@ describe("logQuery", () => { const [, params] = mockQuery.mock.calls[0]; expect(params[7]).toBe("synthetic"); }); + + // v1.15.2 attribution columns: client_ip, user_agent, blocked, block_reason. + // Positional indices on params are 8, 9, 10, 11 respectively (after the + // existing 8 fields tool_name..request_source). + + it("persists client_ip and user_agent verbatim when provided", async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + await logQuery({ + ...baseEntry, + client_ip: "203.0.113.10", + user_agent: "Claude-User/1.0", + }); + + const [, params] = mockQuery.mock.calls[0]; + expect(params[8]).toBe("203.0.113.10"); + expect(params[9]).toBe("Claude-User/1.0"); + }); + + it("truncates a pathological user_agent to USER_AGENT_MAX_LEN chars", async () => { + // Defensive truncation guards against a malicious / runaway UA bloating + // the row. Pinning the cap here so any future change to USER_AGENT_MAX_LEN + // is a deliberate, test-visible decision. + mockQuery.mockResolvedValueOnce({ rows: [] }); + const huge = "x".repeat(2048); + await logQuery({ ...baseEntry, user_agent: huge }); + + const [, params] = mockQuery.mock.calls[0]; + expect((params[9] as string).length).toBe(256); + }); + + it("persists blocked=true with a block_reason", async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + await logQuery({ + ...baseEntry, + blocked: true, + block_reason: "pattern:movie-box-office", + }); + + const [, params] = mockQuery.mock.calls[0]; + expect(params[10]).toBe(true); + expect(params[11]).toBe("pattern:movie-box-office"); + }); + + it("defaults blocked to false and block_reason to null when absent", async () => { + // The writer normalizes missing attribution fields so callers that + // pre-date v1.15.2 (or simply don't care) get back-compatible behavior. + mockQuery.mockResolvedValueOnce({ rows: [] }); + await logQuery(baseEntry); + + const [, params] = mockQuery.mock.calls[0]; + expect(params[10]).toBe(false); + expect(params[11]).toBeNull(); + }); }); // --------------------------------------------------------------------------- diff --git a/src/cli.ts b/src/cli.ts index 6fc5ea0..4ad6be0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ const program = new Command(); program .name("pathfinder") .description("The knowledge server for AI agents") - .version("1.15.1"); + .version("1.15.2"); program .command("init") diff --git a/src/db/analytics.ts b/src/db/analytics.ts index 2fcf0bd..fc4178f 100644 --- a/src/db/analytics.ts +++ b/src/db/analytics.ts @@ -136,8 +136,39 @@ export interface QueryLogEntry { * so the persisted column is always a known value for new rows. */ request_source?: RequestSource | string | null; + /** + * Per-request client IP at MCP-session init, resolved via the same trust- + * proxy boundary as {@link oauthClientIp} / {@link clientIp}. Optional so + * existing call sites compile unchanged; absent values persist as NULL. + */ + client_ip?: string | null; + /** + * Per-request User-Agent at MCP-session init, truncated to + * {@link USER_AGENT_MAX_LEN} chars at the write boundary to bound row size. + */ + user_agent?: string | null; + /** + * Whether the abuse blocklist short-circuited the query. Defaults to false + * at the write boundary so call sites that don't pass it compile and read + * back as "not blocked". + */ + blocked?: boolean; + /** + * Free-form reason string when {@link blocked} is true (e.g. a + * `pattern:` tag from the abuse blocklist). NULL when not blocked. + */ + block_reason?: string | null; } +/** + * Hard cap on `user_agent` storage to bound row size. Pathfinder doesn't need + * the full UA for any analytics workflow — first 256 chars is more than enough + * to distinguish Claude-User, browsers, curl, and the common bots. Truncating + * at the write boundary keeps a runaway / pathological UA header from bloating + * the column. + */ +export const USER_AGENT_MAX_LEN = 256; + export interface AnalyticsSummary { total_queries: number; total_queries_window: number; @@ -275,10 +306,22 @@ export async function logQuery( // written before this column existed stay NULL and are read back as real // users by the analytics layer. const requestSource = normalizeRequestSource(entry.request_source); + // Defensive truncation at the write boundary so a pathological UA header + // can't bloat the row. See USER_AGENT_MAX_LEN. `null`/`undefined` pass + // through unchanged; `slice` on a string is safe for non-ASCII too because + // it operates on UTF-16 code units, not bytes, so a 256-cap always fits + // any DB text column with reasonable headroom. + const userAgent = + typeof entry.user_agent === "string" + ? entry.user_agent.slice(0, USER_AGENT_MAX_LEN) + : (entry.user_agent ?? null); + const blocked = entry.blocked ?? false; + const blockReason = entry.block_reason ?? null; + const clientIp = entry.client_ip ?? null; try { await pool.query( - `INSERT INTO query_log (tool_name, query_text, result_count, top_score, latency_ms, source_name, session_id, request_source) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + `INSERT INTO query_log (tool_name, query_text, result_count, top_score, latency_ms, source_name, session_id, request_source, client_ip, user_agent, blocked, block_reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, [ entry.tool_name, text, @@ -288,6 +331,10 @@ export async function logQuery( entry.source_name, entry.session_id, requestSource, + clientIp, + userAgent, + blocked, + blockReason, ], ); } catch (err) { diff --git a/src/db/schema.ts b/src/db/schema.ts index 8442507..15f98e0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -110,6 +110,10 @@ CREATE TABLE IF NOT EXISTS query_log ( source_name TEXT, session_id TEXT, request_source TEXT, + client_ip TEXT, + user_agent TEXT, + blocked BOOLEAN NOT NULL DEFAULT FALSE, + block_reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -122,6 +126,20 @@ CREATE INDEX IF NOT EXISTS idx_query_log_tool_name ON query_log (tool_name); ALTER TABLE query_log ADD COLUMN IF NOT EXISTS request_source TEXT; CREATE INDEX IF NOT EXISTS idx_query_log_request_source ON query_log (request_source); +-- Per-request attribution columns (v1.15.2). Added so cross-correlation against +-- an external system (PostHog, etc.) by session-id-prefix is no longer required +-- to answer "which IP/UA sent this query". All four columns are additive and +-- nullable / defaulted, so the migration is backward compatible for installs +-- whose query_log predates v1.15.2. The CREATE TABLE above carries them +-- verbatim for fresh installs. The blocked column defaults to FALSE so +-- historical rows read back as "not blocked" without a backfill. +ALTER TABLE query_log ADD COLUMN IF NOT EXISTS client_ip TEXT; +ALTER TABLE query_log ADD COLUMN IF NOT EXISTS user_agent TEXT; +ALTER TABLE query_log ADD COLUMN IF NOT EXISTS blocked BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE query_log ADD COLUMN IF NOT EXISTS block_reason TEXT; +CREATE INDEX IF NOT EXISTS idx_query_log_blocked ON query_log (blocked); +CREATE INDEX IF NOT EXISTS idx_query_log_client_ip ON query_log (client_ip); + -- Webhook delivery tracking CREATE TABLE IF NOT EXISTS webhook_deliveries ( id SERIAL PRIMARY KEY, diff --git a/src/mcp/abuse-blocklist.ts b/src/mcp/abuse-blocklist.ts new file mode 100644 index 0000000..b764eea --- /dev/null +++ b/src/mcp/abuse-blocklist.ts @@ -0,0 +1,61 @@ +/** + * Pattern-based blocklist for the known abuse cluster observed on + * mcp.copilotkit.ai. These patterns have ZERO overlap with CopilotKit, AG-UI, + * MCP, or React-agentic-framework documentation. Any match is, by definition, + * off-topic for this server's index. + * + * Background: production analytics (v1.15.1+ query_log) traced a high-volume + * off-topic empty-query cluster to Anthropic's `Claude-User` shared egress + * pool (`160.79.106.32/29`). That same pool also serves ~46k legit + * Claude-User sessions/7d, so an IP-level block would harm real traffic. A + * surgical pattern-based block catches the abuse with zero false-positives + * on legitimate documentation queries. + * + * Long-term defense lives in the scope classifier (see Notion follow-ups); + * this list is the immediate-stop-bleeding bridge. New patterns added here + * MUST come with both a positive test (matches the abuse string) AND a + * negative test (a real CopilotKit/AG-UI query that contains a near-miss + * phrase does NOT match), so the zero-FP guarantee is pinned by CI. + */ + +export type BlocklistMatch = { matched: boolean; reason?: string }; + +const PATTERNS: { name: string; regex: RegExp }[] = [ + // Movie box-office news (e.g. "Toy Story 5 box office opening weekend"). + // `\b` boundaries + `\s*` between the two words so "box office" and + // "boxoffice" both match while "box for the office layout" does not (the + // negative test for this is in the test file). + { name: "movie-box-office", regex: /\bbox\s*office\b/i }, + { name: "toy-story-5", regex: /\btoy\s*story\s*5\b/i }, + { name: "disclosure-day", regex: /\bdisclosure\s*day\b/i }, + { name: "obsession-2026", regex: /\bobsession\s*(?:movie\s*)?2026\b/i }, + { name: "scary-movie-2026", regex: /\bscary\s*movie\s*2026\b/i }, + // SCOTUS / CFTC + Kalshi / certiorari co-occurrence. Either ordering + // matches via the `|`-joined alternation. Each side requires both a + // court/agency term AND a docket term so legitimate documentation + // mentioning "certiorari" or "SCOTUS" in isolation (unlikely in CopilotKit + // docs, but defensive) doesn't false-positive. + { + name: "scotus-kalshi", + regex: + /\b(?:scotus|cftc)\b.*\b(?:kalshi|certiorari)\b|\b(?:kalshi|certiorari)\b.*\b(?:scotus|cftc)\b/i, + }, + { + name: "sports-event-contracts", + regex: /\bsports?\s*event\s*contracts?\b/i, + }, +]; + +/** + * Check a query against the abuse blocklist. Returns `{ matched: true, + * reason: "pattern:" }` on the first matching pattern, or + * `{ matched: false }` otherwise. The `pattern:` prefix on the reason + * is intentional so future non-pattern reasons (classifier verdict, + * reputation system) carry a different prefix and are greppable. + */ +export function checkBlocklist(query: string): BlocklistMatch { + for (const { name, regex } of PATTERNS) { + if (regex.test(query)) return { matched: true, reason: `pattern:${name}` }; + } + return { matched: false }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b556108..363cdf0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -29,6 +29,12 @@ export function createMcpServer( // originated the traffic. Optional so existing callers/tests keep compiling; // when absent the writer defaults the column to 'user'. getRequestSource?: () => string | undefined, + // Per-session client IP / User-Agent accessors captured at MCP init. + // Threaded the same way as getRequestSource so each query_log row carries + // IP + UA without a session-id join against an external system. Both + // optional; absent values persist as NULL in the new columns. + getClientIp?: () => string | undefined, + getUserAgent?: () => string | undefined, ): McpServer { const cfg = getConfig(); const serverCfg = getServerConfig(); @@ -63,6 +69,8 @@ export function createMcpServer( onToolCall: hooks?.onToolCall, getSessionId, getRequestSource, + getClientIp, + getUserAgent, }); break; case "bash": { @@ -102,6 +110,8 @@ export function createMcpServer( onToolCall: hooks?.onToolCall, getSessionId, getRequestSource, + getClientIp, + getUserAgent, }); break; default: { diff --git a/src/mcp/tools/knowledge.ts b/src/mcp/tools/knowledge.ts index f049483..6cec191 100644 --- a/src/mcp/tools/knowledge.ts +++ b/src/mcp/tools/knowledge.ts @@ -13,6 +13,8 @@ import { } from "../../db/queries.js"; import { logQuery } from "../../db/analytics.js"; import { getAnalyticsConfig } from "../../config.js"; +import { checkBlocklist } from "../abuse-blocklist.js"; +import { oauthLog } from "../../oauth/observability.js"; /** * Format FAQ results in the standard QUESTION/ANSWER/SOURCE/CONFIDENCE format. @@ -71,6 +73,9 @@ export function registerKnowledgeTool( // persists the X-Pathfinder-Source origin tag on each query_log row. getSessionId?: () => string | undefined; getRequestSource?: () => string | undefined; + // Per-session client IP / User-Agent — see registerSearchTool. + getClientIp?: () => string | undefined; + getUserAgent?: () => string | undefined; }, ): void { const inputSchema = { @@ -106,6 +111,58 @@ export function registerKnowledgeTool( const effectiveConfidence = min_confidence ?? toolConfig.min_confidence; const startMs = Date.now(); + // Abuse blocklist short-circuit (search mode only — browse mode has no + // user-supplied query). Mirrors registerSearchTool: telemetry first + // (blocked=true row), then structured response with a domain hint. See + // src/mcp/abuse-blocklist.ts for the pattern set + rationale. + if (query && query.trim() !== "") { + const blocked = checkBlocklist(query); + if (blocked.matched) { + const logQueries = getAnalyticsConfig()?.log_queries ?? true; + const sessionClientIp = options?.getClientIp?.(); + logQuery( + { + tool_name: toolConfig.name, + query_text: query, + result_count: 0, + top_score: null, + latency_ms: Date.now() - startMs, + source_name: toolConfig.sources.join(","), + session_id: options?.getSessionId?.() ?? null, + request_source: options?.getRequestSource?.() ?? null, + client_ip: sessionClientIp ?? null, + user_agent: options?.getUserAgent?.() ?? null, + blocked: true, + block_reason: blocked.reason ?? null, + }, + logQueries, + ).catch((err) => { + console.error( + `[analytics] Failed to log blocked query: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + oauthLog.searchBlocked({ + ip: sessionClientIp ?? "", + reason: blocked.reason ?? "unknown", + tool: toolConfig.name, + }); + const payload = { + results: [], + blocked: true, + domain: "CopilotKit + AG-UI documentation", + hint: "This query is off-topic for this MCP server's index (CopilotKit, AG-UI, agentic-frameworks documentation only). For general questions outside this domain, use a web search instead of this tool.", + }; + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(payload), + }, + ], + }; + } + } + try { if (!query || query.trim() === "") { // Browse mode: return the most-recent N FAQ entries above confidence @@ -130,6 +187,10 @@ export function registerKnowledgeTool( source_name: toolConfig.sources.join(","), session_id: options?.getSessionId?.() ?? null, request_source: options?.getRequestSource?.() ?? null, + client_ip: options?.getClientIp?.() ?? null, + user_agent: options?.getUserAgent?.() ?? null, + blocked: false, + block_reason: null, }, analyticsConfig?.log_queries ?? true, ).catch((err) => { @@ -215,6 +276,10 @@ export function registerKnowledgeTool( source_name: toolConfig.sources.join(","), session_id: options?.getSessionId?.() ?? null, request_source: options?.getRequestSource?.() ?? null, + client_ip: options?.getClientIp?.() ?? null, + user_agent: options?.getUserAgent?.() ?? null, + blocked: false, + block_reason: null, }, analyticsConfig?.log_queries ?? true, ).catch((err) => { diff --git a/src/mcp/tools/search.ts b/src/mcp/tools/search.ts index 2e2a4bc..feffcde 100644 --- a/src/mcp/tools/search.ts +++ b/src/mcp/tools/search.ts @@ -9,6 +9,8 @@ import { } from "../../db/queries.js"; import { logQuery } from "../../db/analytics.js"; import { getAnalyticsConfig } from "../../config.js"; +import { checkBlocklist } from "../abuse-blocklist.js"; +import { oauthLog } from "../../oauth/observability.js"; function formatDocsResults(results: ChunkResult[]): string { if (results.length === 0) return "No results found."; @@ -78,6 +80,12 @@ export function registerSearchTool( // keep working — the analytics writer defaults a missing source to 'user'. getSessionId?: () => string | undefined; getRequestSource?: () => string | undefined; + // Per-session client IP / User-Agent captured at MCP init. Same pattern + // as getRequestSource — closed over for the lifetime of the session so + // every tool call records the attribution from the init request. Both + // optional; absent values land in query_log as NULL. + getClientIp?: () => string | undefined; + getUserAgent?: () => string | undefined; }, ): void { const inputSchema = { @@ -114,6 +122,65 @@ export function registerSearchTool( const effectiveLimit = limit ?? toolConfig.default_limit; const searchMode = toolConfig.search_mode ?? "vector"; const startMs = Date.now(); + + // Abuse blocklist short-circuit. Runs BEFORE the embedding call so a + // blocked query never costs an embedding round-trip. The blocked row is + // still logged (with blocked=true + block_reason) so abuse volume is + // visible on the analytics surface; the structured response teaches the + // calling LLM what's actually in scope. See src/mcp/abuse-blocklist.ts. + const blocked = checkBlocklist(query); + if (blocked.matched) { + const logQueries = getAnalyticsConfig()?.log_queries ?? true; + const sessionClientIp = options?.getClientIp?.(); + logQuery( + { + tool_name: toolConfig.name, + query_text: query, + result_count: 0, + top_score: null, + latency_ms: Date.now() - startMs, + source_name: toolConfig.source, + session_id: options?.getSessionId?.() ?? null, + request_source: options?.getRequestSource?.() ?? null, + client_ip: sessionClientIp ?? null, + user_agent: options?.getUserAgent?.() ?? null, + blocked: true, + block_reason: blocked.reason ?? null, + }, + logQueries, + ).catch((err) => { + console.error( + `[analytics] Failed to log blocked query: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + // Observability hook. `ip` defaults to empty string when unavailable + // so the log line shape stays stable; `reason` is the pattern tag. + oauthLog.searchBlocked({ + ip: sessionClientIp ?? "", + reason: blocked.reason ?? "unknown", + tool: toolConfig.name, + }); + // Structured tool response. MCP tools return text content, so the + // JSON-shaped payload is serialized and emitted as a `text` chunk — + // the calling LLM still sees the structured fields (`blocked`, + // `domain`, `hint`) and can act on them. Keeping a TEXT shape avoids + // depending on MCP content-type extensions that vary across clients. + const payload = { + results: [], + blocked: true, + domain: "CopilotKit + AG-UI documentation", + hint: "This query is off-topic for this MCP server's index (CopilotKit, AG-UI, agentic-frameworks documentation only). For general questions outside this domain, use a web search instead of this tool.", + }; + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(payload), + }, + ], + }; + } + try { let results: ChunkResult[]; const minScore = min_score ?? toolConfig.min_score; @@ -177,6 +244,10 @@ export function registerSearchTool( source_name: toolConfig.source, session_id: options?.getSessionId?.() ?? null, request_source: options?.getRequestSource?.() ?? null, + client_ip: options?.getClientIp?.() ?? null, + user_agent: options?.getUserAgent?.() ?? null, + blocked: false, + block_reason: null, }, logQueries, ).catch((err) => { diff --git a/src/oauth/observability.ts b/src/oauth/observability.ts index c79e7d3..ded2fa8 100644 --- a/src/oauth/observability.ts +++ b/src/oauth/observability.ts @@ -87,4 +87,13 @@ export const oauthLog = { ): void { console.warn(`[oauth] consent_redirect_uri_unparseable ${format(fields)}`); }, + // Abuse blocklist hit on a search/knowledge tool call. Logged with the + // matching pattern reason and the resolved client IP so the operator can + // grep `[oauth] search_blocked` for abuse volume independent of the + // per-row `query_log.blocked` flag. Kept on `oauthLog` for vocabulary + // consistency with the rest of the `[oauth] …` log surface; the block + // itself is not OAuth-scoped but the IP attribution and grep surface are. + searchBlocked(fields: Fields & { ip: string; reason: string }): void { + console.warn(`[oauth] search_blocked ${format(fields)}`); + }, } as const; diff --git a/src/server.ts b/src/server.ts index 3601801..75848e9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -74,6 +74,7 @@ import { consentHandler } from "./oauth/consent-handler.js"; import { setTrustingProxy, assertOauthIpResolverInjected, + oauthClientIp, } from "./oauth/trusted-client-ip.js"; import { WorkspaceManager } from "./workspace.js"; import { generateLlmsTxt, generateLlmsFullTxt } from "./llms-txt.js"; @@ -1850,6 +1851,17 @@ app.post("/mcp", bearerMiddleware, async (req: Request, res: Response) => { // subsequent tool call in the session with the origin the client // declared on initialize. const requestSource = requestSourceFromHeaders(req); + // Same idea for the per-request client IP and User-Agent — captured + // once at init via the same trust-proxy boundary the oauth surface + // uses (oauthClientIp), then closed over for the lifetime of the + // session so every tool call records identical attribution. UA is + // truncated by the analytics writer (USER_AGENT_MAX_LEN), so we + // hand over the raw header here. + const sessionClientIp = oauthClientIp(req); + const rawUserAgent = req.headers["user-agent"]; + const sessionUserAgent = Array.isArray(rawUserAgent) + ? rawUserAgent[0] + : rawUserAgent; const server = createMcpServer( bashInstances, sessionStateManager, @@ -1863,6 +1875,8 @@ app.post("/mcp", bearerMiddleware, async (req: Request, res: Response) => { }, }, () => requestSource, + () => sessionClientIp, + () => sessionUserAgent, ); // Z-1: server.connect(transport) can throw AFTER handleSessionInitAccept // committed maps + ipLimiter counter + ensureSession + onclose wiring. @@ -2027,6 +2041,15 @@ const sseHandlers = createSseHandlers({ const requestSource = req ? requestSourceFromHeaders(req) : normalizeRequestSource(undefined); + // Same per-session capture for client IP + User-Agent. When `req` is + // absent (older SSE bootstrapping paths) both fall back to undefined and + // the analytics writer persists NULL for the row — preserving the + // pre-v1.15.2 shape on that code path instead of guessing. + const sessionClientIp = req ? oauthClientIp(req) : undefined; + const rawUserAgent = req?.headers["user-agent"]; + const sessionUserAgent = Array.isArray(rawUserAgent) + ? rawUserAgent[0] + : rawUserAgent; // The handler creates the transport first, then calls createMcpServer() // and connect(transport). We need the sessionId late-bound so bash tools // can discover it via getSessionId(). @@ -2043,6 +2066,8 @@ const sseHandlers = createSseHandlers({ }, }, () => requestSource, + () => sessionClientIp, + () => sessionUserAgent, ); // Intercept connect() so we can capture the transport reference for // the getSessionId closure above.