Skip to content

Commit eee2398

Browse files
committed
fix(docs): stabilize ai chat tool handling
1 parent fe67b01 commit eee2398

File tree

3 files changed

+145
-33
lines changed

3 files changed

+145
-33
lines changed

docs/app/api/chat/route.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createOpenAI } from "@ai-sdk/openai";
2-
import { stepCountIs, streamText } from "ai";
2+
import { convertToModelMessages, safeValidateUIMessages, stepCountIs, streamText } from "ai";
33
import { systemPrompt } from "@/lib/ai/system-prompt";
44
import { clientTools } from "@/lib/ai/tools";
55
import { getMCPTools } from "@/lib/ai/mcp-client";
@@ -15,16 +15,10 @@ const llmRouter = createOpenAI({
1515
name: "llm-router",
1616
});
1717

18+
const llmRouterModel = process.env.LLM_ROUTER_MODEL?.trim() || "openai/gpt-4o";
19+
1820
const chatRequestSchema = z.object({
19-
messages: z
20-
.array(
21-
z
22-
.object({
23-
role: z.string().min(1),
24-
})
25-
.passthrough(),
26-
)
27-
.min(1),
21+
messages: z.array(z.unknown()).min(1),
2822
});
2923

3024
export async function POST(req: Request) {
@@ -40,18 +34,30 @@ export async function POST(req: Request) {
4034
return Response.json({ error: "Invalid request body" }, { status: 400 });
4135
}
4236

43-
const messages = parsed.data.messages as Parameters<typeof streamText>[0]["messages"];
44-
4537
const mcpTools = await getMCPTools();
38+
const tools = {
39+
...clientTools,
40+
...mcpTools,
41+
};
42+
43+
const validatedMessages = await safeValidateUIMessages({
44+
messages: parsed.data.messages,
45+
});
46+
47+
if (!validatedMessages.success) {
48+
return Response.json({ error: "Invalid messages format" }, { status: 400 });
49+
}
50+
51+
const messages = await convertToModelMessages(validatedMessages.data, {
52+
tools,
53+
ignoreIncompleteToolCalls: true,
54+
});
4655

4756
const result = streamText({
48-
model: llmRouter(process.env.LLM_ROUTER_MODEL ?? "openai/gpt-4o"),
57+
model: llmRouter(llmRouterModel),
4958
system: systemPrompt,
5059
messages,
51-
tools: {
52-
...clientTools,
53-
...mcpTools,
54-
},
60+
tools,
5561
stopWhen: stepCountIs(5),
5662
});
5763

docs/lib/ai/mcp-client.ts

Lines changed: 113 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,41 +20,108 @@ interface MCPToolDefinition {
2020

2121
interface JSONRPCResponse {
2222
jsonrpc: string;
23-
id: number;
23+
id: number | string | null;
2424
result?: {
2525
tools?: MCPToolDefinition[];
2626
content?: Array<{ type: string; text?: string }>;
2727
};
2828
error?: { code: number; message: string };
2929
}
3030

31+
interface JSONRPCRequest {
32+
jsonrpc: "2.0";
33+
method: string;
34+
params?: Record<string, unknown>;
35+
id?: number;
36+
}
37+
38+
const MCP_PROTOCOL_VERSION = "2025-03-26";
39+
3140
let cachedSessionId: string | null = null;
41+
let isInitialized = false;
42+
let initializingPromise: Promise<void> | null = null;
43+
44+
function parseSSEJSONResponse(rawBody: string, method: string): JSONRPCResponse {
45+
const events = rawBody.split(/\r?\n\r?\n/);
46+
47+
for (const eventBlock of events) {
48+
const dataLines = eventBlock
49+
.split(/\r?\n/)
50+
.filter((line) => line.startsWith("data:"))
51+
.map((line) => line.slice(5).trimStart())
52+
.filter(Boolean);
53+
54+
if (dataLines.length === 0) {
55+
continue;
56+
}
57+
58+
const payload = dataLines.join("\n").trim();
59+
if (!payload || payload === "[DONE]") {
60+
continue;
61+
}
62+
63+
try {
64+
return JSON.parse(payload) as JSONRPCResponse;
65+
} catch {
66+
// ignore non-JSON SSE frames and continue
67+
}
68+
}
69+
70+
throw new Error(`Invalid MCP SSE response for "${method}"`);
71+
}
72+
73+
function parseMCPResponseBody(
74+
rawBody: string,
75+
method: string,
76+
contentType: string | null,
77+
): JSONRPCResponse {
78+
if (!rawBody.trim()) {
79+
return { jsonrpc: "2.0", id: null };
80+
}
81+
82+
if (contentType?.includes("text/event-stream") || rawBody.trimStart().startsWith("event:")) {
83+
return parseSSEJSONResponse(rawBody, method);
84+
}
85+
86+
try {
87+
return JSON.parse(rawBody) as JSONRPCResponse;
88+
} catch (error) {
89+
throw new Error(`Invalid MCP JSON response for "${method}": ${(error as Error).message}`);
90+
}
91+
}
3292

3393
async function mcpRequest(
3494
method: string,
3595
params: Record<string, unknown> = {},
96+
options: { notification?: boolean } = {},
3697
): Promise<JSONRPCResponse> {
3798
const url = process.env.SEED_DOCS_MCP_SERVER_URL;
3899
if (!url) throw new Error("SEED_DOCS_MCP_SERVER_URL is not set");
39100

40101
const headers: Record<string, string> = {
41102
"Content-Type": "application/json",
42-
Accept: "application/json",
103+
Accept: "application/json, text/event-stream",
104+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
43105
};
44106

45107
if (cachedSessionId) {
46108
headers["Mcp-Session-Id"] = cachedSessionId;
47109
}
48110

111+
const body: JSONRPCRequest = {
112+
jsonrpc: "2.0",
113+
method,
114+
params,
115+
};
116+
117+
if (!options.notification) {
118+
body.id = Date.now();
119+
}
120+
49121
const response = await fetch(url, {
50122
method: "POST",
51123
headers,
52-
body: JSON.stringify({
53-
jsonrpc: "2.0",
54-
id: Date.now(),
55-
method,
56-
params,
57-
}),
124+
body: JSON.stringify(body),
58125
});
59126

60127
// 세션 ID 저장
@@ -63,24 +130,55 @@ async function mcpRequest(
63130
cachedSessionId = sessionId;
64131
}
65132

133+
const rawBody = await response.text();
134+
const contentType = response.headers.get("content-type");
135+
66136
if (!response.ok) {
67-
throw new Error(`MCP request failed: ${response.status} ${response.statusText}`);
137+
const details = rawBody.trim();
138+
const suffix = details ? ` - ${details.slice(0, 300)}` : "";
139+
throw new Error(`MCP request failed: ${response.status} ${response.statusText}${suffix}`);
68140
}
69141

70-
return response.json();
142+
// notification 응답 혹은 빈 본문(202/204) 처리
143+
if (options.notification || !rawBody.trim()) {
144+
return { jsonrpc: "2.0", id: null };
145+
}
146+
147+
return parseMCPResponseBody(rawBody, method, contentType);
148+
}
149+
150+
async function mcpNotification(
151+
method: string,
152+
params: Record<string, unknown> = {},
153+
): Promise<void> {
154+
await mcpRequest(method, params, { notification: true });
71155
}
72156

73157
/**
74158
* MCP 서버 초기화 (initialize + initialized 핸드셰이크)
75159
*/
76160
async function initializeMCP(): Promise<void> {
77-
await mcpRequest("initialize", {
78-
protocolVersion: "2025-03-26",
79-
capabilities: {},
80-
clientInfo: { name: "seed-docs-ai", version: "1.0.0" },
161+
if (isInitialized) return;
162+
if (initializingPromise) return initializingPromise;
163+
164+
initializingPromise = (async () => {
165+
const initResponse = await mcpRequest("initialize", {
166+
protocolVersion: MCP_PROTOCOL_VERSION,
167+
capabilities: {},
168+
clientInfo: { name: "seed-docs-ai", version: "1.0.0" },
169+
});
170+
171+
if (initResponse.error) {
172+
throw new Error(`MCP initialize failed: ${initResponse.error.message}`);
173+
}
174+
175+
await mcpNotification("notifications/initialized");
176+
isInitialized = true;
177+
})().finally(() => {
178+
initializingPromise = null;
81179
});
82180

83-
await mcpRequest("notifications/initialized");
181+
return initializingPromise;
84182
}
85183

86184
/**

docs/lib/ai/tools.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { tool } from "ai";
22
import { z } from "zod";
33

44
/**
5-
* 클라이언트사이드 도구: execute 함수 없음 → useChat에서 UI로 렌더링
5+
* 채팅 UI 렌더링용 도구.
6+
* 서버에서도 execute를 제공해 tool result가 누락되지 않도록 한다.
67
*/
78
export const clientTools = {
89
showComponentExample: tool({
@@ -21,6 +22,7 @@ export const clientTools = {
2122
'Component example path, e.g., "react/action-button/preview", "react/checkbox/preview"',
2223
),
2324
}),
25+
execute: async () => ({ shown: true }),
2426
}),
2527

2628
showInstallation: tool({
@@ -36,6 +38,7 @@ export const clientTools = {
3638
)
3739
.describe('Component name in kebab-case, e.g., "action-button", "checkbox", "tabs"'),
3840
}),
41+
execute: async ({ name }) => ({ shown: true, component: name }),
3942
}),
4043

4144
showCodeBlock: tool({
@@ -45,5 +48,10 @@ export const clientTools = {
4548
language: z.string().default("tsx").describe("Programming language"),
4649
title: z.string().optional().describe("Optional title for the code block"),
4750
}),
51+
execute: async ({ language, title }) => ({
52+
shown: true,
53+
language,
54+
hasTitle: Boolean(title),
55+
}),
4856
}),
4957
};

0 commit comments

Comments
 (0)