Don’t Let an MCP Crawl Your Community Chat: Build a Consent-Gated Knowledge Pipeline A developer built a consent-gated knowledge pipeline for Tencent RTC's social-messaging community chat, ensuring messages become visible to MCP-based AI assistants only after explicit author consent and moderator review. The architecture separates community chat from the knowledge store, with a lifecycle that suspends published records on edits until re-approved, preventing unauthorized use of chat history as AI context. A community member may be happy to answer a question in chat without volunteering that answer as permanent context for an AI assistant. That distinction gets lost when an MCP server is built as a thin search layer over message history. The demo looks useful: ask a question, retrieve a message, give it to a model. The production problem is less glamorous: The answer should not be “the model decides.” Models can help draft summaries or find candidates, but consent, scope, and publication are application decisions. This tutorial builds a safer alternative: a knowledge promotion pipeline for a Tencent RTC social-messaging community. Messages become MCP-visible only after explicit author consent and human review. Edits, deletion, revocation, and stale callbacks remain visible states rather than edge cases hidden in logs. Tencent RTC's Social Messaging solution https://trtc.io/solutions/social-messaging covers scenarios including group discussion, large communities, rich media, and interest-based social experiences. That makes community chat the interaction surface—but not automatically the canonical knowledge store. We will keep four systems separate: Tencent RTC community chat │ │ selected message snapshot ▼ Knowledge promotion workflow ├─ author consent ├─ moderator review ├─ revision validation └─ access policy │ │ approved records only ▼ MCP search tool │ ▼ AI assistant or other MCP client The MCP layer cannot read arbitrary chat history. It can only read deliberately published records. That is the main architectural decision. Everything else supports it. A message moves through this lifecycle: awaiting consent │ author approves the exact revision ▼ awaiting review │ moderator approves an excerpt ▼ published │ │ │ edit │ deletion or consent withdrawal ▼ ▼ suspended revoked A source edit does not silently update the knowledge record. It suspends the published version until the new revision goes through consent and review again. This is intentionally conservative. A corrected spelling and a reversed recommendation are both “edits” at the messaging boundary. Application code should not guess whether the semantic meaning changed. Start with a small TypeScript project: mkdir community-knowledge-gate cd community-knowledge-gate npm init -y npm install --save-dev typescript tsx @types/node mkdir src test Add these scripts to package.json : { "scripts": { "test": "tsx --test test/ .test.ts", "check": "tsc --noEmit" } } Create tsconfig.json : { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noUncheckedIndexedAccess": true, "skipLibCheck": true }, "include": "src", "test" } Create src/knowledge.ts : export type PromotionStatus = | "awaiting consent" | "awaiting review" | "published" | "suspended" | "revoked" | "rejected"; export interface MessageSnapshot { communityId: string; messageId: string; authorId: string; revision: string; digest: string; capturedAt: string; } export interface KnowledgeRecord { id: string; status: PromotionStatus; source: MessageSnapshot; nominatedBy: string; consentedBy?: string; consentedAt?: string; approvedBy?: string; approvedAt?: string; approvedExcerpt?: string; keywords?: string ; statusReason?: string; } export type PromotionEvent = | { type: "AUTHOR CONSENTED"; authorId: string; revision: string; digest: string; at: string; } | { type: "MODERATOR APPROVED"; moderatorId: string; revision: string; digest: string; excerpt: string; keywords: string ; at: string; } | { type: "MODERATOR REJECTED"; moderatorId: string; reason: string; } | { type: "SOURCE CHANGED"; current: MessageSnapshot; } | { type: "SOURCE DELETED"; at: string; } | { type: "CONSENT WITHDRAWN"; authorId: string; at: string; }; export type TransitionResult = | { ok: true; record: KnowledgeRecord } | { ok: false; code: string; record: KnowledgeRecord }; export function nominate id: string, source: MessageSnapshot, nominatedBy: string, : KnowledgeRecord { return { id, source, nominatedBy, status: "awaiting consent", }; } function sameSourceVersion record: KnowledgeRecord, revision: string, digest: string, : boolean { return record.source.revision === revision && record.source.digest === digest ; } export function transition record: KnowledgeRecord, event: PromotionEvent, : TransitionResult { switch event.type { case "AUTHOR CONSENTED": { if record.status == "awaiting consent" { return { ok: false, code: "NOT AWAITING CONSENT", record }; } if event.authorId == record.source.authorId { return { ok: false, code: "WRONG AUTHOR", record }; } if sameSourceVersion record, event.revision, event.digest { return { ok: false, code: "STALE CONSENT", record }; } return { ok: true, record: { ...record, status: "awaiting review", consentedBy: event.authorId, consentedAt: event.at, }, }; } case "MODERATOR APPROVED": { if record.status == "awaiting review" { return { ok: false, code: "NOT AWAITING REVIEW", record }; } if sameSourceVersion record, event.revision, event.digest { return { ok: false, code: "STALE APPROVAL", record }; } const excerpt = event.excerpt.trim ; if excerpt.length === 0 { return { ok: false, code: "EMPTY EXCERPT", record }; } return { ok: true, record: { ...record, status: "published", approvedBy: event.moderatorId, approvedAt: event.at, approvedExcerpt: excerpt, keywords: event.keywords.map word = word.toLowerCase , }, }; } case "MODERATOR REJECTED": { if record.status == "awaiting review" { return { ok: false, code: "NOT AWAITING REVIEW", record }; } return { ok: true, record: { ...record, status: "rejected", statusReason: event.reason, }, }; } case "SOURCE CHANGED": { if event.current.messageId == record.source.messageId || event.current.communityId == record.source.communityId { return { ok: false, code: "WRONG SOURCE", record }; } if event.current.revision === record.source.revision && event.current.digest === record.source.digest { return { ok: true, record }; } return { ok: true, record: { ...record, status: "suspended", statusReason: "Source message changed after nomination", }, }; } case "SOURCE DELETED": return { ok: true, record: { ...record, status: "revoked", statusReason: Source message deleted at ${event.at} , }, }; case "CONSENT WITHDRAWN": { if event.authorId == record.source.authorId { return { ok: false, code: "WRONG AUTHOR", record }; } return { ok: true, record: { ...record, status: "revoked", statusReason: Author withdrew consent at ${event.at} , }, }; } } } The digest should be computed from the canonical source content and relevant attachment references using a server-side hash such as SHA-256. Do not trust a digest supplied by the browser or by an AI tool. The revision plus digest protects against two different problems: The MCP-facing search function should receive authorization from trusted server context—not from tool arguments generated by a model. Add this to src/knowledge.ts : export interface AuthContext { actorId: string; communityIds: ReadonlySet