cd /news/developer-tools/dont-let-an-mcp-crawl-your-community… · home topics developer-tools article
[ARTICLE · art-122585] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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.

read11 min views1 publishedSep 7, 2026

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 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<string>;
}

export interface SearchResult {
  knowledgeId: string;
  excerpt: string;
  communityId: string;
  sourceMessageId: string;
  sourceRevision: string;
  approvedAt: string;
}

export function searchPublishedKnowledge(
  query: string,
  auth: AuthContext,
  records: readonly KnowledgeRecord[],
): SearchResult[] {
  const terms = query
    .toLowerCase()
    .split(/\s+/)
    .map((term) => term.trim())
    .filter((term) => term.length > 1);

  if (terms.length === 0) return [];

  return records.flatMap((record) => {
    if (record.status !== "published") return [];
    if (!auth.communityIds.has(record.source.communityId)) return [];
    if (!record.approvedExcerpt || !record.approvedAt) return [];

    const searchable = [
      record.approvedExcerpt,
      ...(record.keywords ?? []),
    ]
      .join(" ")
      .toLowerCase();

    if (!terms.some((term) => searchable.includes(term))) return [];

    return [
      {
        knowledgeId: record.id,
        excerpt: record.approvedExcerpt,
        communityId: record.source.communityId,
        sourceMessageId: record.source.messageId,
        sourceRevision: record.source.revision,
        approvedAt: record.approvedAt,
      },
    ];
  });
}

Your MCP server can expose this function as a tool such as search_community_knowledge with one model-controlled argument:

{
  "query": "How do I submit a documentation correction?"
}

Do 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.

A transport-neutral handler might look like this:

interface SearchToolInput {
  query: string;
}

interface RequestIdentity {
  actorId: string;
  authorizedCommunityIds: string[];
}

export function handleKnowledgeSearch(
  input: SearchToolInput,
  identity: RequestIdentity,
  records: readonly KnowledgeRecord[],
) {
  const auth: AuthContext = {
    actorId: identity.actorId,
    communityIds: new Set(identity.authorizedCommunityIds),
  };

  return searchPublishedKnowledge(input.query, auth, records);
}

Connect 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.

Create test/knowledge.test.ts:

import assert from "node:assert/strict";
import test from "node:test";
import {
  nominate,
  searchPublishedKnowledge,
  transition,
  type MessageSnapshot,
} from "../src/knowledge.js";

const source: MessageSnapshot = {
  communityId: "community-docs",
  messageId: "message-42",
  authorId: "member-alex",
  revision: "r1",
  digest: "sha256-original",
  capturedAt: "2026-09-07T10:00:00Z",
};

function publishFixture() {
  let record = nominate("knowledge-1", source, "member-sam");

  const consent = transition(record, {
    type: "AUTHOR_CONSENTED",
    authorId: "member-alex",
    revision: "r1",
    digest: "sha256-original",
    at: "2026-09-07T10:05:00Z",
  });
  assert.equal(consent.ok, true);
  record = consent.record;

  const approval = transition(record, {
    type: "MODERATOR_APPROVED",
    moderatorId: "moderator-lee",
    revision: "r1",
    digest: "sha256-original",
    excerpt: "Documentation corrections should include the affected page URL.",
    keywords: ["docs", "correction", "URL"],
    at: "2026-09-07T10:10:00Z",
  });
  assert.equal(approval.ok, true);

  return approval.record;
}

test("publishes a consented and reviewed revision", () => {
  const record = publishFixture();

  const results = searchPublishedKnowledge(
    "documentation correction",
    {
      actorId: "member-kai",
      communityIds: new Set(["community-docs"]),
    },
    [record],
  );

  assert.equal(results.length, 1);
  assert.equal(results[0]?.sourceRevision, "r1");
});

