Skip to content

Commit 4be401f

Browse files
brendan-kellamjsourcebotmsukkari
authored
ask mcp integration (#1209)
* Add ask MCP server integration * Merge MCP user server credentials * Inject Prisma into MCP OAuth provider * Scope MCP user server queries * Add org-approved MCP servers * feat(web): add workspace MCP configuration * fix(web): allow MCP cleanup without OAuth entitlement * feat(web): improve MCP server add flow * fix(web): check DCR for prefab MCP servers * feat(web): support static OAuth MCP credentials * feat(web): add more prefab MCP servers * fix(web): use official Atlassian MCP icons * fix(web): use Atlassian prefab MCP server * feat(web): connect approved MCP servers from chat * feat(web): redesign MCP servers settings page Rework the MCP servers page with a cleaner, more compact layout: - Split servers into Connected / Suggested sections - Add search bar with All / Connected filter tabs - Compact card design with smaller favicons, stripped URLs, quieter status indicators - Move Reconnect into three-dot overflow menu alongside new Disconnect option - Add disconnectMcpServer server action to remove a user's MCP credentials - Extract useConnectMcp hook for shared connect/reconnect logic * Rename MCP settings to Ask Agent connectors * feat(web): redesign workspace Ask Agent settings page Redesign the workspace Ask Agent page with card-based layout, 3-stat strip, connector rows with status indicators, kebab menu, and Connect button with OAuth flow. Extract shared ConnectorRowInfo component for reuse between workspace and account settings pages. * feat(web): add workspace connector config link to chat toolbar Add "Configure connectors" link to the chat toolbar's Connectors submenu, pointing to /settings/workspaceAskAgent for workspace-level connector management. Keep existing "Manage connectors" link to /settings/accountAskAgent for personal connector setup. * Add MCP connector tool metadata * feat(web): redesign MCP tools list as compact clickable badges Replace the full-card tool list with a compact badge grid. Clicking a badge reveals that tool's detail panel inline; only one detail is visible at a time. Also remove the focus ring from the tools trigger button. * refactor(web): extract shared ConnectorCard component Unify the duplicated card layout between AccountConnectedConnectorCard and WorkspaceConnectorCard into a single ConnectorCard component that owns the Collapsible state, Card shell, ConnectorRowInfo, and tool disclosure wiring. The divergent parts (status badge, action buttons) are passed as ReactNode slots. * Fix Ask approval turn progress state * Add MCP connector usage counters * Address MCP review feedback * Remove workspace Ask Agent connector summary cards * Add PostHog prefab MCP server * Add Ask MCP PostHog metrics * Add Ask MCP tool call analytics * Add Ask MCP connector lifecycle analytics * Fix v5 rebase follow-ups * Clean up Ask MCP deployment references * Move EE MCP feature under chat * Simplify static MCP OAuth HTTPS guard * docs: v5 docs updates (#1244) * [v5] feat(web): Add usage information for yearly subs (#1245) * schema * feat * fix build errors after merge * fix bug where Ask doesn't load if we dont have a license key * refactor(web): clean up MCP OAuth provider * Prisma Migrations * Address various review feedback --------- Co-authored-by: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Co-authored-by: Michael Sukkarieh <michael.sukkarieh@mail.mcgill.ca>
1 parent 4f1708f commit 4be401f

114 files changed

Lines changed: 12632 additions & 139 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
-- CreateEnum
2+
CREATE TYPE "McpServerClientInfoSource" AS ENUM ('DYNAMIC', 'STATIC');
3+
4+
-- CreateTable
5+
CREATE TABLE "McpServer" (
6+
"id" TEXT NOT NULL,
7+
"name" TEXT NOT NULL,
8+
"sanitizedName" TEXT NOT NULL,
9+
"serverUrl" TEXT NOT NULL,
10+
"clientInfo" TEXT,
11+
"clientInfoSource" "McpServerClientInfoSource" NOT NULL DEFAULT 'DYNAMIC',
12+
"orgId" INTEGER NOT NULL,
13+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14+
"updatedAt" TIMESTAMP(3) NOT NULL,
15+
16+
CONSTRAINT "McpServer_pkey" PRIMARY KEY ("id")
17+
);
18+
19+
-- CreateTable
20+
CREATE TABLE "McpServerToolCallCount" (
21+
"mcpServerId" TEXT NOT NULL,
22+
"toolName" TEXT NOT NULL,
23+
"count" INTEGER NOT NULL DEFAULT 0,
24+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
25+
"updatedAt" TIMESTAMP(3) NOT NULL,
26+
27+
CONSTRAINT "McpServerToolCallCount_pkey" PRIMARY KEY ("mcpServerId","toolName")
28+
);
29+
30+
-- CreateTable
31+
CREATE TABLE "UserMcpServer" (
32+
"userId" TEXT NOT NULL,
33+
"serverId" TEXT NOT NULL,
34+
"tokens" TEXT,
35+
"tokensExpiresAt" TIMESTAMP(3),
36+
"codeVerifier" TEXT,
37+
"state" TEXT,
38+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
39+
"updatedAt" TIMESTAMP(3) NOT NULL,
40+
41+
CONSTRAINT "UserMcpServer_pkey" PRIMARY KEY ("userId","serverId")
42+
);
43+
44+
-- CreateIndex
45+
CREATE UNIQUE INDEX "McpServer_serverUrl_orgId_key" ON "McpServer"("serverUrl", "orgId");
46+
47+
-- CreateIndex
48+
CREATE UNIQUE INDEX "McpServer_orgId_sanitizedName_key" ON "McpServer"("orgId", "sanitizedName");
49+
50+
-- CreateIndex
51+
CREATE INDEX "UserMcpServer_serverId_idx" ON "UserMcpServer"("serverId");
52+
53+
-- CreateIndex
54+
CREATE INDEX "UserMcpServer_state_idx" ON "UserMcpServer"("state");
55+
56+
-- AddForeignKey
57+
ALTER TABLE "McpServer" ADD CONSTRAINT "McpServer_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
58+
59+
-- AddForeignKey
60+
ALTER TABLE "McpServerToolCallCount" ADD CONSTRAINT "McpServerToolCallCount_mcpServerId_fkey" FOREIGN KEY ("mcpServerId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
61+
62+
-- AddForeignKey
63+
ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
64+
65+
-- AddForeignKey
66+
ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,8 @@ model Org {
294294
chats Chat[]
295295
repoVisits RepoVisit[]
296296
297+
mcpServers McpServer[]
298+
297299
license License?
298300
}
299301

@@ -340,6 +342,11 @@ enum OrgRole {
340342
MEMBER
341343
}
342344

345+
enum McpServerClientInfoSource {
346+
DYNAMIC
347+
STATIC
348+
}
349+
343350
model UserToOrg {
344351
joinedAt DateTime @default(now())
345352
@@ -422,6 +429,8 @@ model User {
422429
/// claim baked into the JWT cookie at mint time.
423430
sessionVersion Int @default(0)
424431
432+
userMcpServers UserMcpServer[]
433+
425434
createdAt DateTime @default(now())
426435
updatedAt DateTime @updatedAt
427436
@@ -656,3 +665,73 @@ model ChangelogEntry {
656665
657666
@@index([publishedAt])
658667
}
668+
669+
/// An external MCP server endpoint, unique per org.
670+
/// Stores the dynamic client registration (client_id/client_secret) once per org.
671+
model McpServer {
672+
id String @id @default(cuid())
673+
name String /// Org-approved display name (e.g., "Linear")
674+
sanitizedName String /// Stable tool-name prefix (e.g., "linear")
675+
serverUrl String /// MCP server endpoint (e.g., "https://mcp.linear.app/mcp")
676+
677+
/// Dynamic client registration result (RFC 7591) or admin-provided static OAuth client credentials.
678+
/// Encrypted JSON of OAuthClientInformation: { client_id, client_secret, client_id_issued_at, client_secret_expires_at }
679+
/// Null for DYNAMIC rows until first user in the org triggers registration.
680+
clientInfo String?
681+
clientInfoSource McpServerClientInfoSource @default(DYNAMIC)
682+
683+
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
684+
orgId Int
685+
686+
userMcpServers UserMcpServer[]
687+
toolCallCounts McpServerToolCallCount[]
688+
689+
createdAt DateTime @default(now())
690+
updatedAt DateTime @updatedAt
691+
692+
@@unique([serverUrl, orgId])
693+
@@unique([orgId, sanitizedName])
694+
}
695+
696+
/// Lifetime tool call counters for an MCP server.
697+
model McpServerToolCallCount {
698+
mcpServer McpServer @relation(fields: [mcpServerId], references: [id], onDelete: Cascade)
699+
mcpServerId String
700+
toolName String
701+
count Int @default(0)
702+
703+
createdAt DateTime @default(now())
704+
updatedAt DateTime @updatedAt
705+
706+
@@id([mcpServerId, toolName])
707+
}
708+
709+
/// A user's personal connection to an MCP server.
710+
/// Stores per-user OAuth tokens and ephemeral auth-flow state.
711+
model UserMcpServer {
712+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
713+
userId String
714+
715+
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
716+
serverId String
717+
718+
/// OAuth tokens (access_token, refresh_token, etc.) — encrypted JSON of OAuthTokens.
719+
tokens String?
720+
721+
/// Absolute expiry time of the access token, computed at issuance from expires_in.
722+
/// Null when no tokens are stored or the provider did not include expires_in.
723+
tokensExpiresAt DateTime?
724+
725+
/// PKCE code verifier — ephemeral, only used between redirect and callback.
726+
codeVerifier String?
727+
728+
/// OAuth state parameter — ephemeral, for CSRF protection during auth flow.
729+
state String?
730+
731+
createdAt DateTime @default(now())
732+
updatedAt DateTime @updatedAt
733+
734+
@@id([userId, serverId])
735+
@@index([serverId])
736+
@@index([state])
737+
}

packages/shared/src/entitlements.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
6565
cancelAt: null,
6666
trialEnd: null,
6767
hasPaymentMethod: null,
68+
yearlyTermStartedAt: null,
69+
yearlyTermEndsAt: null,
70+
yearlyTotalQuartersInTerm: null,
71+
yearlyCurrentQuarterNumber: null,
72+
yearlyCurrentQuarterStartedAt: null,
73+
yearlyCurrentQuarterEndsAt: null,
74+
yearlyCommittedSeats: null,
75+
yearlyOverageSeats: null,
76+
yearlyBillableOverageSeats: null,
77+
yearlyPeakSeats: null,
6878
lastSyncAt: new Date(),
6979
lastSyncErrorCode: null,
7080
createdAt: new Date(),

packages/shared/src/env.server.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,31 @@ describe('PERMISSION_SYNC_ENABLED', () => {
5454
expect(env.PERMISSION_SYNC_ENABLED).toBe('false');
5555
});
5656
});
57+
58+
describe('SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS', () => {
59+
beforeEach(() => {
60+
vi.resetModules();
61+
delete process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS;
62+
});
63+
64+
afterEach(() => {
65+
delete process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS;
66+
});
67+
68+
test('defaults to 60000 when not set', async () => {
69+
const { env } = await import('./env.server.js');
70+
expect(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS).toBe(60000);
71+
});
72+
73+
test('accepts positive integers', async () => {
74+
process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS = '5000';
75+
const { env } = await import('./env.server.js');
76+
expect(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS).toBe(5000);
77+
});
78+
79+
test.each(['0', '-1', '1.5', '2147483648', String(Number.MAX_SAFE_INTEGER + 1)])('rejects %s', async (timeoutMs) => {
80+
process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS = timeoutMs;
81+
82+
await expect(import('./env.server.js')).rejects.toThrow();
83+
});
84+
});

packages/shared/src/env.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const booleanSchema = z.enum(["true", "false"]);
1414
// coerce helps us convert them to numbers.
1515
// @see: https://zod.dev/?id=coercion-for-primitives
1616
const numberSchema = z.coerce.number();
17+
const maxTimerDelayMs = 2_147_483_647;
1718

1819
const ajv = new Ajv({
1920
validateFormats: false,
@@ -282,6 +283,7 @@ const options = {
282283
*/
283284
SOURCEBOT_CHAT_MODEL_TEMPERATURE: numberSchema.optional(),
284285
SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
286+
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),
285287

286288
DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
287289
DEBUG_ENABLE_REACT_SCAN: booleanSchema.default('false'),

packages/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@ai-sdk/deepseek": "^2.0.29",
2121
"@ai-sdk/google": "^3.0.64",
2222
"@ai-sdk/google-vertex": "^4.0.111",
23+
"@ai-sdk/mcp": "^2.0.0-beta.11",
2324
"@ai-sdk/mistral": "^3.0.30",
2425
"@ai-sdk/openai": "^3.0.53",
2526
"@ai-sdk/openai-compatible": "^2.0.41",
@@ -196,7 +197,7 @@
196197
"use-stick-to-bottom": "^1.1.3",
197198
"usehooks-ts": "^3.1.0",
198199
"vscode-icons-js": "^11.6.1",
199-
"zod": "^3.25.74",
200+
"zod": "^3.25.76",
200201
"zod-to-json-schema": "^3.24.5"
201202
},
202203
"devDependencies": {

packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,24 @@ import {
1212
import { useEntitlements } from "@/features/entitlements/useEntitlements";
1313
import { Entitlement } from "@sourcebot/shared";
1414
import {
15+
BotIcon,
1516
ChartAreaIcon,
1617
KeyRoundIcon,
1718
LinkIcon,
1819
type LucideIcon,
1920
PlugIcon,
2021
ScrollTextIcon,
22+
ServerIcon,
2123
Settings2Icon,
2224
ShieldIcon,
2325
UserIcon,
2426
UsersIcon,
2527
} from "lucide-react";
28+
import { IconType } from "react-icons/lib";
2629
import { VscMcp } from "react-icons/vsc";
2730
import Link from "next/link";
2831
import { usePathname } from "next/navigation";
2932
import { UpgradeBadge } from "../upgradeBadge";
30-
import { IconType } from "react-icons/lib";
3133

3234
const iconMap = {
3335
"link": LinkIcon,
@@ -37,9 +39,11 @@ const iconMap = {
3739
"plug": PlugIcon,
3840
"chart-area": ChartAreaIcon,
3941
"scroll-text": ScrollTextIcon,
42+
"server": ServerIcon,
4043
"settings": Settings2Icon,
4144
"user": UserIcon,
4245
"mcp": VscMcp,
46+
"bot": BotIcon,
4347
} satisfies Record<string, LucideIcon | IconType>;
4448

4549
export type NavIconName = keyof typeof iconMap;

packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => {
339339
</DropdownMenuSub>
340340
</DropdownMenuGroup>
341341
)
342-
}
342+
}

packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const LandingPage = ({
6868
<div className="border rounded-md w-full shadow-sm">
6969
<ChatBox
7070
onSubmit={(children) => {
71-
createNewChatThread(children, selectedSearchScopes);
71+
createNewChatThread(children, selectedSearchScopes, []);
7272
}}
7373
className="min-h-[50px]"
7474
isRedirecting={isLoading}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { cleanup, render, waitFor } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
3+
import { SET_CHAT_STATE_SESSION_STORAGE_KEY } from '@/features/chat/constants';
4+
import { ChatThreadPanel } from './chatThreadPanel';
5+
6+
const { chatThreadProps } = vi.hoisted(() => ({
7+
chatThreadProps: [] as Array<{ disabledMcpServerIds?: unknown }>,
8+
}));
9+
10+
vi.mock('next/navigation', () => ({
11+
useParams: () => ({ id: 'chat-1' }),
12+
}));
13+
14+
vi.mock('@/features/chat/components/chatThread', () => ({
15+
ChatThread: (props: { disabledMcpServerIds?: unknown }) => {
16+
chatThreadProps.push(props);
17+
return <div data-testid="chat-thread" />;
18+
},
19+
}));
20+
21+
function createMockStorage(): Storage {
22+
const store = new Map<string, string>();
23+
24+
return {
25+
get length() {
26+
return store.size;
27+
},
28+
clear: () => store.clear(),
29+
getItem: (key: string) => store.get(key) ?? null,
30+
key: (index: number) => Array.from(store.keys())[index] ?? null,
31+
removeItem: (key: string) => {
32+
store.delete(key);
33+
},
34+
setItem: (key: string, value: string) => {
35+
store.set(key, value);
36+
},
37+
};
38+
}
39+
40+
function installMockStorage(key: 'localStorage' | 'sessionStorage') {
41+
const storage = createMockStorage();
42+
Object.defineProperty(window, key, {
43+
configurable: true,
44+
value: storage,
45+
});
46+
Object.defineProperty(globalThis, key, {
47+
configurable: true,
48+
value: storage,
49+
});
50+
}
51+
52+
describe('ChatThreadPanel', () => {
53+
beforeEach(() => {
54+
installMockStorage('localStorage');
55+
installMockStorage('sessionStorage');
56+
chatThreadProps.length = 0;
57+
sessionStorage.clear();
58+
});
59+
60+
afterEach(() => {
61+
cleanup();
62+
sessionStorage.clear();
63+
});
64+
65+
test('defaults restored disabled MCP server ids to an empty array when missing from session storage', async () => {
66+
sessionStorage.setItem(SET_CHAT_STATE_SESSION_STORAGE_KEY, JSON.stringify({
67+
inputMessage: {
68+
role: 'user',
69+
parts: [{ type: 'text', text: 'hello' }],
70+
},
71+
selectedSearchScopes: [],
72+
}));
73+
74+
render(
75+
<ChatThreadPanel
76+
languageModels={[]}
77+
repos={[]}
78+
searchContexts={[]}
79+
messages={[]}
80+
isOwner={true}
81+
isAuthenticated={true}
82+
/>
83+
);
84+
85+
await waitFor(() => expect(chatThreadProps.length).toBeGreaterThan(1));
86+
87+
expect(chatThreadProps.at(-1)?.disabledMcpServerIds).toEqual([]);
88+
});
89+
});

0 commit comments

Comments
 (0)