{"slug": "dont-let-an-mcp-crawl-your-community-chat-build-a-consent-gated-knowledge", "title": "Don’t Let an MCP Crawl Your Community Chat: Build a Consent-Gated Knowledge Pipeline", "summary": "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.", "body_md": "A community member may be happy to answer a question in chat without volunteering that answer as permanent context for an AI assistant.\n\nThat 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:\n\nThe answer should not be “the model decides.” Models can help draft summaries or find candidates, but consent, scope, and publication are application decisions.\n\nThis 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.\n\nTencent 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.\n\nWe will keep four systems separate:\n\n```\nTencent RTC community chat\n        │\n        │ selected message snapshot\n        ▼\nKnowledge promotion workflow\n  ├─ author consent\n  ├─ moderator review\n  ├─ revision validation\n  └─ access policy\n        │\n        │ approved records only\n        ▼\nMCP search tool\n        │\n        ▼\nAI assistant or other MCP client\n```\n\nThe MCP layer cannot read arbitrary chat history. It can only read deliberately published records.\n\nThat is the main architectural decision. Everything else supports it.\n\nA message moves through this lifecycle:\n\n```\nawaiting_consent\n        │ author approves the exact revision\n        ▼\nawaiting_review\n        │ moderator approves an excerpt\n        ▼\npublished\n   │         │\n   │ edit    │ deletion or consent withdrawal\n   ▼         ▼\nsuspended   revoked\n```\n\nA source edit does not silently update the knowledge record. It suspends the published version until the new revision goes through consent and review again.\n\nThis 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.\n\nStart with a small TypeScript project:\n\n```\nmkdir community-knowledge-gate\ncd community-knowledge-gate\nnpm init -y\nnpm install --save-dev typescript tsx @types/node\nmkdir src test\n```\n\nAdd these scripts to `package.json`:\n\n```\n{\n  \"scripts\": {\n    \"test\": \"tsx --test test/*.test.ts\",\n    \"check\": \"tsc --noEmit\"\n  }\n}\n```\n\nCreate `tsconfig.json`:\n\n```\n{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"skipLibCheck\": true\n  },\n  \"include\": [\"src\", \"test\"]\n}\n```\n\nCreate `src/knowledge.ts`:\n\n```\nexport type PromotionStatus =\n  | \"awaiting_consent\"\n  | \"awaiting_review\"\n  | \"published\"\n  | \"suspended\"\n  | \"revoked\"\n  | \"rejected\";\n\nexport interface MessageSnapshot {\n  communityId: string;\n  messageId: string;\n  authorId: string;\n  revision: string;\n  digest: string;\n  capturedAt: string;\n}\n\nexport interface KnowledgeRecord {\n  id: string;\n  status: PromotionStatus;\n  source: MessageSnapshot;\n  nominatedBy: string;\n\n  consentedBy?: string;\n  consentedAt?: string;\n\n  approvedBy?: string;\n  approvedAt?: string;\n  approvedExcerpt?: string;\n  keywords?: string[];\n\n  statusReason?: string;\n}\n\nexport type PromotionEvent =\n  | {\n      type: \"AUTHOR_CONSENTED\";\n      authorId: string;\n      revision: string;\n      digest: string;\n      at: string;\n    }\n  | {\n      type: \"MODERATOR_APPROVED\";\n      moderatorId: string;\n      revision: string;\n      digest: string;\n      excerpt: string;\n      keywords: string[];\n      at: string;\n    }\n  | {\n      type: \"MODERATOR_REJECTED\";\n      moderatorId: string;\n      reason: string;\n    }\n  | {\n      type: \"SOURCE_CHANGED\";\n      current: MessageSnapshot;\n    }\n  | {\n      type: \"SOURCE_DELETED\";\n      at: string;\n    }\n  | {\n      type: \"CONSENT_WITHDRAWN\";\n      authorId: string;\n      at: string;\n    };\n\nexport type TransitionResult =\n  | { ok: true; record: KnowledgeRecord }\n  | { ok: false; code: string; record: KnowledgeRecord };\n\nexport function nominate(\n  id: string,\n  source: MessageSnapshot,\n  nominatedBy: string,\n): KnowledgeRecord {\n  return {\n    id,\n    source,\n    nominatedBy,\n    status: \"awaiting_consent\",\n  };\n}\n\nfunction sameSourceVersion(\n  record: KnowledgeRecord,\n  revision: string,\n  digest: string,\n): boolean {\n  return (\n    record.source.revision === revision &&\n    record.source.digest === digest\n  );\n}\n\nexport function transition(\n  record: KnowledgeRecord,\n  event: PromotionEvent,\n): TransitionResult {\n  switch (event.type) {\n    case \"AUTHOR_CONSENTED\": {\n      if (record.status !== \"awaiting_consent\") {\n        return { ok: false, code: \"NOT_AWAITING_CONSENT\", record };\n      }\n\n      if (event.authorId !== record.source.authorId) {\n        return { ok: false, code: \"WRONG_AUTHOR\", record };\n      }\n\n      if (!sameSourceVersion(record, event.revision, event.digest)) {\n        return { ok: false, code: \"STALE_CONSENT\", record };\n      }\n\n      return {\n        ok: true,\n        record: {\n          ...record,\n          status: \"awaiting_review\",\n          consentedBy: event.authorId,\n          consentedAt: event.at,\n        },\n      };\n    }\n\n    case \"MODERATOR_APPROVED\": {\n      if (record.status !== \"awaiting_review\") {\n        return { ok: false, code: \"NOT_AWAITING_REVIEW\", record };\n      }\n\n      if (!sameSourceVersion(record, event.revision, event.digest)) {\n        return { ok: false, code: \"STALE_APPROVAL\", record };\n      }\n\n      const excerpt = event.excerpt.trim();\n      if (excerpt.length === 0) {\n        return { ok: false, code: \"EMPTY_EXCERPT\", record };\n      }\n\n      return {\n        ok: true,\n        record: {\n          ...record,\n          status: \"published\",\n          approvedBy: event.moderatorId,\n          approvedAt: event.at,\n          approvedExcerpt: excerpt,\n          keywords: event.keywords.map((word) => word.toLowerCase()),\n        },\n      };\n    }\n\n    case \"MODERATOR_REJECTED\": {\n      if (record.status !== \"awaiting_review\") {\n        return { ok: false, code: \"NOT_AWAITING_REVIEW\", record };\n      }\n\n      return {\n        ok: true,\n        record: {\n          ...record,\n          status: \"rejected\",\n          statusReason: event.reason,\n        },\n      };\n    }\n\n    case \"SOURCE_CHANGED\": {\n      if (\n        event.current.messageId !== record.source.messageId ||\n        event.current.communityId !== record.source.communityId\n      ) {\n        return { ok: false, code: \"WRONG_SOURCE\", record };\n      }\n\n      if (\n        event.current.revision === record.source.revision &&\n        event.current.digest === record.source.digest\n      ) {\n        return { ok: true, record };\n      }\n\n      return {\n        ok: true,\n        record: {\n          ...record,\n          status: \"suspended\",\n          statusReason: \"Source message changed after nomination\",\n        },\n      };\n    }\n\n    case \"SOURCE_DELETED\":\n      return {\n        ok: true,\n        record: {\n          ...record,\n          status: \"revoked\",\n          statusReason: `Source message deleted at ${event.at}`,\n        },\n      };\n\n    case \"CONSENT_WITHDRAWN\": {\n      if (event.authorId !== record.source.authorId) {\n        return { ok: false, code: \"WRONG_AUTHOR\", record };\n      }\n\n      return {\n        ok: true,\n        record: {\n          ...record,\n          status: \"revoked\",\n          statusReason: `Author withdrew consent at ${event.at}`,\n        },\n      };\n    }\n  }\n}\n```\n\nThe `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.\n\nThe revision plus digest protects against two different problems:\n\nThe MCP-facing search function should receive authorization from trusted server context—not from tool arguments generated by a model.\n\nAdd this to `src/knowledge.ts`:\n\n```\nexport interface AuthContext {\n  actorId: string;\n  communityIds: ReadonlySet<string>;\n}\n\nexport interface SearchResult {\n  knowledgeId: string;\n  excerpt: string;\n  communityId: string;\n  sourceMessageId: string;\n  sourceRevision: string;\n  approvedAt: string;\n}\n\nexport function searchPublishedKnowledge(\n  query: string,\n  auth: AuthContext,\n  records: readonly KnowledgeRecord[],\n): SearchResult[] {\n  const terms = query\n    .toLowerCase()\n    .split(/\\s+/)\n    .map((term) => term.trim())\n    .filter((term) => term.length > 1);\n\n  if (terms.length === 0) return [];\n\n  return records.flatMap((record) => {\n    if (record.status !== \"published\") return [];\n    if (!auth.communityIds.has(record.source.communityId)) return [];\n    if (!record.approvedExcerpt || !record.approvedAt) return [];\n\n    const searchable = [\n      record.approvedExcerpt,\n      ...(record.keywords ?? []),\n    ]\n      .join(\" \")\n      .toLowerCase();\n\n    if (!terms.some((term) => searchable.includes(term))) return [];\n\n    return [\n      {\n        knowledgeId: record.id,\n        excerpt: record.approvedExcerpt,\n        communityId: record.source.communityId,\n        sourceMessageId: record.source.messageId,\n        sourceRevision: record.source.revision,\n        approvedAt: record.approvedAt,\n      },\n    ];\n  });\n}\n```\n\nYour MCP server can expose this function as a tool such as `search_community_knowledge` with one model-controlled argument:\n\n```\n{\n  \"query\": \"How do I submit a documentation correction?\"\n}\n```\n\nDo **not** add `actorId`, `communityId`, `includePrivate`, or `ignoreRevocation` as model-controlled arguments. Resolve the authenticated user and their community memberships in the MCP transport or gateway, then create `AuthContext` on the server.\n\nA transport-neutral handler might look like this:\n\n```\ninterface SearchToolInput {\n  query: string;\n}\n\ninterface RequestIdentity {\n  actorId: string;\n  authorizedCommunityIds: string[];\n}\n\nexport function handleKnowledgeSearch(\n  input: SearchToolInput,\n  identity: RequestIdentity,\n  records: readonly KnowledgeRecord[],\n) {\n  const auth: AuthContext = {\n    actorId: identity.actorId,\n    communityIds: new Set(identity.authorizedCommunityIds),\n  };\n\n  return searchPublishedKnowledge(input.query, auth, records);\n}\n```\n\nConnect this handler to the tool-registration surface of your chosen MCP SDK. Keeping the authorization and promotion core independent of the transport makes it testable without launching an MCP client or granting access to real community data.\n\nCreate `test/knowledge.test.ts`:\n\n``` python\nimport assert from \"node:assert/strict\";\nimport test from \"node:test\";\nimport {\n  nominate,\n  searchPublishedKnowledge,\n  transition,\n  type MessageSnapshot,\n} from \"../src/knowledge.js\";\n\nconst source: MessageSnapshot = {\n  communityId: \"community-docs\",\n  messageId: \"message-42\",\n  authorId: \"member-alex\",\n  revision: \"r1\",\n  digest: \"sha256-original\",\n  capturedAt: \"2026-09-07T10:00:00Z\",\n};\n\nfunction publishFixture() {\n  let record = nominate(\"knowledge-1\", source, \"member-sam\");\n\n  const consent = transition(record, {\n    type: \"AUTHOR_CONSENTED\",\n    authorId: \"member-alex\",\n    revision: \"r1\",\n    digest: \"sha256-original\",\n    at: \"2026-09-07T10:05:00Z\",\n  });\n  assert.equal(consent.ok, true);\n  record = consent.record;\n\n  const approval = transition(record, {\n    type: \"MODERATOR_APPROVED\",\n    moderatorId: \"moderator-lee\",\n    revision: \"r1\",\n    digest: \"sha256-original\",\n    excerpt: \"Documentation corrections should include the affected page URL.\",\n    keywords: [\"docs\", \"correction\", \"URL\"],\n    at: \"2026-09-07T10:10:00Z\",\n  });\n  assert.equal(approval.ok, true);\n\n  return approval.record;\n}\n\ntest(\"publishes a consented and reviewed revision\", () => {\n  const record = publishFixture();\n\n  const results = searchPublishedKnowledge(\n    \"documentation correction\",\n    {\n      actorId: \"member-kai\",\n      communityIds: new Set([\"community-docs\"]),\n    },\n    [record],\n  );\n\n  assert.equal(results.length, 1);\n  assert.equal(results[0]?.sourceRevision, \"r1\");\n});\n\ntest(\"rejects consent for an obsolete source revision\", () => {\n  const record = nominate(\"knowledge-1\", source, \"member-sam\");\n\n  const result = transition(record, {\n    type: \"AUTHOR_CONSENTED\",\n    authorId: \"member-alex\",\n    revision: \"r0\",\n    digest: \"sha256-older\",\n    at: \"2026-09-07T10:05:00Z\",\n  });\n\n  assert.equal(result.ok, false);\n  assert.equal(result.code, \"STALE_CONSENT\");\n  assert.equal(result.record.status, \"awaiting_consent\");\n});\n\ntest(\"suspends a published record after a source edit\", () => {\n  const published = publishFixture();\n\n  const changed = transition(published, {\n    type: \"SOURCE_CHANGED\",\n    current: {\n      ...source,\n      revision: \"r2\",\n      digest: \"sha256-corrected\",\n      capturedAt: \"2026-09-07T11:00:00Z\",\n    },\n  });\n\n  assert.equal(changed.ok, true);\n  assert.equal(changed.record.status, \"suspended\");\n\n  const results = searchPublishedKnowledge(\n    \"documentation\",\n    {\n      actorId: \"member-kai\",\n      communityIds: new Set([\"community-docs\"]),\n    },\n    [changed.record],\n  );\n\n  assert.deepEqual(results, []);\n});\n\ntest(\"does not trust a requested community outside server authorization\", () => {\n  const published = publishFixture();\n\n  const results = searchPublishedKnowledge(\n    \"documentation\",\n    {\n      actorId: \"external-user\",\n      communityIds: new Set([\"another-community\"]),\n    },\n    [published],\n  );\n\n  assert.deepEqual(results, []);\n});\n\ntest(\"removes withdrawn knowledge from search\", () => {\n  const published = publishFixture();\n\n  const withdrawn = transition(published, {\n    type: \"CONSENT_WITHDRAWN\",\n    authorId: \"member-alex\",\n    at: \"2026-09-07T12:00:00Z\",\n  });\n\n  assert.equal(withdrawn.record.status, \"revoked\");\n\n  const results = searchPublishedKnowledge(\n    \"documentation\",\n    {\n      actorId: \"member-kai\",\n      communityIds: new Set([\"community-docs\"]),\n    },\n    [withdrawn.record],\n  );\n\n  assert.deepEqual(results, []);\n});\n```\n\nRun the checks:\n\n```\nnpm run check\nnpm test\n```\n\nThese tests establish four important properties:\n\nKeep the product integration behind an application-owned port. This avoids pretending that one invented API name applies to every supported client and server stack.\n\n```\nexport interface CommunityMessagePort {\n  loadCurrentSnapshot(input: {\n    communityId: string;\n    messageId: string;\n  }): Promise<MessageSnapshot | null>;\n\n  showConsentRequest(input: {\n    communityId: string;\n    messageId: string;\n    authorId: string;\n    promotionId: string;\n  }): Promise<void>;\n\n  showModerationTask(input: {\n    promotionId: string;\n    communityId: string;\n    messageId: string;\n  }): Promise<void>;\n}\n```\n\nMap the actual message identifiers, authenticated users, update notifications, and deletion notifications from your Tencent RTC integration into this port using the official documentation for your chosen stack.\n\nA production nomination workflow should perform these steps:\n\nDo not copy an entire thread into the knowledge record “for context.” Context collection should be narrow and visible. Other participants in the thread did not automatically consent because one message was nominated.\n\nAn AI assistant can reasonably help with:\n\nThose are demonstrated categories of language-model capability: classification, summarization, and extraction. They are not proof that a message is correct, reusable, or consensual.\n\nThe model should therefore produce a proposal, never a publication command:\n\n```\ninterface DraftSuggestion {\n  excerpt: string;\n  keywords: string[];\n  contextQuestions: string[];\n}\n```\n\nStore the suggestion separately from the moderator-approved fields. The moderator should see the original source, the draft, and any warnings side by side.\n\nIf the AI service is unavailable, the workflow should fall back to manual excerpt entry. Consent and moderation must not depend on the model being online.\n\nThis separation also helps with a common professional anxiety: using AI does not have to mean surrendering the valuable part of the work. The durable engineering skill here is deciding authority—what a model may suggest, what a person must decide, and what software must enforce.\n\nMultilingual communities may want readers to translate an approved message. TUIChat provides on-demand text-message translation, with supported content types, languages, and edition constraints documented in the official [TUIChat message translation guide](https://trtc.io/document/60772).\n\nTreat translation as a reader-selected view of the approved source, not as an independently verified fact:\n\n```\napproved original\n   ├─ translated view for reader A\n   └─ translated view for reader B\n```\n\nPreserve the original revision and digest in the MCP result. If translated text is passed to an assistant, label its target language and provenance. Do not overwrite the moderator-approved original with a generated translation.\n\nReload the message before applying consent. If the revision or digest differs, reject the action as stale and show the author the new version.\n\nDo not quietly apply consent to the edit.\n\nMessaging events alone should not be your only defense. Reconcile published records periodically by loading their current source metadata. Suspend records whose source cannot be verified.\n\nChoose the reconciliation interval according to the sensitivity and expected edit rate of your community; there is no universal safe interval.\n\nDistinguish “confirmed deleted” from “temporarily unavailable.”\n\nFailing open is convenient, but it means unverifiable content remains available to assistants.\n\nShow the source and draft together. Require an explicit human confirmation, and record the exact approved excerpt—not merely an “approved” boolean attached to mutable text.\n\nThe requested community must not come from the prompt or tool arguments. The server derives accessible community IDs from authenticated membership and filters every result.\n\nNew searches must exclude the record immediately. You cannot reliably retract text already delivered to a model, so minimize caching and avoid placing sensitive community material into long-lived model memory.\n\nIf the product displays citations, mark the source as unavailable when the final answer is rendered. For sensitive use cases, revalidate selected records before returning the assistant's answer.\n\nBefore connecting the workflow to a real community, verify all of the following:\n\n`published`.\nA direct chat crawler produces a larger index with less work. It also turns informal conversation into undeclared infrastructure and asks a language model to compensate for missing governance.\n\nA promotion pipeline creates less knowledge, more slowly. In return, each published record has an author, a reviewed excerpt, a source revision, an audience, and a revocation path.\n\nFor a community assistant, that smaller corpus is often the more useful one. The goal is not to make every message available to AI. It is to let people deliberately turn selected conversations into knowledge they are comfortable sharing.\n\n**Disclosure:** I have a content relationship with Tencent RTC. I used the official Tencent RTC Social Messaging solution page and TUIChat message translation documentation as implementation references for this article.", "url": "https://wpnews.pro/news/dont-let-an-mcp-crawl-your-community-chat-build-a-consent-gated-knowledge", "canonical_source": "https://dev.to/susiewang/dont-let-an-mcp-crawl-your-community-chat-build-a-consent-gated-knowledge-pipeline-3d9b", "published_at": "2026-09-07 17:11:55+00:00", "updated_at": "2026-09-07 17:32:38.521782+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-products"], "entities": ["Tencent RTC"], "alternates": {"html": "https://wpnews.pro/news/dont-let-an-mcp-crawl-your-community-chat-build-a-consent-gated-knowledge", "markdown": "https://wpnews.pro/news/dont-let-an-mcp-crawl-your-community-chat-build-a-consent-gated-knowledge.md", "text": "https://wpnews.pro/news/dont-let-an-mcp-crawl-your-community-chat-build-a-consent-gated-knowledge.txt", "jsonld": "https://wpnews.pro/news/dont-let-an-mcp-crawl-your-community-chat-build-a-consent-gated-knowledge.jsonld"}}