Skip to content

Commit f7ba084

Browse files
feat(web): add MCP HTTP endpoint with Streamable HTTP transport (#976)
* feat(web): add MCP HTTP endpoint with Streamable HTTP transport (SOU-263) - Add /api/mcp route with WebStandardStreamableHTTPServerTransport supporting SSE and JSON responses - Add Bearer token auth support to getAuthenticatedUser for programmatic MCP clients - Add session ownership validation to prevent session hijacking (per MCP security spec) - Extract chat utils to utils.server.ts to fix 'use server' module boundary violations - Add ask_codebase, search_code, read_file, list_repos, list_commits, list_tree, list_language_models MCP tools - Add withAuthV2 tests for Bearer token authentication flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * add method to apiHandler * changelog * feedback * feedback --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d9d5b2a commit f7ba084

File tree

26 files changed

+1800
-862
lines changed

26 files changed

+1800
-862
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Added support a MCP streamable http transport hosted at `/api/mcp`. [#976](https://github.com/sourcebot-dev/sourcebot/pull/976)
12+
1013
## [4.13.2] - 2026-03-02
1114

1215
### Changed

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@hookform/resolvers": "^3.9.0",
6060
"@iconify/react": "^5.1.0",
6161
"@iizukak/codemirror-lang-wgsl": "^0.3.0",
62+
"@modelcontextprotocol/sdk": "^1.27.1",
6263
"@openrouter/ai-sdk-provider": "^2.2.3",
6364
"@opentelemetry/api-logs": "^0.203.0",
6465
"@opentelemetry/instrumentation": "^0.203.0",

packages/web/src/app/[domain]/askgh/[owner]/[repo]/page.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { addGithubRepo } from "@/features/workerApi/actions";
2-
import { isServiceError, unwrapServiceError } from "@/lib/utils";
2+
import { isServiceError } from "@/lib/utils";
33
import { ServiceErrorException } from "@/lib/serviceError";
44
import { prisma } from "@/prisma";
55
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
66
import { getRepoInfo } from "./api";
77
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
88
import { RepoIndexedGuard } from "./components/repoIndexedGuard";
99
import { LandingPage } from "./components/landingPage";
10-
import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
10+
import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server";
1111
import { auth } from "@/auth";
1212

1313
interface PageProps {
@@ -45,8 +45,12 @@ export default async function GitHubRepoPage(props: PageProps) {
4545
return response.repoId;
4646
})();
4747

48-
const repoInfo = await unwrapServiceError(getRepoInfo(repoId));
49-
const languageModels = await unwrapServiceError(getConfiguredLanguageModelsInfo());
48+
const repoInfo = await getRepoInfo(repoId)
49+
const languageModels = await getConfiguredLanguageModelsInfo()
50+
51+
if (isServiceError(repoInfo)) {
52+
throw new ServiceErrorException(repoInfo);
53+
}
5054

5155
return (
5256
<RepoIndexedGuard initialRepoInfo={repoInfo}>

packages/web/src/app/[domain]/browse/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { auth } from "@/auth";
22
import { LayoutClient } from "./layoutClient";
3-
import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
3+
import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server";
44

55
interface LayoutProps {
66
children: React.ReactNode;

packages/web/src/app/[domain]/chat/[id]/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getRepos, getSearchContexts } from '@/actions';
2-
import { getUserChatHistory, getConfiguredLanguageModelsInfo, getChatInfo, claimAnonymousChats, getSharedWithUsersForChat } from '@/features/chat/actions';
2+
import { getUserChatHistory, getChatInfo, claimAnonymousChats, getSharedWithUsersForChat } from '@/features/chat/actions';
3+
import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server";
34
import { ServiceErrorException } from '@/lib/serviceError';
45
import { isServiceError } from '@/lib/utils';
56
import { ChatThreadPanel } from './components/chatThreadPanel';

packages/web/src/app/[domain]/chat/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getRepos, getReposStats, getSearchContexts } from "@/actions";
22
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
3-
import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions";
3+
import { getUserChatHistory } from "@/features/chat/actions";
4+
import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server";
45
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
56
import { ServiceErrorException } from "@/lib/serviceError";
67
import { isServiceError, measure } from "@/lib/utils";

packages/web/src/app/[domain]/search/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { env } from "@sourcebot/shared";
22
import { SearchLandingPage } from "./components/searchLandingPage";
33
import { SearchResultsPage } from "./components/searchResultsPage";
44
import { auth } from "@/auth";
5-
import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
5+
import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server";
66

77
interface SearchPageProps {
88
params: Promise<{ domain: string }>;
Lines changed: 8 additions & 221 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,11 @@
1-
import { sew } from "@/actions";
2-
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, _generateChatNameFromMessage } from "@/features/chat/actions";
3-
import { LanguageModelInfo, languageModelInfoSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
4-
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
5-
import { ErrorCode } from "@/lib/errorCodes";
6-
import { requestBodySchemaValidationError, ServiceError, ServiceErrorException, serviceErrorResponse } from "@/lib/serviceError";
1+
import { askCodebase } from "@/features/mcp/askCodebase";
2+
import { languageModelInfoSchema } from "@/features/chat/types";
3+
import { apiHandler } from "@/lib/apiHandler";
4+
import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
75
import { isServiceError } from "@/lib/utils";
8-
import { withOptionalAuthV2 } from "@/withAuthV2";
9-
import { ChatVisibility, Prisma } from "@sourcebot/db";
10-
import { createLogger, env } from "@sourcebot/shared";
11-
import { randomUUID } from "crypto";
12-
import { StatusCodes } from "http-status-codes";
6+
import { ChatVisibility } from "@sourcebot/db";
137
import { NextRequest, NextResponse } from "next/server";
148
import { z } from "zod";
15-
import { createMessageStream } from "../route";
16-
import { InferUIMessageChunk, UITools, UIDataTypes, UIMessage } from "ai";
17-
import { apiHandler } from "@/lib/apiHandler";
18-
import { captureEvent } from "@/lib/posthog";
19-
20-
const logger = createLogger('chat-blocking-api');
219

2210
/**
2311
* Request schema for the blocking chat API.
@@ -40,22 +28,12 @@ const blockingChatRequestSchema = z.object({
4028
.describe("The visibility of the chat session. If not provided, defaults to PRIVATE for authenticated users and PUBLIC for anonymous users. Set to PUBLIC to make the chat viewable by anyone with the link. Note: Anonymous users cannot create PRIVATE chats; any PRIVATE request from an unauthenticated user will be ignored and set to PUBLIC."),
4129
});
4230

43-
/**
44-
* Response schema for the blocking chat API.
45-
*/
46-
interface BlockingChatResponse {
47-
answer: string;
48-
chatId: string;
49-
chatUrl: string;
50-
languageModel: LanguageModelInfo;
51-
}
52-
5331
/**
5432
* POST /api/chat/blocking
55-
*
33+
*
5634
* A blocking (non-streaming) chat endpoint designed for MCP and other integrations.
5735
* Creates a chat session, runs the agent to completion, and returns the final answer.
58-
*
36+
*
5937
* The chat session is persisted to the database, allowing users to view the full
6038
* conversation (including tool calls and reasoning) in the web UI.
6139
*/
@@ -67,202 +45,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
6745
return serviceErrorResponse(requestBodySchemaValidationError(parsed.error));
6846
}
6947

70-
const { query, repos = [], languageModel: requestedLanguageModel, visibility: requestedVisibility } = parsed.data;
71-
72-
const response: BlockingChatResponse | ServiceError = await sew(() =>
73-
withOptionalAuthV2(async ({ org, user, prisma }) => {
74-
// Get all configured language models
75-
const configuredModels = await _getConfiguredLanguageModelsFull();
76-
if (configuredModels.length === 0) {
77-
return {
78-
statusCode: StatusCodes.BAD_REQUEST,
79-
errorCode: ErrorCode.INVALID_REQUEST_BODY,
80-
message: "No language models are configured. Please configure at least one language model. See: https://docs.sourcebot.dev/docs/configuration/language-model-providers",
81-
} satisfies ServiceError;
82-
}
83-
84-
// Use the requested language model if provided, otherwise default to the first configured model
85-
let languageModelConfig = configuredModels[0];
86-
if (requestedLanguageModel) {
87-
const matchingModel = configuredModels.find(
88-
(m) => getLanguageModelKey(m) === getLanguageModelKey(requestedLanguageModel as LanguageModelInfo)
89-
);
90-
if (!matchingModel) {
91-
return {
92-
statusCode: StatusCodes.BAD_REQUEST,
93-
errorCode: ErrorCode.INVALID_REQUEST_BODY,
94-
message: `Language model '${requestedLanguageModel.provider}/${requestedLanguageModel.model}' is not configured.`,
95-
} satisfies ServiceError;
96-
}
97-
languageModelConfig = matchingModel;
98-
}
99-
100-
const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig);
101-
const modelName = languageModelConfig.displayName ?? languageModelConfig.model;
102-
103-
// Determine visibility: anonymous users cannot create private chats (they would be inaccessible)
104-
// Only use requested visibility if user is authenticated, otherwise always use PUBLIC
105-
const chatVisibility = (requestedVisibility && user)
106-
? requestedVisibility
107-
: (user ? ChatVisibility.PRIVATE : ChatVisibility.PUBLIC);
108-
109-
// Create a new chat session
110-
const chat = await prisma.chat.create({
111-
data: {
112-
orgId: org.id,
113-
createdById: user?.id,
114-
visibility: chatVisibility,
115-
messages: [] as unknown as Prisma.InputJsonValue,
116-
},
117-
});
118-
119-
await captureEvent('wa_chat_thread_created', {
120-
chatId: chat.id,
121-
isAnonymous: !user,
122-
});
123-
124-
// Run the agent to completion
125-
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
126-
chatId: chat.id,
127-
query: query.substring(0, 100),
128-
model: modelName,
129-
});
130-
131-
// Create the initial user message
132-
const userMessage: SBChatMessage = {
133-
id: randomUUID(),
134-
role: 'user',
135-
parts: [{ type: 'text', text: query }],
136-
};
137-
138-
const selectedRepos = (await Promise.all(repos.map(async (repo) => {
139-
const repoDB = await prisma.repo.findFirst({
140-
where: {
141-
name: repo,
142-
},
143-
});
144-
145-
if (!repoDB) {
146-
throw new ServiceErrorException({
147-
statusCode: StatusCodes.BAD_REQUEST,
148-
errorCode: ErrorCode.INVALID_REQUEST_BODY,
149-
message: `Repository '${repo}' not found.`,
150-
})
151-
}
152-
153-
return {
154-
type: 'repo',
155-
value: repoDB.name,
156-
name: repoDB.displayName ?? repoDB.name.split('/').pop() ?? repoDB.name,
157-
codeHostType: repoDB.external_codeHostType,
158-
} satisfies SearchScope;
159-
})));
160-
161-
// We'll capture the final messages and usage from the stream
162-
let finalMessages: SBChatMessage[] = [];
163-
164-
await captureEvent('wa_chat_message_sent', {
165-
chatId: chat.id,
166-
messageCount: 1,
167-
selectedReposCount: selectedRepos.length,
168-
...(env.EXPERIMENT_ASK_GH_ENABLED === 'true' ? {
169-
selectedRepos: selectedRepos.map(r => r.value)
170-
} : {}),
171-
});
172-
173-
const stream = await createMessageStream({
174-
chatId: chat.id,
175-
messages: [userMessage],
176-
metadata: {
177-
selectedSearchScopes: selectedRepos,
178-
},
179-
selectedRepos: selectedRepos.map(r => r.value),
180-
model,
181-
modelName,
182-
modelProviderOptions: providerOptions,
183-
onFinish: async ({ messages }) => {
184-
finalMessages = messages;
185-
},
186-
onError: (error) => {
187-
if (error instanceof ServiceErrorException) {
188-
throw error;
189-
}
190-
191-
const message = error instanceof Error ? error.message : String(error);
192-
throw new ServiceErrorException({
193-
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
194-
errorCode: ErrorCode.UNEXPECTED_ERROR,
195-
message,
196-
});
197-
},
198-
})
199-
200-
const [_, name] = await Promise.all([
201-
// Consume the stream fully to trigger onFinish
202-
blockStreamUntilFinish(stream),
203-
// Generate and update the chat name
204-
_generateChatNameFromMessage({
205-
message: query,
206-
languageModelConfig,
207-
})
208-
]);
209-
210-
// Persist the messages to the chat
211-
await _updateChatMessages({ chatId: chat.id, messages: finalMessages, prisma });
212-
213-
// Update the chat name
214-
await prisma.chat.update({
215-
where: {
216-
id: chat.id,
217-
orgId: org.id,
218-
},
219-
data: {
220-
name: name,
221-
},
222-
});
223-
224-
// Extract the answer text from the assistant message
225-
const assistantMessage = finalMessages.find(m => m.role === 'assistant');
226-
const answerPart = assistantMessage
227-
? getAnswerPartFromAssistantMessage(assistantMessage, false)
228-
: undefined;
229-
const answerText = answerPart?.text ?? '';
230-
231-
// Build the base URL and chat URL
232-
const baseUrl = env.AUTH_URL;
233-
234-
// Convert to portable markdown (replaces @file: references with markdown links)
235-
const portableAnswer = convertLLMOutputToPortableMarkdown(answerText, baseUrl);
236-
const chatUrl = `${baseUrl}/${org.domain}/chat/${chat.id}`;
237-
238-
logger.debug(`Completed blocking agent for chat ${chat.id}`, {
239-
chatId: chat.id,
240-
});
241-
242-
return {
243-
answer: portableAnswer,
244-
chatId: chat.id,
245-
chatUrl,
246-
languageModel: {
247-
provider: languageModelConfig.provider,
248-
model: languageModelConfig.model,
249-
displayName: languageModelConfig.displayName,
250-
},
251-
} satisfies BlockingChatResponse;
252-
})
253-
);
48+
const response = await askCodebase(parsed.data);
25449

25550
if (isServiceError(response)) {
25651
return serviceErrorResponse(response);
25752
}
25853

25954
return NextResponse.json(response);
26055
});
261-
262-
const blockStreamUntilFinish = async <T extends UIMessage<unknown, UIDataTypes, UITools>>(stream: ReadableStream<InferUIMessageChunk<T>>) => {
263-
const reader = stream.getReader();
264-
while (true as const) {
265-
const { done } = await reader.read();
266-
if (done) break;
267-
}
268-
}

0 commit comments

Comments
 (0)