Skip to content

Commit e831972

Browse files
authored
Move internal MCP server to backend, use Mintlify MCP for docs tools (#1389)
## Summary - Move the `/api/internal/[transport]` MCP route from the docs app to the backend, so the public `ask_stack_auth` MCP tool is served from the same origin as the AI query API it proxies to. - Replace the bespoke docs-tools HTTP client in `apps/backend/src/lib/ai/tools/docs.ts` with an `@ai-sdk/mcp` client that talks to Mintlify's generated MCP server. The backend AI agent now consumes Mintlify's lower-level search/fetch tools directly instead of going through the docs app. - Swap `STACK_DOCS_INTERNAL_BASE_URL` for `STACK_MINTLIFY_MCP_URL` (defaults to the Mintlify-hosted MCP URL). - Move the `@vercel/mcp-adapter` dependency from `docs` to `apps/backend`. ## Test plan - [ ] `pnpm typecheck` - [ ] `pnpm lint` - [ ] e2e: new `apps/e2e/tests/backend/endpoints/api/v1/internal/mcp.test.ts` covers `tools/list` and validation on `tools/call` - [ ] Manual: hit `POST /api/internal/mcp` on the backend and confirm `ask_stack_auth` is listed and callable - [ ] Manual: confirm backend AI agent docs tools resolve via the Mintlify MCP URL <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Backend docs tooling now uses a Mintlify MCP server for documentation tools and discovery. * **Chores** * Development environment variables updated to point to the Mintlify MCP endpoint. * Backend dependency added to support MCP integration; docs package dependency removed. * **Tests** * Added end-to-end tests for the internal MCP endpoint and tool validation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent ed89610 commit e831972

8 files changed

Lines changed: 197 additions & 175 deletions

File tree

apps/backend/.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ STACK_TELEGRAM_BOT_TOKEN= # enter you telegram bot token
117117
STACK_TELEGRAM_CHAT_ID=# enter your telegram chat id
118118

119119
# Docs AI tool bundle
120-
STACK_DOCS_INTERNAL_BASE_URL=# override the docs origin used by the backend's AI tool bundle to call the docs app's `/api/internal/docs-tools` endpoint. Defaults to http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04 in dev, https://mcp.stack-auth.com in prod
120+
STACK_MINTLIFY_MCP_URL=# override the Mintlify MCP server used by the backend's AI docs tool bundle. Defaults to https://stackauth-e0affa27.mintlify.app/mcp
121121

122122
# MCP review tool (SpacetimeDB)
123123
STACK_SPACETIMEDB_URI=# SpacetimeDB host URI; default empty (logging disabled)

apps/backend/.env.development

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
7878
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
7979
STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION
8080
STACK_FEEDBACK_MODE=FORWARD_TO_PRODUCTION
81-
# STACK_DOCS_INTERNAL_BASE_URL=http://localhost:8104
81+
STACK_MINTLIFY_MCP_URL=https://stackauth-e0affa27.mintlify.app/mcp
8282
# Email monitor configuration for tests
8383
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification
8484
STACK_EMAIL_MONITOR_PROJECT_ID=internal

apps/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"@stackframe/stack-shared": "workspace:*",
8888
"@upstash/qstash": "^2.8.2",
8989
"@vercel/functions": "^2.0.0",
90+
"@vercel/mcp-adapter": "^1.0.0",
9091
"@vercel/otel": "^1.10.4",
9192
"@vercel/sandbox": "^1.2.0",
9293
"ai": "^6.0.0",

docs/src/app/api/internal/[transport]/route.ts renamed to apps/backend/src/app/api/internal/[transport]/route.ts

Lines changed: 21 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
12
import { createMcpHandler } from "@vercel/mcp-adapter";
2-
import { PostHog } from "posthog-node";
33
import { z } from "zod";
44

5-
const nodeClient = process.env.NEXT_PUBLIC_POSTHOG_KEY
6-
? new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY)
7-
: null;
5+
import withPostHog from "@/analytics";
6+
7+
function getBackendApiBaseUrl(): string {
8+
return (
9+
getEnvVariable("NEXT_PUBLIC_SERVER_STACK_API_URL", "") ||
10+
getEnvVariable("NEXT_PUBLIC_STACK_API_URL")
11+
).replace(/\/$/, "");
12+
}
813