test("rejects consent for an obsolete source revision", () => {
  const record = nominate("knowledge-1", source, "member-sam");

  const result = transition(record, {
    type: "AUTHOR_CONSENTED",
    authorId: "member-alex",
    revision: "r0",
    digest: "sha256-older",
    at: "2026-09-07T10:05:00Z",
  });

  assert.equal(result.ok, false);
  assert.equal(result.code, "STALE_CONSENT");
  assert.equal(result.record.status, "awaiting_consent");
});

test("suspends a published record after a source edit", () => {
  const published = publishFixture();

  const changed = transition(published, {
    type: "SOURCE_CHANGED",
    current: {
      ...source,
      revision: "r2",
      digest: "sha256-corrected",
      capturedAt: "2026-09-07T11:00:00Z",
    },
  });

  assert.equal(changed.ok, true);
  assert.equal(changed.record.status, "suspended");

  const results = searchPublishedKnowledge(
    "documentation",
    {
      actorId: "member-kai",
      communityIds: new Set(["community-docs"]),
    },
    [changed.record],
  );

  assert.deepEqual(results, []);
});

test("does not trust a requested community outside server authorization", () => {
  const published = publishFixture();

  const results = searchPublishedKnowledge(
    "documentation",
    {
      actorId: "external-user",
      communityIds: new Set(["another-community"]),
    },
    [published],
  );

  assert.deepEqual(results, []);
});

test("removes withdrawn knowledge from search", () => {
  const published = publishFixture();

  const withdrawn = transition(published, {
    type: "CONSENT_WITHDRAWN",
    authorId: "member-alex",
    at: "2026-09-07T12:00:00Z",
  });

  assert.equal(withdrawn.record.status, "revoked");

  const results = searchPublishedKnowledge(
    "documentation",
    {
      actorId: "member-kai",
      communityIds: new Set(["community-docs"]),
    },
    [withdrawn.record],
  );

  assert.deepEqual(results, []);
});

Run the checks:

npm run check
npm test

These tests establish four important properties:

Keep the product integration behind an application-owned port. This avoids pretending that one invented API name applies to every supported client and server stack.

export interface CommunityMessagePort {
  loadCurrentSnapshot(input: {
    communityId: string;
    messageId: string;
  }): Promise<MessageSnapshot | null>;

  showConsentRequest(input: {
    communityId: string;
    messageId: string;
    authorId: string;
    promotionId: string;
  }): Promise<void>;

  showModerationTask(input: {
    promotionId: string;
    communityId: string;
    messageId: string;
  }): Promise<void>;
}

Map 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.

A production nomination workflow should perform these steps:

Do 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.

An AI assistant can reasonably help with:

Those are demonstrated categories of language-model capability: classification, summarization, and extraction. They are not proof that a message is correct, reusable, or consensual.

The model should therefore produce a proposal, never a publication command:

interface DraftSuggestion {
  excerpt: string;
  keywords: string[];
  contextQuestions: string[];
}

Store the suggestion separately from the moderator-approved fields. The moderator should see the original source, the draft, and any warnings side by side.

If 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.

This 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.

Multilingual 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.

Treat translation as a reader-selected view of the approved source, not as an independently verified fact:

approved original
   ├─ translated view for reader A
   └─ translated view for reader B

Preserve 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.

Reload the message before applying consent. If the revision or digest differs, reject the action as stale and show the author the new version.

Do not quietly apply consent to the edit.

Messaging events alone should not be your only defense. Reconcile published records periodically by their current source metadata. Suspend records whose source cannot be verified.

Choose the reconciliation interval according to the sensitivity and expected edit rate of your community; there is no universal safe interval.

Distinguish “confirmed deleted” from “temporarily unavailable.”

Failing open is convenient, but it means unverifiable content remains available to assistants.

Show 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.

The requested community must not come from the prompt or tool arguments. The server derives accessible community IDs from authenticated membership and filters every result.

New 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.

If 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.

Before connecting the workflow to a real community, verify all of the following:

published. A 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.

A 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.

For 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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @tencent rtc 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/dont-let-an-mcp-craw…] indexed:0 read:11min 2026-09-07 ·