e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
491 lines
13 KiB
TypeScript
491 lines
13 KiB
TypeScript
import type * as PageTree from "fumadocs-core/page-tree";
|
|
import { NextResponse, type NextRequest } from "next/server";
|
|
import { getLLMText } from "@/lib/get-llm-text";
|
|
import { examples, getTapDocsPage, source, tapDocs } from "@/lib/source";
|
|
|
|
export const revalidate = false;
|
|
|
|
type JsonRpcRequest = {
|
|
jsonrpc?: string;
|
|
id?: string | number | null;
|
|
method?: string;
|
|
params?: unknown;
|
|
};
|
|
|
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
|
|
const toolDefinitions = [
|
|
{
|
|
name: "list_pages",
|
|
description:
|
|
"List assistant-ui documentation pages. Optionally filter by a URL path prefix such as /docs/tools, /examples, or /tap/docs.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
path: {
|
|
type: "string",
|
|
description: "Optional docs path or prefix to filter by.",
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "get_navigation",
|
|
description: "Return the assistant-ui docs navigation tree.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {},
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "search_docs",
|
|
description:
|
|
"Search assistant-ui docs, examples, and Tap docs by title, description, or URL.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
query: {
|
|
type: "string",
|
|
description: "Search query.",
|
|
},
|
|
},
|
|
required: ["query"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: "read_page",
|
|
description:
|
|
"Read one assistant-ui docs, examples, or Tap docs page as markdown. Accepts a slug, path, .md URL, or same-origin URL.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
path: {
|
|
type: "string",
|
|
description:
|
|
"Page path such as /docs/installation, /docs/installation.md, examples/ai-sdk, tap/docs/store/state, or a same-origin URL.",
|
|
},
|
|
},
|
|
required: ["path"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
];
|
|
|
|
function getStringParam(params: unknown, key: string) {
|
|
if (params && typeof params === "object" && key in params) {
|
|
const value = (params as Record<string, unknown>)[key];
|
|
if (typeof value === "string") return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function pageSummary(page: {
|
|
url: string;
|
|
data: { title: string; description?: string | undefined };
|
|
}) {
|
|
return {
|
|
title: page.data.title,
|
|
url: page.url,
|
|
...(page.data.description ? { description: page.data.description } : {}),
|
|
};
|
|
}
|
|
|
|
function allPages() {
|
|
return [
|
|
...source.getPages().map((page) => ({ kind: "docs" as const, page })),
|
|
...examples.getPages().map((page) => ({
|
|
kind: "examples" as const,
|
|
page,
|
|
})),
|
|
...tapDocs.getPages().map((page) => ({
|
|
kind: "tap" as const,
|
|
page,
|
|
})),
|
|
];
|
|
}
|
|
|
|
function hasHttpScheme(value: string) {
|
|
const prefix = value.slice(0, "https://".length).toLowerCase();
|
|
return prefix.startsWith("http://") || prefix.startsWith("https://");
|
|
}
|
|
|
|
function stripLeadingSlashes(value: string) {
|
|
let start = 0;
|
|
while (start < value.length && value.charCodeAt(start) === 47) start += 1;
|
|
return value.slice(start);
|
|
}
|
|
|
|
function stripTrailingSlashes(value: string) {
|
|
let end = value.length;
|
|
while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
|
|
return value.slice(0, end);
|
|
}
|
|
|
|
function stripMarkdownSuffix(value: string) {
|
|
const lower = value.toLowerCase();
|
|
if (lower.endsWith("/index.mdx")) return value.slice(0, -"/index.mdx".length);
|
|
if (lower.endsWith("/index.md")) return value.slice(0, -"/index.md".length);
|
|
if (lower.endsWith(".mdx")) return value.slice(0, -".mdx".length);
|
|
if (lower.endsWith(".md")) return value.slice(0, -".md".length);
|
|
return value;
|
|
}
|
|
|
|
function normalizePathname(rawPath: string, requestUrl?: string) {
|
|
let value = rawPath.trim();
|
|
|
|
if (hasHttpScheme(value)) {
|
|
const parsed = new URL(value);
|
|
if (requestUrl) {
|
|
const request = new URL(requestUrl);
|
|
if (parsed.origin !== request.origin) {
|
|
throw new Error("Only same-origin docs URLs are supported");
|
|
}
|
|
}
|
|
value = parsed.pathname;
|
|
}
|
|
|
|
return stripMarkdownSuffix(stripTrailingSlashes(stripLeadingSlashes(value)));
|
|
}
|
|
|
|
function normalizePageUrlPrefix(rawPath: string) {
|
|
const pathname = normalizePathname(rawPath);
|
|
return pathname ? `/${pathname}` : "";
|
|
}
|
|
|
|
function normalizePath(rawPath: string, requestUrl: string) {
|
|
const value = normalizePathname(rawPath, requestUrl);
|
|
if (!value) return { kind: "docs" as const, slugs: [] };
|
|
|
|
if (value.includes("..")) {
|
|
throw new Error("Parent directory segments are not supported");
|
|
}
|
|
|
|
if (value === "docs") return { kind: "docs" as const, slugs: [] };
|
|
if (value === "examples") return { kind: "examples" as const, slugs: [] };
|
|
if (value === "tap/docs") return { kind: "tap" as const, slugs: [] };
|
|
if (value.startsWith("docs/")) {
|
|
return {
|
|
kind: "docs" as const,
|
|
slugs: value.slice("docs/".length).split("/").filter(Boolean),
|
|
};
|
|
}
|
|
if (value.startsWith("examples/")) {
|
|
return {
|
|
kind: "examples" as const,
|
|
slugs: value.slice("examples/".length).split("/").filter(Boolean),
|
|
};
|
|
}
|
|
if (value.startsWith("tap/docs/")) {
|
|
return {
|
|
kind: "tap" as const,
|
|
slugs: value.slice("tap/docs/".length).split("/").filter(Boolean),
|
|
};
|
|
}
|
|
return { kind: "docs" as const, slugs: value.split("/").filter(Boolean) };
|
|
}
|
|
|
|
function listPages(path: string | undefined) {
|
|
const normalizedPrefix = path ? normalizePageUrlPrefix(path) : undefined;
|
|
|
|
return allPages()
|
|
.map(({ page }) => pageSummary(page))
|
|
.filter(
|
|
(page) =>
|
|
!normalizedPrefix ||
|
|
page.url === normalizedPrefix ||
|
|
page.url.startsWith(`${normalizedPrefix}/`),
|
|
);
|
|
}
|
|
|
|
function serializeNode(node: PageTree.Node): unknown {
|
|
if (node.type === "page") {
|
|
return {
|
|
type: "page",
|
|
title: typeof node.name === "string" ? node.name : node.url,
|
|
url: node.url,
|
|
...("description" in node &&
|
|
typeof node.description === "string" &&
|
|
node.description
|
|
? { description: node.description }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
if (node.type === "folder") {
|
|
return {
|
|
type: "folder",
|
|
title: typeof node.name === "string" ? node.name : undefined,
|
|
...(node.index ? { url: node.index.url } : {}),
|
|
...("description" in node &&
|
|
typeof node.description === "string" &&
|
|
node.description
|
|
? { description: node.description }
|
|
: {}),
|
|
children: node.children.map(serializeNode),
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: node.type,
|
|
};
|
|
}
|
|
|
|
function getNavigation() {
|
|
return {
|
|
docs: source.pageTree.children.map(serializeNode),
|
|
examples: examples.pageTree.children.map(serializeNode),
|
|
tapDocs: tapDocs.pageTree.children.map(serializeNode),
|
|
};
|
|
}
|
|
|
|
function searchDocs(query: string) {
|
|
const normalized = query.trim().toLowerCase();
|
|
if (!normalized) return [];
|
|
|
|
return allPages()
|
|
.map(({ page }) => pageSummary(page))
|
|
.filter((page) =>
|
|
[page.title, page.url, page.description ?? ""].some((value) =>
|
|
value.toLowerCase().includes(normalized),
|
|
),
|
|
)
|
|
.slice(0, 20);
|
|
}
|
|
|
|
async function readPage(path: string | undefined, requestUrl: string) {
|
|
if (!path) throw new Error("path is required");
|
|
|
|
const normalized = normalizePath(path, requestUrl);
|
|
const page =
|
|
normalized.kind === "examples"
|
|
? examples.getPage(normalized.slugs)
|
|
: normalized.kind === "tap"
|
|
? getTapDocsPage(normalized.slugs)
|
|
: source.getPage(normalized.slugs);
|
|
|
|
if (!page) throw new Error(`Page not found: ${path}`);
|
|
|
|
return {
|
|
title: page.data.title,
|
|
url: page.url,
|
|
content: await getLLMText(page),
|
|
};
|
|
}
|
|
|
|
async function callTool(
|
|
name: string | undefined,
|
|
params: unknown,
|
|
requestUrl: string,
|
|
) {
|
|
const args =
|
|
params && typeof params === "object" && "arguments" in params
|
|
? (params as { arguments?: unknown }).arguments
|
|
: undefined;
|
|
|
|
switch (name) {
|
|
case "list_pages":
|
|
return listPages(getStringParam(args, "path"));
|
|
case "get_navigation":
|
|
return getNavigation();
|
|
case "search_docs":
|
|
return searchDocs(getStringParam(args, "query") ?? "");
|
|
case "read_page":
|
|
return readPage(getStringParam(args, "path"), requestUrl);
|
|
default:
|
|
throw new Error(`Unknown tool: ${name ?? "missing"}`);
|
|
}
|
|
}
|
|
|
|
function jsonRpcResult(id: JsonRpcRequest["id"], result: unknown) {
|
|
return { jsonrpc: "2.0", id, result };
|
|
}
|
|
|
|
function jsonRpcError(id: JsonRpcRequest["id"], code: number, message: string) {
|
|
return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
|
|
}
|
|
|
|
async function handleJsonRpcMessage(
|
|
message: JsonRpcRequest,
|
|
requestUrl: string,
|
|
) {
|
|
if (message.id === undefined) return null;
|
|
|
|
try {
|
|
switch (message.method) {
|
|
case "initialize": {
|
|
return jsonRpcResult(message.id, {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
capabilities: {
|
|
tools: { listChanged: false },
|
|
resources: { subscribe: false, listChanged: false },
|
|
},
|
|
serverInfo: {
|
|
name: "assistant-ui-docs",
|
|
version: "1.0.0",
|
|
},
|
|
});
|
|
}
|
|
case "tools/list":
|
|
return jsonRpcResult(message.id, { tools: toolDefinitions });
|
|
case "tools/call": {
|
|
const name = getStringParam(message.params, "name");
|
|
let result: unknown;
|
|
try {
|
|
result = await callTool(name, message.params, requestUrl);
|
|
} catch (error) {
|
|
return jsonRpcResult(message.id, {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: error instanceof Error ? error.message : String(error),
|
|
},
|
|
],
|
|
isError: true,
|
|
});
|
|
}
|
|
return jsonRpcResult(message.id, {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text:
|
|
typeof result === "string"
|
|
? result
|
|
: JSON.stringify(result, null, 2),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
case "resources/list":
|
|
return jsonRpcResult(message.id, {
|
|
resources: [
|
|
{
|
|
uri: "assistant-ui://navigation",
|
|
name: "assistant-ui docs navigation",
|
|
mimeType: "application/json",
|
|
},
|
|
...allPages().map(({ page }) => ({
|
|
uri: `assistant-ui://${stripLeadingSlashes(page.url)}`,
|
|
name: page.data.title,
|
|
mimeType: "text/markdown",
|
|
})),
|
|
],
|
|
});
|
|
case "resources/read": {
|
|
const uri = getStringParam(message.params, "uri");
|
|
if (uri === "assistant-ui://navigation") {
|
|
return jsonRpcResult(message.id, {
|
|
contents: [
|
|
{
|
|
uri,
|
|
mimeType: "application/json",
|
|
text: JSON.stringify(getNavigation(), null, 2),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (!uri?.startsWith("assistant-ui://")) {
|
|
return jsonRpcError(message.id, -32602, "Unsupported resource URI");
|
|
}
|
|
const path = uri.slice("assistant-ui://".length);
|
|
const page = await readPage(path, requestUrl);
|
|
return jsonRpcResult(message.id, {
|
|
contents: [
|
|
{
|
|
uri,
|
|
mimeType: "text/markdown",
|
|
text: page.content,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
default:
|
|
return jsonRpcError(
|
|
message.id,
|
|
-32601,
|
|
`Method not found: ${message.method ?? "missing"}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
return jsonRpcError(
|
|
message.id,
|
|
-32000,
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
}
|
|
}
|
|
|
|
function jsonResponse(body: unknown, init?: ResponseInit) {
|
|
return NextResponse.json(body, {
|
|
...init,
|
|
headers: {
|
|
"Cache-Control": "no-store",
|
|
...init?.headers,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function GET() {
|
|
return jsonResponse({
|
|
name: "assistant-ui-docs",
|
|
protocol: "mcp",
|
|
endpoints: ["/mcp", "/.well-known/mcp", "/docs/mcp"],
|
|
tools: toolDefinitions.map((tool) => ({
|
|
name: tool.name,
|
|
description: tool.description,
|
|
})),
|
|
});
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
let payload: unknown;
|
|
try {
|
|
payload = await request.json();
|
|
} catch {
|
|
return jsonResponse(jsonRpcError(null, -32700, "Parse error"), {
|
|
status: 400,
|
|
});
|
|
}
|
|
|
|
if (Array.isArray(payload)) {
|
|
if (payload.length === 0) {
|
|
return jsonResponse(jsonRpcError(null, -32600, "Invalid Request"), {
|
|
status: 400,
|
|
});
|
|
}
|
|
|
|
const results = (
|
|
await Promise.all(
|
|
payload.map((message) =>
|
|
handleJsonRpcMessage(message as JsonRpcRequest, request.url),
|
|
),
|
|
)
|
|
).filter((result) => result !== null);
|
|
|
|
return results.length > 0
|
|
? jsonResponse(results)
|
|
: new NextResponse(null, { status: 202 });
|
|
}
|
|
|
|
const result = await handleJsonRpcMessage(
|
|
payload as JsonRpcRequest,
|
|
request.url,
|
|
);
|
|
|
|
return result
|
|
? jsonResponse(result)
|
|
: new NextResponse(null, { status: 202 });
|
|
}
|
|
|
|
export function OPTIONS() {
|
|
return new NextResponse(null, {
|
|
status: 204,
|
|
headers: {
|
|
Allow: "GET, POST, OPTIONS",
|
|
"Cache-Control": "no-store",
|
|
},
|
|
});
|
|
}
|