diff --git a/.env.example b/.env.example
index 2c4be9065..ead937ffa 100644
--- a/.env.example
+++ b/.env.example
@@ -8,3 +8,5 @@ GOOGLE_TAG_MANAGER_ID=GTM-PTLT3GH
POSTHOG_API_HOST=https://directus.io/ingest
POSTHOG_API_KEY=phc_secret_key_here
NUXT_PUBLIC_SITE_URL=https://directus.io
+# Optional. Fine-grained PAT, public repos read-only. Required for code search and raises GitHub raw rate limits.
+# GITHUB_TOKEN=github_pat_...
diff --git a/app/components/CopyDocButton.vue b/app/components/CopyDocButton.vue
index d1a00dbcc..0121f3b84 100644
--- a/app/components/CopyDocButton.vue
+++ b/app/components/CopyDocButton.vue
@@ -1,88 +1,120 @@
@@ -104,8 +137,17 @@ async function copyPage() {
class="text-lg"
/>
-
{{ item.label }}
-
+
+ {{ item.label }}
+
+
{{ item.description }}
diff --git a/app/utils/agent-deeplinks.ts b/app/utils/agent-deeplinks.ts
new file mode 100644
index 000000000..ce8011b04
--- /dev/null
+++ b/app/utils/agent-deeplinks.ts
@@ -0,0 +1,34 @@
+// IDE deeplink helpers for the docs MCP server and agent prompt copy actions.
+// Toolkit-supported IDEs are sourced from @nuxtjs/mcp-toolkit's deeplink route.
+
+export type McpIde = 'cursor' | 'vscode';
+
+export interface McpIdeOption {
+ id: McpIde;
+ label: string;
+ icon: string;
+}
+
+export const MCP_IDES: McpIdeOption[] = [
+ { id: 'cursor', label: 'Add to Cursor', icon: 'i-simple-icons:cursor' },
+ { id: 'vscode', label: 'Add to VS Code', icon: 'i-simple-icons:visualstudiocode' },
+];
+
+export function mcpDeeplinkPath(baseURL: string, ide?: McpIde) {
+ const base = baseURL.replace(/\/$/, '');
+ const path = `${base}/mcp/deeplink`;
+ return ide ? `${path}?ide=${ide}` : path;
+}
+
+export function mcpServerUrl(origin: string, baseURL: string) {
+ const base = baseURL.replace(/\/$/, '');
+ return `${origin}${base}/mcp`;
+}
+
+export function chatGptPromptUrl(prompt: string) {
+ return `https://chatgpt.com/?hints=search&q=${encodeURIComponent(prompt)}`;
+}
+
+export function claudePromptUrl(prompt: string) {
+ return `https://claude.ai/new?q=${encodeURIComponent(prompt)}`;
+}
diff --git a/content/guides/11.ai/2.mcp/0.index.md b/content/guides/11.ai/2.mcp/0.index.md
index 717e03e65..62acb59df 100644
--- a/content/guides/11.ai/2.mcp/0.index.md
+++ b/content/guides/11.ai/2.mcp/0.index.md
@@ -1,7 +1,9 @@
---
stableId: 91f4d2fc-286d-47c0-8942-68af505ce679
-title: Overview
+title: Directus MCP
description: Connect AI assistants directly to your Directus instance. Let Claude, ChatGPT, and other AI tools manage your content without manual copy-pasting.
+navigation:
+ title: Overview
---
`;
+ }
+ return inlinePartials(partial, partials, depth + 1);
+ });
+}
+
+function stripBlockFences(body: string): string {
+ const lines = body.split('\n');
+ const out: string[] = [];
+ const openFences: number[] = [];
+ let inCodeBlock = false;
+ let codeFence = '';
+
+ for (const line of lines) {
+ const codeMatch = line.match(/^[\t ]*(```+|~~~+)/);
+ if (codeMatch) {
+ if (!inCodeBlock) {
+ inCodeBlock = true;
+ codeFence = codeMatch[1] ?? '';
+ }
+ else if (line.trim().startsWith(codeFence)) {
+ inCodeBlock = false;
+ codeFence = '';
+ }
+ out.push(line);
+ continue;
+ }
+
+ if (inCodeBlock) {
+ out.push(line);
+ continue;
+ }
+
+ const open = line.match(/^[\t ]*(:{2,})([a-z][a-z0-9-]*)(?:\{[^}]*\})?[\t ]*$/);
+ if (open) {
+ const openToken = open[1];
+ if (openToken) openFences.push(openToken.length);
+ continue;
+ }
+
+ const closeMatch = line.match(/^[\t ]*(:{2,})[\t ]*$/);
+ if (closeMatch && openFences.length > 0) {
+ const closeToken = closeMatch[1];
+ if (!closeToken) continue;
+ const closeLen = closeToken.length;
+ const lastOpen = openFences[openFences.length - 1];
+ if (closeLen === lastOpen) {
+ openFences.pop();
+ continue;
+ }
+ }
+
+ out.push(line);
+ }
+
+ return out.join('\n');
+}
+
+function rewriteInlineDirectives(body: string): string {
+ return body
+ .replace(VIDEO_EMBED, (_m, id: string) => `[Watch video](https://www.youtube.com/watch?v=${id})`)
+ .replace(DOC_CLI_SNIPPET, (_m, cmd: string) => `\n\`\`\`bash\n${cmd}\n\`\`\`\n`)
+ .replace(CTA_CLOUD_LINE, '')
+ .replace(PRODUCT_LINK, '');
+}
diff --git a/server/utils/directus-repos.ts b/server/utils/directus-repos.ts
new file mode 100644
index 000000000..b9812d4fe
--- /dev/null
+++ b/server/utils/directus-repos.ts
@@ -0,0 +1,17 @@
+// `@directus/sdk` and `@directus/extensions-sdk` live inside the `directus/directus`
+// monorepo (under `packages/`), not as separate repos. Use `path:packages/sdk` or
+// `path:packages/extensions-sdk` to scope a search-directus-code call to those packages.
+export const DIRECTUS_REPOS = {
+ directus: 'directus/directus',
+ examples: 'directus/examples',
+ docs: 'directus/docs',
+} as const;
+
+export type DirectusRepoSlug = keyof typeof DIRECTUS_REPOS;
+
+export const DIRECTUS_REPO_SLUGS = Object.keys(DIRECTUS_REPOS) as [DirectusRepoSlug, ...DirectusRepoSlug[]];
+
+export function directusRepoSearchQualifier(repo?: DirectusRepoSlug): string {
+ if (repo) return `repo:${DIRECTUS_REPOS[repo]}`;
+ return `(${Object.values(DIRECTUS_REPOS).map(full => `repo:${full}`).join(' OR ')})`;
+}
diff --git a/server/utils/docs-api-rate-limit.ts b/server/utils/docs-api-rate-limit.ts
new file mode 100644
index 000000000..28c9fe89a
--- /dev/null
+++ b/server/utils/docs-api-rate-limit.ts
@@ -0,0 +1,35 @@
+// Per-IP rate limit for the public docs HTTP API (60/min). Lower budget than chat
+// because these are read-only and cheap, but still protect against scraping abuse.
+
+interface Bucket {
+ count: number;
+ resetAt: number;
+}
+
+const WINDOW_MS = 60_000;
+const MAX = 60;
+const buckets = new Map();
+let lastSweep = 0;
+
+export function checkDocsApiRateLimit(key: string): { ok: boolean; retryAfter?: number } {
+ const now = Date.now();
+
+ if (now - lastSweep > WINDOW_MS) {
+ for (const [k, b] of buckets) {
+ if (b.resetAt < now) buckets.delete(k);
+ }
+ lastSweep = now;
+ }
+
+ const bucket = buckets.get(key);
+ if (!bucket || bucket.resetAt < now) {
+ buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
+ return { ok: true };
+ }
+
+ bucket.count++;
+ if (bucket.count > MAX) {
+ return { ok: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
+ }
+ return { ok: true };
+}
diff --git a/server/utils/mcp-rate-limit.ts b/server/utils/mcp-rate-limit.ts
new file mode 100644
index 000000000..4f456ce23
--- /dev/null
+++ b/server/utils/mcp-rate-limit.ts
@@ -0,0 +1,18 @@
+const buckets = new Map();
+
+export function checkMcpRateLimit(key: string, max: number, windowMs: number): { ok: boolean; retryAfter?: number } {
+ const now = Date.now();
+ const bucket = buckets.get(key);
+ if (!bucket || bucket.resetAt <= now) {
+ buckets.set(key, { count: 1, resetAt: now + windowMs });
+ return { ok: true };
+ }
+
+ bucket.count++;
+ if (bucket.count <= max) return { ok: true };
+
+ return {
+ ok: false,
+ retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
+ };
+}
diff --git a/server/utils/sliceUtf8.ts b/server/utils/sliceUtf8.ts
new file mode 100644
index 000000000..ad9e3d929
--- /dev/null
+++ b/server/utils/sliceUtf8.ts
@@ -0,0 +1,13 @@
+export function sliceUtf8(text: string, offset: number, bytes: number): { content: string; nextOffset: number | null; truncated: boolean } {
+ const buffer = Buffer.from(text, 'utf8');
+ const start = Math.min(offset, buffer.length);
+ let end = Math.min(start + bytes, buffer.length);
+ let content = buffer.subarray(start, end).toString('utf8');
+ while (end < buffer.length && content.endsWith('\uFFFD') && end > start) {
+ end--;
+ content = buffer.subarray(start, end).toString('utf8');
+ }
+ const consumed = Buffer.byteLength(content, 'utf8');
+ const nextOffset = start + consumed < buffer.length ? start + consumed : null;
+ return { content, nextOffset, truncated: nextOffset !== null };
+}
diff --git a/tests/server/get-directus-file.test.ts b/tests/server/get-directus-file.test.ts
new file mode 100644
index 000000000..76074535e
--- /dev/null
+++ b/tests/server/get-directus-file.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+import { sliceUtf8 } from '../../server/utils/sliceUtf8';
+
+describe('sliceUtf8', () => {
+ it('backs up to a valid UTF-8 boundary', () => {
+ const first = sliceUtf8('abc😀def', 0, 5);
+
+ expect(first.content).toBe('abc');
+ expect(first.nextOffset).toBe(3);
+ expect(first.truncated).toBe(true);
+
+ const second = sliceUtf8('abc😀def', first.nextOffset!, 1024);
+
+ expect(second.content).toBe('😀def');
+ expect(second.nextOffset).toBeNull();
+ expect(second.truncated).toBe(false);
+ });
+});