Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
07c1908
Add ask MCP server integration
jsourcebot Mar 28, 2026
48b4448
Merge MCP user server credentials
jsourcebot May 22, 2026
c357e62
Inject Prisma into MCP OAuth provider
jsourcebot May 22, 2026
1e3fdc1
Scope MCP user server queries
jsourcebot May 22, 2026
da3f885
Add org-approved MCP servers
jsourcebot May 24, 2026
acda402
feat(web): add workspace MCP configuration
jsourcebot May 25, 2026
ffe0d87
fix(web): allow MCP cleanup without OAuth entitlement
jsourcebot May 25, 2026
1e6860b
feat(web): improve MCP server add flow
jsourcebot May 26, 2026
d193ce6
fix(web): check DCR for prefab MCP servers
jsourcebot May 26, 2026
820ecb3
feat(web): support static OAuth MCP credentials
jsourcebot May 26, 2026
95e2d95
feat(web): add more prefab MCP servers
jsourcebot May 26, 2026
e71e25f
fix(web): use official Atlassian MCP icons
jsourcebot May 26, 2026
d7e1e7d
fix(web): use Atlassian prefab MCP server
jsourcebot May 26, 2026
0abe155
feat(web): connect approved MCP servers from chat
jsourcebot May 26, 2026
4d1bcfc
feat(web): redesign MCP servers settings page
jsourcebot May 26, 2026
78cd0f0
Rename MCP settings to Ask Agent connectors
jsourcebot May 26, 2026
c0e9225
feat(web): redesign workspace Ask Agent settings page
jsourcebot May 27, 2026
29edd6c
feat(web): add workspace connector config link to chat toolbar
jsourcebot May 27, 2026
49d5906
Add MCP connector tool metadata
jsourcebot May 27, 2026
ff6fb0f
feat(web): redesign MCP tools list as compact clickable badges
jsourcebot May 27, 2026
33e7785
refactor(web): extract shared ConnectorCard component
jsourcebot May 27, 2026
0c72b28
Fix Ask approval turn progress state
jsourcebot May 27, 2026
289bf5c
Add MCP connector usage counters
jsourcebot May 28, 2026
5fc2fc8
Address MCP review feedback
jsourcebot May 28, 2026
25dae4e
Remove workspace Ask Agent connector summary cards
jsourcebot May 28, 2026
f358378
Add PostHog prefab MCP server
jsourcebot May 28, 2026
6d336f7
Add Ask MCP PostHog metrics
jsourcebot May 28, 2026
0bebd3b
Add Ask MCP tool call analytics
jsourcebot May 28, 2026
a62a615
Add Ask MCP connector lifecycle analytics
jsourcebot May 28, 2026
1d62399
Fix v5 rebase follow-ups
jsourcebot May 29, 2026
8903390
Clean up Ask MCP deployment references
jsourcebot May 29, 2026
209da4d
Move EE MCP feature under chat
jsourcebot May 29, 2026
71c2893
Simplify static MCP OAuth HTTPS guard
jsourcebot May 29, 2026
9c15807
docs: v5 docs updates (#1244)
msukkari May 29, 2026
6739694
[v5] feat(web): Add usage information for yearly subs (#1245)
brendan-kellam May 29, 2026
3d00a39
fix build errors after merge
msukkari May 29, 2026
2c34b08
fix bug where Ask doesn't load if we dont have a license key
msukkari May 29, 2026
a7088e8
refactor(web): clean up MCP OAuth provider
jsourcebot May 29, 2026
0c6ab64
Merge branch 'v5' into BlueBottleLatte/askmcp
brendan-kellam May 29, 2026
41b9f04
Merge branch 'BlueBottleLatte/askmcp' of https://github.com/sourcebot…
jsourcebot May 29, 2026
7deb050
Prisma Migrations
jsourcebot May 29, 2026
c2e7885
Merge branch 'v5' into BlueBottleLatte/askmcp
brendan-kellam May 29, 2026
7833a07
Address various review feedback
jsourcebot May 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Comment thread
brendan-kellam marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
-- CreateEnum
CREATE TYPE "McpServerClientInfoSource" AS ENUM ('DYNAMIC', 'STATIC');

-- CreateTable
CREATE TABLE "McpServer" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sanitizedName" TEXT NOT NULL,
"serverUrl" TEXT NOT NULL,
"clientInfo" TEXT,
"clientInfoSource" "McpServerClientInfoSource" NOT NULL DEFAULT 'DYNAMIC',
"orgId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "McpServer_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "McpServerToolCallCount" (
"mcpServerId" TEXT NOT NULL,
"toolName" TEXT NOT NULL,
"count" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "McpServerToolCallCount_pkey" PRIMARY KEY ("mcpServerId","toolName")
);

-- CreateTable
CREATE TABLE "UserMcpServer" (
"userId" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"tokens" TEXT,
"tokensExpiresAt" TIMESTAMP(3),
"codeVerifier" TEXT,
"state" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "UserMcpServer_pkey" PRIMARY KEY ("userId","serverId")
);

-- CreateIndex
CREATE UNIQUE INDEX "McpServer_serverUrl_orgId_key" ON "McpServer"("serverUrl", "orgId");

-- CreateIndex
CREATE UNIQUE INDEX "McpServer_orgId_sanitizedName_key" ON "McpServer"("orgId", "sanitizedName");

-- CreateIndex
CREATE INDEX "UserMcpServer_serverId_idx" ON "UserMcpServer"("serverId");

-- CreateIndex
CREATE INDEX "UserMcpServer_state_idx" ON "UserMcpServer"("state");

-- AddForeignKey
ALTER TABLE "McpServer" ADD CONSTRAINT "McpServer_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "McpServerToolCallCount" ADD CONSTRAINT "McpServerToolCallCount_mcpServerId_fkey" FOREIGN KEY ("mcpServerId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
79 changes: 79 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ model Org {
chats Chat[]
repoVisits RepoVisit[]

mcpServers McpServer[]

license License?
}

Expand Down Expand Up @@ -340,6 +342,11 @@ enum OrgRole {
MEMBER
}

enum McpServerClientInfoSource {
DYNAMIC
STATIC
}

model UserToOrg {
joinedAt DateTime @default(now())

Expand Down Expand Up @@ -422,6 +429,8 @@ model User {
/// claim baked into the JWT cookie at mint time.
sessionVersion Int @default(0)

userMcpServers UserMcpServer[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down Expand Up @@ -656,3 +665,73 @@ model ChangelogEntry {

@@index([publishedAt])
}

/// An external MCP server endpoint, unique per org.
/// Stores the dynamic client registration (client_id/client_secret) once per org.
model McpServer {
id String @id @default(cuid())
name String /// Org-approved display name (e.g., "Linear")
sanitizedName String /// Stable tool-name prefix (e.g., "linear")
serverUrl String /// MCP server endpoint (e.g., "https://mcp.linear.app/mcp")

/// Dynamic client registration result (RFC 7591) or admin-provided static OAuth client credentials.
/// Encrypted JSON of OAuthClientInformation: { client_id, client_secret, client_id_issued_at, client_secret_expires_at }
/// Null for DYNAMIC rows until first user in the org triggers registration.
clientInfo String?
clientInfoSource McpServerClientInfoSource @default(DYNAMIC)

org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int

userMcpServers UserMcpServer[]
toolCallCounts McpServerToolCallCount[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([serverUrl, orgId])
@@unique([orgId, sanitizedName])
}

/// Lifetime tool call counters for an MCP server.
model McpServerToolCallCount {
mcpServer McpServer @relation(fields: [mcpServerId], references: [id], onDelete: Cascade)
mcpServerId String
toolName String
count Int @default(0)

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@id([mcpServerId, toolName])
}

/// A user's personal connection to an MCP server.
/// Stores per-user OAuth tokens and ephemeral auth-flow state.
model UserMcpServer {
Comment thread
brendan-kellam marked this conversation as resolved.
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String

server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String

/// OAuth tokens (access_token, refresh_token, etc.) — encrypted JSON of OAuthTokens.
tokens String?

/// Absolute expiry time of the access token, computed at issuance from expires_in.
/// Null when no tokens are stored or the provider did not include expires_in.
tokensExpiresAt DateTime?

/// PKCE code verifier — ephemeral, only used between redirect and callback.
codeVerifier String?

/// OAuth state parameter — ephemeral, for CSRF protection during auth flow.
state String?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@id([userId, serverId])
@@index([serverId])
@@index([state])
}
10 changes: 10 additions & 0 deletions packages/shared/src/entitlements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
cancelAt: null,
trialEnd: null,
hasPaymentMethod: null,
yearlyTermStartedAt: null,
yearlyTermEndsAt: null,
yearlyTotalQuartersInTerm: null,
yearlyCurrentQuarterNumber: null,
yearlyCurrentQuarterStartedAt: null,
yearlyCurrentQuarterEndsAt: null,
yearlyCommittedSeats: null,
yearlyOverageSeats: null,
yearlyBillableOverageSeats: null,
yearlyPeakSeats: null,
lastSyncAt: new Date(),
lastSyncErrorCode: null,
createdAt: new Date(),
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/env.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,31 @@ describe('PERMISSION_SYNC_ENABLED', () => {
expect(env.PERMISSION_SYNC_ENABLED).toBe('false');
});
});

describe('SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS', () => {
beforeEach(() => {
vi.resetModules();
delete process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS;
});

afterEach(() => {
delete process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS;
});

test('defaults to 60000 when not set', async () => {
const { env } = await import('./env.server.js');
expect(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS).toBe(60000);
});

test('accepts positive integers', async () => {
process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS = '5000';
const { env } = await import('./env.server.js');
expect(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS).toBe(5000);
});

test.each(['0', '-1', '1.5', '2147483648', String(Number.MAX_SAFE_INTEGER + 1)])('rejects %s', async (timeoutMs) => {
process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS = timeoutMs;

await expect(import('./env.server.js')).rejects.toThrow();
});
});
2 changes: 2 additions & 0 deletions packages/shared/src/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const booleanSchema = z.enum(["true", "false"]);
// coerce helps us convert them to numbers.
// @see: https://zod.dev/?id=coercion-for-primitives
const numberSchema = z.coerce.number();
const maxTimerDelayMs = 2_147_483_647;

const ajv = new Ajv({
validateFormats: false,
Expand Down Expand Up @@ -282,6 +283,7 @@ const options = {
*/
SOURCEBOT_CHAT_MODEL_TEMPERATURE: numberSchema.optional(),
SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),

DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
DEBUG_ENABLE_REACT_SCAN: booleanSchema.default('false'),
Expand Down
3 changes: 2 additions & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@ai-sdk/deepseek": "^2.0.29",
"@ai-sdk/google": "^3.0.64",
"@ai-sdk/google-vertex": "^4.0.111",
"@ai-sdk/mcp": "^2.0.0-beta.11",
"@ai-sdk/mistral": "^3.0.30",
"@ai-sdk/openai": "^3.0.53",
"@ai-sdk/openai-compatible": "^2.0.41",
Expand Down Expand Up @@ -196,7 +197,7 @@
"use-stick-to-bottom": "^1.1.3",
"usehooks-ts": "^3.1.0",
"vscode-icons-js": "^11.6.1",
"zod": "^3.25.74",
"zod": "^3.25.76",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,24 @@ import {
import { useEntitlements } from "@/features/entitlements/useEntitlements";
import { Entitlement } from "@sourcebot/shared";
import {
BotIcon,
ChartAreaIcon,
KeyRoundIcon,
LinkIcon,
type LucideIcon,
PlugIcon,
ScrollTextIcon,
ServerIcon,
Settings2Icon,
ShieldIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
import { IconType } from "react-icons/lib";
import { VscMcp } from "react-icons/vsc";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { UpgradeBadge } from "../upgradeBadge";
import { IconType } from "react-icons/lib";

const iconMap = {
"link": LinkIcon,
Expand All @@ -37,9 +39,11 @@ const iconMap = {
"plug": PlugIcon,
"chart-area": ChartAreaIcon,
"scroll-text": ScrollTextIcon,
"server": ServerIcon,
"settings": Settings2Icon,
"user": UserIcon,
"mcp": VscMcp,
"bot": BotIcon,
} satisfies Record<string, LucideIcon | IconType>;

export type NavIconName = keyof typeof iconMap;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => {
</DropdownMenuSub>
</DropdownMenuGroup>
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const LandingPage = ({
<div className="border rounded-md w-full shadow-sm">
<ChatBox
onSubmit={(children) => {
createNewChatThread(children, selectedSearchScopes);
createNewChatThread(children, selectedSearchScopes, []);
}}
className="min-h-[50px]"
isRedirecting={isLoading}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { SET_CHAT_STATE_SESSION_STORAGE_KEY } from '@/features/chat/constants';
import { ChatThreadPanel } from './chatThreadPanel';

const { chatThreadProps } = vi.hoisted(() => ({
chatThreadProps: [] as Array<{ disabledMcpServerIds?: unknown }>,
}));

vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'chat-1' }),
}));

vi.mock('@/features/chat/components/chatThread', () => ({
ChatThread: (props: { disabledMcpServerIds?: unknown }) => {
chatThreadProps.push(props);
return <div data-testid="chat-thread" />;
},
}));

function createMockStorage(): Storage {
const store = new Map<string, string>();

return {
get length() {
return store.size;
},
clear: () => store.clear(),
getItem: (key: string) => store.get(key) ?? null,
key: (index: number) => Array.from(store.keys())[index] ?? null,
removeItem: (key: string) => {
store.delete(key);
},
setItem: (key: string, value: string) => {
store.set(key, value);
},
};
}

function installMockStorage(key: 'localStorage' | 'sessionStorage') {
const storage = createMockStorage();
Object.defineProperty(window, key, {
configurable: true,
value: storage,
});
Object.defineProperty(globalThis, key, {
configurable: true,
value: storage,
});
}

describe('ChatThreadPanel', () => {
beforeEach(() => {
installMockStorage('localStorage');
installMockStorage('sessionStorage');
chatThreadProps.length = 0;
sessionStorage.clear();
});

afterEach(() => {
cleanup();
sessionStorage.clear();
});

test('defaults restored disabled MCP server ids to an empty array when missing from session storage', async () => {
sessionStorage.setItem(SET_CHAT_STATE_SESSION_STORAGE_KEY, JSON.stringify({
inputMessage: {
role: 'user',
parts: [{ type: 'text', text: 'hello' }],
},
selectedSearchScopes: [],
}));

render(
<ChatThreadPanel
languageModels={[]}
repos={[]}
searchContexts={[]}
messages={[]}
isOwner={true}
isAuthenticated={true}
/>
);

await waitFor(() => expect(chatThreadProps.length).toBeGreaterThan(1));

expect(chatThreadProps.at(-1)?.disabledMcpServerIds).toEqual([]);
});
});
Loading
Loading