가이드
MCP로 사이트의 지식을 제공하기
문서를 검색하는 읽기 전용 MCP 도구를 게시하면 Busymate AI가 사이트를 크롤링하는 대신 해당 도구로 페이지 링크와 함께 답변합니다.
이 페이지의 목차
Expose documentation through an MCP server with source links. Busymate AI can answer through its tools without copying your site. Build, advertise and connect the server below.
1. Design the knowledge tools
Use descriptive knowledge-tool names. Public delivery quotes, coverage lookups and account searches do not replace a knowledge base.
| Tool | Input | Returns |
|---|---|---|
search_docs | query, optional limit (1–10) | results: title, url, section, snippet |
read_docs_page | url from a search result | title, url, section, full content |
list_docs_sections | nothing | sections, each with its pages and URLs |
get_setup_steps (optional) | optional platform | ordered steps, each with a url |
get_pricing (optional) | nothing | your published plans and the pricing page url |
get_faq (optional) | optional topic | question, answer and url per entry |
The full tools/list entries, with input and output schemas:
{
"tools": [
{
"name": "search_docs",
"title": "Search the docs",
"description": "Search the public documentation. Returns the best-matching pages with their URLs; cite the URL you use.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "What the visitor asked, in their words."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5
}
},
"required": [
"query"
]
},
"outputSchema": {
"type": "object",
"properties": {
"results": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"url": {
"type": "string",
"format": "uri"
},
"section": {
"type": "string"
},
"snippet": {
"type": "string"
}
},
"required": [
"title",
"url",
"snippet"
]
}
}
},
"required": [
"results"
]
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
},
"_meta": {
"securitySchemes": [
{
"type": "noauth"
}
]
}
},
{
"name": "read_docs_page",
"title": "Read one docs page",
"description": "Return the full text of one documentation page by its URL (from search_docs or list_docs_sections).",
"inputSchema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"format": "uri"
}
},
"required": [
"url"
]
},
"outputSchema": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"url": {
"type": "string",
"format": "uri"
},
"section": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": [
"title",
"url",
"content"
]
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
},
"_meta": {
"securitySchemes": [
{
"type": "noauth"
}
]
}
},
{
"name": "list_docs_sections",
"title": "List the docs sections",
"description": "List the documentation's sections and the pages in each, with URLs.",
"inputSchema": {
"type": "object",
"properties": {}
},
"outputSchema": {
"type": "object",
"properties": {
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"pages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"url": {
"type": "string",
"format": "uri"
}
},
"required": [
"title",
"url"
]
}
}
},
"required": [
"name",
"pages"
]
}
}
},
"required": [
"sections"
]
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
},
"_meta": {
"securitySchemes": [
{
"type": "noauth"
}
]
}
},
{
"name": "get_setup_steps",
"title": "Get setup steps",
"description": "Optional. Ordered setup steps for one platform, each with the page URL that explains it.",
"inputSchema": {
"type": "object",
"properties": {
"platform": {
"type": "string",
"enum": [
"all",
"web",
"ios",
"android",
"desktop"
],
"default": "all"
}
}
},
"outputSchema": {
"type": "object",
"properties": {
"platform": {
"type": "string"
},
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"step": {
"type": "string"
},
"url": {
"type": "string",
"format": "uri"
}
},
"required": [
"step",
"url"
]
}
}
},
"required": [
"platform",
"steps"
]
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
},
"_meta": {
"securitySchemes": [
{
"type": "noauth"
}
]
}
}
]
}A search_docs call returns the data twice: as text for every client, and as structuredContent that matches the output schema:
{
"content": [
{
"type": "text",
"text": "{\"results\":[{\"title\":\"Refunds\",\"url\":\"https://docs.example.com/billing/refunds\",\"section\":\"Billing\",\"snippet\":\"Refunds are issued within 14 days of purchase from Settings › Billing.\"}]}"
}
],
"structuredContent": {
"results": [
{
"title": "Refunds",
"url": "https://docs.example.com/billing/refunds",
"section": "Billing",
"snippet": "Refunds are issued within 14 days of purchase from Settings › Billing."
}
]
}
}Accept anonymous initialize and tools/list requests. An OAuth-only endpoint returning 401 before listing tools cannot serve public knowledge; expose a separate endpoint if necessary.
Four rules decide whether Busymate AI can offer a tool to anonymous visitors:
- Annotate it read-only. Set
readOnlyHint: trueanddestructiveHint: false. A tool without that annotation is never offered to visitors. - Declare it public. Add
securitySchemes: [{ "type": "noauth" }]under_meta. This explicitly declares public access. A standard read-only tool without security metadata is offered as an anonymous candidate: access is established only by a real requested call. Explicit OAuth-only tools are excluded. Discovery does not execute tools with invented arguments. A 401, a 403 or a sign-in error never grants public access. - Use a read name. A name with a write verb (
create_,update_,delete_,send_and similar) is never called, whatever its annotations say. - Return absolute page URLs. These are the citations visitors see.
2. Run the server
Each sample serves Streamable HTTP at /mcp. Replace sample pages with your search.
TypeScript, with the official MCP SDK:
// npm init -y && npm pkg set type=module && npm i @modelcontextprotocol/sdk@1.32.2 zod@4
// Run: node server.ts (Node 24), or npx tsx server.ts. Serves http://localhost:3000/mcp.
import { createServer } from "node:http";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
type Page = { title: string; url: string; section: string; text: string };
// Replace with your own search: a database, a search service or a JSON file.
const PAGES: Page[] = [
{ title: "Getting started", url: "https://docs.example.com/start", section: "Basics", text: "Create an account, add your site, then publish." },
{ title: "Refunds", url: "https://docs.example.com/billing/refunds", section: "Billing", text: "Refunds are issued within 14 days of purchase from Settings › Billing." },
{ title: "Install on iPhone", url: "https://docs.example.com/setup/iphone", section: "Setup", text: "Install the app from the App Store and sign in with your work email." },
];
const PUBLIC_READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
const NO_AUTH = { securitySchemes: [{ type: "noauth" }] };
const reply = (data: Record<string, unknown>) => ({ content: [{ type: "text" as const, text: JSON.stringify(data) }], structuredContent: data });
const link = z.object({ title: z.string(), url: z.string() });
function buildServer(): McpServer {
const server = new McpServer({ name: "example-docs", version: "1.0.0" });
server.registerTool("search_docs", {
title: "Search the docs",
description: "Search the public documentation. Returns the best-matching pages with their URLs; cite the URL you use.",
inputSchema: { query: z.string().min(1).max(200), limit: z.number().int().min(1).max(10).optional() },
outputSchema: { results: z.array(link.extend({ section: z.string(), snippet: z.string() })) },
annotations: PUBLIC_READ,
_meta: NO_AUTH,
}, async ({ query, limit = 5 }) => {
const terms = query.toLowerCase().split(/\W+/).filter(Boolean);
const results = PAGES
.map((p) => ({ p, score: terms.filter((t) => `${p.title} ${p.text}`.toLowerCase().includes(t)).length }))
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ p }) => ({ title: p.title, url: p.url, section: p.section, snippet: p.text.slice(0, 300) }));
return reply({ results });
});
server.registerTool("read_docs_page", {
title: "Read one docs page",
description: "Return the full text of one documentation page by its URL (from search_docs or list_docs_sections).",
inputSchema: { url: z.string() },
annotations: PUBLIC_READ,
_meta: NO_AUTH,
}, async ({ url }) => {
const page = PAGES.find((p) => p.url === url);
if (!page) return { isError: true, content: [{ type: "text" as const, text: `No page at ${url}. Use search_docs to find one.` }] };
return reply({ title: page.title, url: page.url, section: page.section, content: page.text });
});
server.registerTool("list_docs_sections", {
title: "List the docs sections",
description: "List the documentation's sections and the pages in each, with URLs.",
inputSchema: {},
outputSchema: { sections: z.array(z.object({ name: z.string(), pages: z.array(link) })) },
annotations: PUBLIC_READ,
_meta: NO_AUTH,
}, async () => {
const names = [...new Set(PAGES.map((p) => p.section))];
return reply({ sections: names.map((name) => ({ name, pages: PAGES.filter((p) => p.section === name).map(({ title, url }) => ({ title, url })) })) });
});
return server;
}
createServer(async (req, res) => {
if (!req.url?.startsWith("/mcp")) return void res.writeHead(404).end();
if (req.method !== "POST") return void res.writeHead(405, { allow: "POST" }).end();
// Stateless: a fresh server per request, so nothing is shared between visitors.
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
res.on("close", () => void transport.close().then(() => server.close()));
await server.connect(transport);
await transport.handleRequest(req, res);
}).listen(Number(process.env.PORT ?? 3000));Python, with FastMCP:
# pip install fastmcp==4.1.4 — run: python server.py (Python 3.10+)
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from pydantic import Field
# Replace with your own search: a database, a search service or a JSON file.
PAGES = [
{"title": "Getting started", "url": "https://docs.example.com/start", "section": "Basics", "text": "Create an account, add your site, then publish."},
{"title": "Refunds", "url": "https://docs.example.com/billing/refunds", "section": "Billing", "text": "Refunds are issued within 14 days of purchase from Settings › Billing."},
{"title": "Install on iPhone", "url": "https://docs.example.com/setup/iphone", "section": "Setup", "text": "Install the app from the App Store and sign in with your work email."},
]
PUBLIC_READ = {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False}
NO_AUTH = {"securitySchemes": [{"type": "noauth"}]}
mcp = FastMCP("example-docs")
@mcp.tool(annotations=PUBLIC_READ, meta=NO_AUTH)
def search_docs(
query: Annotated[str, Field(min_length=1, max_length=200)],
limit: Annotated[int, Field(ge=1, le=10)] = 5,
) -> dict:
"""Search the public documentation. Returns the best-matching pages with their URLs; cite the URL you use."""
terms = [t for t in query.lower().split() if t]
scored = [(sum(t in f"{p['title']} {p['text']}".lower() for t in terms), p) for p in PAGES]
hits = sorted((s for s in scored if s[0] > 0), key=lambda s: -s[0])[:limit]
return {"results": [{"title": p["title"], "url": p["url"], "section": p["section"], "snippet": p["text"][:300]} for _, p in hits]}
@mcp.tool(annotations=PUBLIC_READ, meta=NO_AUTH)
def read_docs_page(url: str) -> dict:
"""Return the full text of one documentation page by its URL (from search_docs or list_docs_sections)."""
page = next((p for p in PAGES if p["url"] == url), None)
if page is None:
raise ToolError(f"No page at {url}. Use search_docs to find one.")
return {"title": page["title"], "url": page["url"], "section": page["section"], "content": page["text"]}
@mcp.tool(annotations=PUBLIC_READ, meta=NO_AUTH)
def list_docs_sections() -> dict:
"""List the documentation's sections and the pages in each, with URLs."""
names = list(dict.fromkeys(p["section"] for p in PAGES))
return {"sections": [{"name": n, "pages": [{"title": p["title"], "url": p["url"]} for p in PAGES if p["section"] == n]} for n in names]}
if __name__ == "__main__":
# Serves Streamable HTTP at http://0.0.0.0:8000/mcp; put it behind HTTPS.
mcp.run(transport="http", host="0.0.0.0", port=8000, stateless_http=True)This Cloudflare Worker searches your docs JSON index and serves discovery:
// A read-only MCP server with no SDK and no database: it searches the JSON
// index your site build already writes ([{ title, url, section, text }]).
// Deploy as a Cloudflare Worker on your docs domain, routes /mcp and /.well-known/mcp.json.
const INDEX_URL = "https://docs.example.com/search-index.json";
const VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
const PUBLIC_READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
const NO_AUTH = { securitySchemes: [{ type: "noauth" }] };
const TOOLS = [
{ name: "search_docs", description: "Search the public documentation. Returns the best-matching pages with their URLs; cite the URL you use.",
inputSchema: { type: "object", properties: { query: { type: "string", minLength: 1, maxLength: 200 }, limit: { type: "integer", minimum: 1, maximum: 10 } }, required: ["query"] },
annotations: PUBLIC_READ, _meta: NO_AUTH },
{ name: "read_docs_page", description: "Return the full text of one documentation page by its URL.",
inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] },
annotations: PUBLIC_READ, _meta: NO_AUTH },
{ name: "list_docs_sections", description: "List the documentation's sections and the pages in each, with URLs.",
inputSchema: { type: "object", properties: {} }, annotations: PUBLIC_READ, _meta: NO_AUTH },
];
let cache = { at: 0, pages: [] };
async function pages() {
if (Date.now() - cache.at > 300_000) cache = { at: Date.now(), pages: await (await fetch(INDEX_URL)).json() };
return cache.pages;
}
const ok = (data) => ({ content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data });
async function callTool(name, args) {
const all = await pages();
if (name === "search_docs") {
const terms = String(args.query ?? "").toLowerCase().split(/\W+/).filter(Boolean);
const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 10);
const results = all
.map((p) => ({ p, score: terms.filter((t) => `${p.title} ${p.text}`.toLowerCase().includes(t)).length }))
.filter((r) => r.score > 0).sort((a, b) => b.score - a.score).slice(0, limit)
.map(({ p }) => ({ title: p.title, url: p.url, section: p.section, snippet: p.text.slice(0, 300) }));
return ok({ results });
}
if (name === "read_docs_page") {
const p = all.find((page) => page.url === args.url);
return p ? ok({ title: p.title, url: p.url, section: p.section, content: p.text })
: { isError: true, content: [{ type: "text", text: `No page at ${args.url}. Use search_docs to find one.` }] };
}
if (name === "list_docs_sections") {
const names = [...new Set(all.map((p) => p.section))];
return ok({ sections: names.map((n) => ({ name: n, pages: all.filter((p) => p.section === n).map(({ title, url }) => ({ title, url })) })) });
}
return null;
}
const rpc = (id, body) => Response.json({ jsonrpc: "2.0", id, ...body });
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/.well-known/mcp.json") {
return Response.json({ name: "Example Docs", endpoint: `${url.origin}/mcp`, authentication: { required: false } });
}
if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 });
if (request.method !== "POST") return new Response(null, { status: 405, headers: { allow: "POST" } });
let msg;
try { msg = await request.json(); } catch { return rpc(null, { error: { code: -32700, message: "Parse error" } }); }
if (msg.id === undefined || msg.id === null) return new Response(null, { status: 202 }); // a notification
switch (msg.method) {
case "initialize": {
const asked = msg.params?.protocolVersion;
return rpc(msg.id, { result: { protocolVersion: VERSIONS.includes(asked) ? asked : VERSIONS[0], capabilities: { tools: {} }, serverInfo: { name: "example-docs", version: "1.0.0" } } });
}
case "ping": return rpc(msg.id, { result: {} });
case "tools/list": return rpc(msg.id, { result: { tools: TOOLS } });
case "tools/call": {
const result = await callTool(msg.params?.name, msg.params?.arguments ?? {});
return result ? rpc(msg.id, { result }) : rpc(msg.id, { error: { code: -32602, message: "Unknown tool" } });
}
default: return rpc(msg.id, { error: { code: -32601, message: "Method not found" } });
}
},
};Put the endpoint on HTTPS. It must answer the POST itself: Busymate AI does not follow redirects, so /mcp must not redirect to /mcp/.
3. Publish a discovery file
Busymate AI looks for your server on the origin of the site you add, strongest signal first:
/.well-known/mcp.json, withendpointorurl(aserverslist also works).- A server card at
/.well-known/mcp/server-card.jsonor/.well-known/mcp, withtransport.endpointorremotes[].url. /.well-known/agents.jsonor/agents.json, with aninterfacesentry of typemcp.- The home page: a
Linkheader or a<link>tag withrel="mcp".
Use an absolute HTTPS endpoint and serve discovery as JSON.
{
"name": "Example Docs",
"description": "Search and read the Example documentation.",
"endpoint": "https://docs.example.com/mcp",
"documentation": "https://docs.example.com/",
"authentication": {
"required": false
}
}Or, with no file at all:
<!-- in the <head> of your home page -->
<link rel="mcp" href="https://docs.example.com/mcp">
<!-- or as a response header on the home page:
Link: <https://docs.example.com/mcp>; rel="mcp" -->4. Connect and publish your tools
An MCP-only setup needs no Website crawl source.
- Open Console → MCP servers in the correct workspace. Enter the HTTPS endpoint, choose HTTP transport and no server credential for the public knowledge endpoint, then Probe.
- Check the listed tools. Give only public knowledge reads public access. Keep account tools separate.
- Save the connection. In Knowledge, tools, and behavior, select it, save the draft, run preflight and publish. A saved connector alone is not live.
- Ask representative questions through your assistant. Check that its calls reach your server and its citations match your tool results.
Management tools: probe_mcp_connector, upsert_tenant_connector, publish_tenant_runtime. Preserve other settings and verify runtime publication.
Keep existing crawled knowledge until tool coverage is verified. Discovery alone does not prove crawling stopped. Check serving status before changing sync settings; never delete knowledge to pass a check.
Preview before connecting
/try/<your-host> reads discovery and the anonymous catalog without executing tools. Declared public reads and read-only candidates are offered; explicit OAuth and writes are excluded. Candidate access is established by a real requested read. An authentication refusal never triggers credential fallback.
Knowledge tools replace deeper collection; business-only tools do not establish knowledge coverage. Existing assistants keep their published assignments. Previews do not grant workspace access.
Supported Apps use the existing sandbox: declare _meta.ui.resourceUri with a ui:// URI. Callbacks require current anonymous read-only eligibility, "app" visibility, and the same authenticated preview source. For an owned saved preview, reloading can revalidate expired widget access against the current site without rerunning the original tool. If current eligibility cannot be verified, the App remains unavailable.
5. Keep account tools behind sign-in
Keep account tools behind OAuth. An anonymous 401 never grants access. For signed-in customers, use Connect your MCP server as assistant tools, and give those tools the identified or delegated access level.
6. Secure the public set
- Public data only. Index only pages that are already published.
- Rate limits. Limit requests per client IP, cap
limitand the response size, and answer429withRetry-Afterwhen a client goes over. - No writes. Keep write tools off the public server, or put them on a separate endpoint behind OAuth. Never mark a write as read-only.
- Transport and CORS. Busymate AI calls your server from its own servers over HTTPS and needs no CORS. Add CORS only for browser clients, and validate the
Originheader as the MCP spec describes.
On WordPress, advertise knowledge through a server card or Link header; the plugin owns mcp.json. For Shopify, see the guide.
Verify
- Check the server with the MCP Inspector:
# 1. What tools does the server list, with which annotations?
npx @modelcontextprotocol/inspector@2.12.0 --cli https://docs.example.com/mcp --transport http --method tools/list
# 2. Does a search answer WITHOUT any credentials, with page URLs?
npx @modelcontextprotocol/inspector@2.12.0 --cli https://docs.example.com/mcp --transport http \
--method tools/call --tool-name search_docs --tool-arg query=refunds
# 3. Is the discovery file served as JSON?
curl -sS -H "accept: application/json" https://docs.example.com/.well-known/mcp.json- The tool list shows your tools with
readOnlyHint: true, and the search answers with no credentials, with page URLs. - The published workspace includes your connection, and MCP servers lists its knowledge tools with public access.
- Ask your mate a question only your docs answer. The reply cites a URL your tool returned.
| Symptom | Cause |
|---|---|
| Tools are saved but unavailable | The connection is not selected in the published workspace, or projection is not ready |
| Connected, but nothing is public | Tools lack read-only metadata, require authentication, or are unavailable |
| One tool is missing | It declares authentication or a write operation |
| Answers have no links | Results do not include absolute page URLs |
Next
- Teach your assistant your own content: crawled sites and published text.
- Connect your MCP server as assistant tools: access levels for account tools.
- Connecting your systems (MCP): the explanation behind both.
질문
Do I need a Website crawl source?
No. Connect and publish your knowledge MCP directly. For an existing crawled source, verify equivalent answers and citations before changing its sync settings. Discovery alone does not prove a working replacement.
Which MCP protocol version is used?
Streamable HTTP, protocol
2025-06-18. JSON and event-stream responses both work, and so do stateless servers and servers with sessions.Can visitors reach my account tools this way?
No. Preview calls are anonymous and read-only; candidates may refuse access. Account tools require customer sign-in through step 5.
Does publishing knowledge tools grant account access?
No. Keep account tools behind their own authentication and consent. Never mark customer data as public knowledge.