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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,8 @@ model Org {
chats Chat[]
repoVisits RepoVisit[]

mcpServers McpServer[]

license License?
}

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

enum McpServerClientInfoSource {
DYNAMIC
STATIC
}

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

Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => {
</DropdownMenuSub>
</DropdownMenuGroup>
)
}
}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,8 @@ model Org {
chats Chat[]
repoVisits RepoVisit[]

mcpServers McpServer[]

license License?
}

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

enum McpServerClientInfoSource {
DYNAMIC
STATIC
}

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

Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => {
</DropdownMenuSub>
</DropdownMenuGroup>
)
}
}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,8 @@ model Org {
chats Chat[]
repoVisits RepoVisit[]

mcpServers McpServer[]

license License?
}

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

enum McpServerClientInfoSource {
DYNAMIC
STATIC
}

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

Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => {
</DropdownMenuSub>
</DropdownMenuGroup>
)
}
}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,8 @@ model Org {
chats Chat[]
repoVisits RepoVisit[]

mcpServers McpServer[]

license License?
}

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

enum McpServerClientInfoSource {
DYNAMIC
STATIC
}

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

Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => {
</DropdownMenuSub>
</DropdownMenuGroup>
)
}
}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,8 @@ model Org {
chats Chat[]
repoVisits RepoVisit[]

mcpServers McpServer[]

license License?
}

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

enum McpServerClientInfoSource {
DYNAMIC
STATIC
}

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

Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => {
</DropdownMenuSub>
</DropdownMenuGroup>
)
}
}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,8 @@ model Org {
chats Chat[]
repoVisits RepoVisit[]

mcpServers McpServer[]

license License?
}

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

enum McpServerClientInfoSource {
DYNAMIC
STATIC
}

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

Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => {
</DropdownMenuSub>
</DropdownMenuGroup>
)
}
}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,8 @@ model Org {
chats Chat[]
repoVisits RepoVisit[]

mcpServers McpServer[]

license License?
}

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

enum McpServerClientInfoSource {
DYNAMIC
STATIC
}

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

Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => {
</DropdownMenuSub>
</DropdownMenuGroup>
)
}
}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,8 @@ model Org {
chats Chat[]
repoVisits RepoVisit[]

mcpServers McpServer[]

license License?
}

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

enum McpServerClientInfoSource {
DYNAMIC
STATIC
}

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

Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -339,4 +339,4 @@ const AppearanceDropdownMenuGroup = () => {
</DropdownMenuSub>
</DropdownMenuGroup>
)
}
}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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