DEV Community

LunarDrift
LunarDrift

Posted on

Don’t Let an MCP Crawl Your Community Chat: Build a Consent-Gated Knowledge Pipeline

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:

  • Was the message actually intended for reuse?
  • Has the author edited or deleted it?
  • Does it make sense outside its original thread?
  • Is the requesting user allowed to see its community?
  • Who decides that an answer is reliable enough to become shared knowledge?

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.

The boundary we are building

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
Enter fullscreen mode Exit fullscreen mode

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.

Promotion states

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
Enter fullscreen mode Exit fullscreen mode

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.

Create the reproducible project

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
Enter fullscreen mode Exit fullscreen mode

Add these scripts to package.json:

{
  "scripts": {
    "test": "tsx --test test/*.test.ts",
    "check": "tsc --noEmit"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true
  },
  "include": ["src", "test"]
}
Enter fullscreen mode Exit fullscreen mode

Represent the source as a revisioned snapshot

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}`,
        },
      };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. A delayed consent or moderation action targeting an older message version.
  2. A source system that reuses or normalizes revision identifiers unexpectedly.

Give the MCP tool less authority than the application

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,
      },
    ];
  });
}
Enter fullscreen mode Exit fullscreen mode

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?"
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Verify the dangerous paths

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, []);
});
Enter fullscreen mode Exit fullscreen mode

Run the checks:

npm run check
npm test
Enter fullscreen mode Exit fullscreen mode

These tests establish four important properties:

  • Consent applies to an exact source version.
  • Edited knowledge stops being retrievable.
  • Community access comes from trusted identity context.
  • Consent withdrawal affects retrieval, not merely the UI.

Connect the workflow to Tencent RTC messaging

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>;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. A member selects “Nominate as shared knowledge.”
  2. The server loads the current message rather than trusting text submitted by the client.
  3. The server records the source revision and digest.
  4. The author sees the proposed reuse scope and explicitly consents.
  5. The server reloads the source before accepting that consent.
  6. A moderator reviews context and writes or approves the reusable excerpt.
  7. The server reloads the source again before publication.
  8. Only the approved excerpt enters the MCP-readable index.
  9. Message edits, deletion, or consent withdrawal suspend or revoke the record.

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.

Where AI helps—and where it should stop

An AI assistant can reasonably help with:

  • suggesting candidate messages for nomination;
  • drafting a shorter excerpt after author consent;
  • proposing search keywords;
  • identifying potentially missing context for a moderator.

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[];
}
Enter fullscreen mode Exit fullscreen mode

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.

Translation is a view, not a new source record

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
Enter fullscreen mode Exit fullscreen mode

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.

Failure modes to rehearse

Consent arrives after the message was edited

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.

The edit notification is missed

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

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

The source cannot be loaded

Distinguish “confirmed deleted” from “temporarily unavailable.”

  • Confirmed deletion should revoke the record.
  • A transient lookup failure should mark verification as pending and prevent a fresh publication decision.
  • Existing records may be suspended if your risk policy requires current source verification.

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

A moderator approves an AI-written summary that changes the meaning

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.

A user asks the model to search another private community

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.

Consent is withdrawn while an assistant is composing

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.

Pre-release verification checklist

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

  • [ ] A nominee cannot impersonate the source author.
  • [ ] Consent names the reuse scope and the exact message revision.
  • [ ] Moderator approval applies to immutable excerpt text.
  • [ ] AI drafts cannot transition a record to published.
  • [ ] Edited messages become non-searchable.
  • [ ] Deleted messages become non-searchable.
  • [ ] Consent withdrawal becomes effective in the retrieval layer.
  • [ ] MCP authorization comes from trusted request identity.
  • [ ] Search results never cross community boundaries.
  • [ ] The tool returns an approved excerpt rather than raw thread history.
  • [ ] Translation remains linked to the approved original.
  • [ ] Missed update events can be detected by reconciliation.
  • [ ] Moderators and authors can see why a record is suspended or rejected.
  • [ ] Audit logs avoid storing unnecessary message bodies or model prompts.

The trade-off: slower ingestion, stronger shared knowledge

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.

Top comments (0)