914
const handler = createMcpHandler(
1015
async (server) => {
@@ -29,26 +34,19 @@ const handler = createMcpHandler(
2934
.string()
3035
.optional()
3136
.describe(
32-
"Pass the conversationId from a previous response to group related calls into the same conversation. Omit on the first call the server will generate one and return it.",
37+
"Pass the conversationId from a previous response to group related calls into the same conversation. Omit on the first call - the server will generate one and return it.",
3338
),
3439
},
3540
async ({ question, reason, userPrompt, conversationId }) => {
36-
nodeClient?.capture({
37-
event: "ask_stack_auth_mcp",
38-
properties: { question, reason },
39-
distinctId: "mcp-handler",
41+
await withPostHog(async (posthog) => {
42+
posthog.capture({
43+
event: "ask_stack_auth_mcp",
44+
properties: { question, reason },
45+
distinctId: "mcp-handler",
46+
});
4047
});
4148

42-
const apiBase = process.env.NEXT_PUBLIC_STACK_API_URL;
43-
if (apiBase == null || apiBase === "") {
44-
return {
45-
content: [{ type: "text", text: "NEXT_PUBLIC_STACK_API_URL is not configured on the docs server." }],
46-
isError: true,
47-
};
48-
}
49-
50-
const url = `${apiBase.replace(/\/$/, "")}/api/latest/ai/query/generate`;
51-
const res = await fetch(url, {
49+
const res = await fetch(`${getBackendApiBaseUrl()}/api/latest/ai/query/generate`, {
5250
method: "POST",
5351
headers: { "Content-Type": "application/json" },
5452
body: JSON.stringify({
@@ -86,44 +84,15 @@ const handler = createMcpHandler(
8684
const responseConversationId = body.conversationId ?? conversationId ?? "";
8785

8886
return {
89-
content: [{ type: "text", text: `${text.length > 0 ? text : "(empty response)"}\n\n[conversationId: ${responseConversationId} pass this value as the conversationId parameter in your next ask_stack_auth call to continue this conversation]` }],
87+
content: [{ type: "text", text: `${text.length > 0 ? text : "(empty response)"}\n\n[conversationId: ${responseConversationId} - pass this value as the conversationId parameter in your next ask_stack_auth call to continue this conversation]` }],
9088
};
9189
},
9290
);
9391
},
9492
{
95-
capabilities: {
96-
tools: {
97-
ask_stack_auth: {
98-
description:
99-
"Ask the Stack Auth documentation assistant any question about Stack Auth (setup, APIs, SDKs, configuration, troubleshooting).",
100-
parameters: {
101-
type: "object",
102-
properties: {
103-
question: {
104-
type: "string",
105-
description: "The full question to ask about Stack Auth.",
106-
},
107-
reason: {
108-
type: "string",
109-
description:
110-
"Why the agent invoked this tool (for analytics and debugging). Not sent to the documentation model.",
111-
},
112-
userPrompt: {
113-
type: "string",
114-
description:
115-
"The original user message/prompt that triggered this tool call. Copy the user's exact words.",
116-
},
117-
conversationId: {
118-
type: "string",
119-
description:
120-
"Pass the conversationId from a previous response to group related calls. Omit on first call.",
121-
},
122-
},
123-
required: ["question", "reason", "userPrompt"],
124-
},
125-
},
126-
},
93+
serverInfo: {
94+
name: "stack-auth-mcp",
95+
version: "0.1.0",
12796
},
12897
},
12998
{
Lines changed: 34 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,134 +1,51 @@
1-
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
1+
import { createMCPClient, type MCPClient } from "@ai-sdk/mcp";
2+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
23
import { captureError } from "@stackframe/stack-shared/dist/utils/errors";
34
import { tool } from "ai";
45
import { z } from "zod";
56

6-
type DocsToolHttpResult = {
7-
content?: Array<{ type: string, text?: string }>,
8-
isError?: boolean,
9-
};
7+
let mintlifyMcpClientPromise: Promise<MCPClient> | null = null;
108

11-
function getDocsToolsBaseUrl(): string {
12-
const fromEnv = getEnvVariable("STACK_DOCS_INTERNAL_BASE_URL", "");
13-
if (fromEnv !== "") {
14-
return fromEnv.replace(/\/$/, "");
15-
}
16-
if (getNodeEnvironment() === "development") {
17-
const portPrefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81");
18-
return `http://localhost:${portPrefix}26`;
19-
}
20-
return "https://mcp.stack-auth.com";
9+
function getMintlifyMcpUrl(): string {
10+
return getEnvVariable("STACK_MINTLIFY_MCP_URL", "https://stackauth-e0affa27.mintlify.app/mcp");
2111
}
2212

23-
async function postDocsToolAction(action: Record<string, unknown>): Promise<string> {
24-
const base = getDocsToolsBaseUrl();
25-
try {
26-
const res = await fetch(`${base}/api/internal/docs-tools`, {
27-
method: "POST",
28-
headers: {
29-
"Content-Type": "application/json",
30-
// MCP-style JSON-RPC endpoint requires clients to advertise both JSON and SSE.
31-
Accept: "application/json, text/event-stream",
13+
async function getMintlifyMcpClient(): Promise<MCPClient> {
14+
if (mintlifyMcpClientPromise == null) {
15+
mintlifyMcpClientPromise = createMCPClient({
16+
transport: {
17+
type: "http",
18+
url: getMintlifyMcpUrl(),
3219
},
33-
body: JSON.stringify(action),
20+
name: "stack-auth-backend-docs-agent",
21+
}).catch((err: unknown) => {
22+
mintlifyMcpClientPromise = null;
23+
throw err;
3424
});
35-
36-
if (!res.ok) {
37-
const errBody = await res.text();
38-
captureError("docs-tools-http-error", new Error(`Stack Auth docs tools error (${res.status}): ${errBody}`));
39-
return "Stack Auth docs tools returned an error. Please try again later.";
40-
}
41-
42-
const data = (await res.json()) as DocsToolHttpResult;
43-
const text = data.content
44-
?.filter((c): c is { type: "text", text: string } => c.type === "text" && typeof c.text === "string")
45-
.map((c) => c.text)
46-
.join("\n") ?? "";
47-
48-
if (data.isError === true) {
49-
return text || "Unknown docs tool error";
50-
}
51-
52-
return text;
53-
} catch (err) {
54-
captureError("docs-tools-transport-error", err instanceof Error ? err : new Error(String(err)));
55-
return "Stack Auth docs tools are temporarily unavailable. Please try again later.";
5625
}
26+
27+
return await mintlifyMcpClientPromise;
5728
}
5829

5930
/**
60-
* Documentation tools backed by the docs app's `/api/internal/docs-tools` endpoint.
61-
*
62-
* The public MCP server at the same docs origin exposes only `ask_stack_auth`, which proxies to
63-
* `/api/latest/ai/query/generate`; these tools avoid MCP recursion by calling the HTTP API directly.
31+
* Documentation tools backed by Mintlify's generated MCP server.
32+
* The public Stack Auth MCP server still exposes the higher-level `ask_stack_auth` tool;
33+
* that agent uses these lower-level Mintlify tools for search and page reads.
6434
*/
6535
export async function createDocsTools() {
66-
return {
67-
list_available_docs: tool({
68-
description:
69-
"Use this tool to learn about what Stack Auth is, available documentation, and see if you can use it for what you're working on. It returns a list of all available Stack Auth Documentation pages.",
70-
inputSchema: z.object({}),
71-
execute: async () => {
72-
return await postDocsToolAction({ action: "list_available_docs" });
73-
},
74-
}),
75-
76-
search_docs: tool({
77-
description:
78-
"Search through all Stack Auth documentation including API docs, guides, and examples. Returns ranked results with snippets and relevance scores.",
79-
inputSchema: z.object({
80-
search_query: z.string().describe("The search query to find relevant documentation"),
81-
result_limit: z.number().optional().describe("Maximum number of results to return (default: 50)"),
82-
}),
83-
execute: async ({ search_query, result_limit = 50 }) => {
84-
return await postDocsToolAction({
85-
action: "search_docs",
86-
search_query,
87-
result_limit,
88-
});
89-
},
90-
}),
91-
92-
get_docs_by_id: tool({
93-
description:
94-
"Use this tool to retrieve a specific Stack Auth Documentation page by its ID. It gives you the full content of the page so you can know exactly how to use specific Stack Auth APIs. Whenever using Stack Auth, you should always check the documentation first to have the most up-to-date information. When you write code using Stack Auth documentation you should reference the content you used in your comments.",
95-
inputSchema: z.object({
96-
id: z.string(),
97-
}),
98-
execute: async ({ id }) => {
99-
return await postDocsToolAction({ action: "get_docs_by_id", id });
100-
},
101-
}),
102-
103-
get_stack_auth_setup_instructions: tool({
104-
description:
105-
"Use this tool when the user wants to set up authentication in a new project. It provides step-by-step instructions for installing and configuring Stack Auth authentication.",
106-
inputSchema: z.object({}),
107-
execute: async () => {
108-
return await postDocsToolAction({ action: "get_stack_auth_setup_instructions" });
109-
},
110-
}),
111-
112-
search: tool({
113-
description:
114-
"Search for Stack Auth documentation pages.\n\nUse this tool to find documentation pages that contain a specific keyword or phrase.",
115-
inputSchema: z.object({
116-
query: z.string(),
117-
}),
118-
execute: async ({ query }) => {
119-
return await postDocsToolAction({ action: "search", query });
120-
},
121-
}),
122-
123-
fetch: tool({
124-
description:
125-
"Fetch a particular Stack Auth Documentation page by its ID.\n\nThis tool is identical to `get_docs_by_id`.",
126-
inputSchema: z.object({
127-
id: z.string(),
36+
try {
37+
const client = await getMintlifyMcpClient();
38+
return await client.tools();
39+
} catch (error) {
40+
captureError("mintlify-mcp-docs-tools", error);
41+
return {
42+
docsUnavailable: tool({
43+
description: "Report that the Stack Auth documentation search tools are currently unavailable.",
44+
inputSchema: z.object({}),
45+
execute: async () => ({
46+
error: "Stack Auth documentation search is temporarily unavailable. Please try again later.",
47+
}),
12848
}),
129-
execute: async ({ id }) => {
130-
return await postDocsToolAction({ action: "fetch", id });
131-
},
132-
}),
133-
};
49+
};
50+
}
13451
}

0 commit comments

Comments
 (0)