+ This example demonstrates OAuth authentication with Linear's MCP server.
+ Click "Authenticate with Linear" to start the OAuth flow.
+
+
+ Note:
+ {' '}
+ You'll need to register this application with Linear to get a proper client ID.
+ For this demo, we're using a placeholder client ID.
+
+
+ Browser limitations:
+ {' '}
+ Stdio connections (using
+ command
+ {' '}
+ and
+ args
+ )
+ are not supported in the browser. Use
+ ws_url
+ {' '}
+ for WebSocket or
+ url
+ {' '}
+ for HTTP/SSE connections.
+
+
+
+ )
+}
+
+export default ReactExample
diff --git a/examples/react/serve.js b/examples/react/serve.js
new file mode 100644
index 00000000..9d723118
--- /dev/null
+++ b/examples/react/serve.js
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+
+import { existsSync, readFileSync } from 'node:fs'
+import { createServer } from 'node:http'
+import { dirname, extname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+
+const port = process.env.PORT || 3000
+const distDir = join(__dirname, 'dist')
+
+const mimeTypes = {
+ '.html': 'text/html',
+ '.js': 'application/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon',
+}
+
+const server = createServer((req, res) => {
+ const filePath = join(distDir, req.url === '/' ? 'react_example.html' : req.url)
+
+ // Security check - prevent directory traversal
+ if (!filePath.startsWith(distDir)) {
+ res.writeHead(403)
+ res.end('Forbidden')
+ return
+ }
+
+ if (!existsSync(filePath)) {
+ res.writeHead(404)
+ res.end('Not Found')
+ return
+ }
+
+ try {
+ const content = readFileSync(filePath)
+ const ext = extname(filePath)
+ const contentType = mimeTypes[ext] || 'application/octet-stream'
+
+ res.writeHead(200, { 'Content-Type': contentType })
+ res.end(content)
+ }
+ catch {
+ res.writeHead(500)
+ res.end('Internal Server Error')
+ }
+})
+
+server.listen(port, () => {
+ console.log(`🚀 React example server running at http://localhost:${port}`)
+ console.log(`📁 Serving files from: ${distDir}`)
+})
diff --git a/examples/react/tsconfig.json b/examples/react/tsconfig.json
new file mode 100644
index 00000000..17ac59b0
--- /dev/null
+++ b/examples/react/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "jsx": "react-jsx",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "allowImportingTsExtensions": true,
+
+ /* Linting */
+ "strict": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "skipLibCheck": true
+ },
+ "references": [{ "path": "./tsconfig.node.json" }],
+ "include": ["**/*.ts", "**/*.tsx"]
+}
diff --git a/examples/react/tsconfig.node.json b/examples/react/tsconfig.node.json
new file mode 100644
index 00000000..dde08948
--- /dev/null
+++ b/examples/react/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "skipLibCheck": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/examples/react/vite.config.ts b/examples/react/vite.config.ts
new file mode 100644
index 00000000..7de8b540
--- /dev/null
+++ b/examples/react/vite.config.ts
@@ -0,0 +1,46 @@
+import { resolve } from 'node:path'
+import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'
+import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill'
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ outDir: 'dist',
+ commonjsOptions: {
+ transformMixedEsModules: true,
+ },
+ },
+ resolve: {
+ alias: {
+ 'mcp-use/browser': resolve(__dirname, '../../dist/src/browser.js'),
+ 'mcp-use': resolve(__dirname, '../../dist/src'),
+ },
+ },
+ define: {
+ 'global': 'globalThis',
+ 'process.env.DEBUG': 'undefined',
+ 'process.env.MCP_USE_ANONYMIZED_TELEMETRY': 'undefined',
+ 'process.env.MCP_USE_TELEMETRY_SOURCE': 'undefined',
+ 'process.env.MCP_USE_LANGFUSE': 'undefined',
+ 'process.platform': '""',
+ 'process.version': '""',
+ 'process.argv': '[]',
+ },
+ optimizeDeps: {
+ include: ['react', 'react-dom'],
+ esbuildOptions: {
+ define: {
+ global: 'globalThis',
+ },
+ plugins: [
+ NodeGlobalsPolyfillPlugin({
+ process: true,
+ buffer: true,
+ }),
+ NodeModulesPolyfillPlugin(),
+ ],
+ },
+ },
+})
diff --git a/package-lock.json b/package-lock.json
index 4fdadb1c..14e059a4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"posthog-node": "^5.1.1",
"uuid": "^11.1.0",
"winston": "^3.17.0",
+ "winston-transport-browserconsole": "^1.0.5",
"ws": "^8.18.2",
"zod": "^3.25.48",
"zod-to-json-schema": "^3.24.6"
@@ -8842,6 +8843,16 @@
"node": ">= 12.0.0"
}
},
+ "node_modules/winston-transport-browserconsole": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/winston-transport-browserconsole/-/winston-transport-browserconsole-1.0.5.tgz",
+ "integrity": "sha512-BFZDvknATAmsqaRY3WatB2S0eEb0ziwqBYIv+H0Fnu9oe7GnPUVQzEJC1frmLdNsfEkfnyR1PCNDBAFtZE3SuQ==",
+ "license": "ISC",
+ "dependencies": {
+ "winston": "^3.2.1",
+ "winston-transport": "^4.3.0"
+ }
+ },
"node_modules/winston/node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
diff --git a/package.json b/package.json
index 01ff97ef..7921878b 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
+ },
+ "./browser": {
+ "types": "./dist/src/browser.d.ts",
+ "import": "./dist/src/browser.js"
}
},
"main": "./dist/index.js",
@@ -103,6 +107,7 @@
"posthog-node": "^5.1.1",
"uuid": "^11.1.0",
"winston": "^3.17.0",
+ "winston-transport-browserconsole": "^1.0.5",
"ws": "^8.18.2",
"zod": "^3.25.48",
"zod-to-json-schema": "^3.24.6"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a18aa20e..5d1fdc35 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -53,6 +53,9 @@ importers:
winston:
specifier: ^3.17.0
version: 3.17.0
+ winston-transport-browserconsole:
+ specifier: ^1.0.5
+ version: 1.0.5
ws:
specifier: ^8.18.2
version: 8.18.2
@@ -2896,6 +2899,9 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ winston-transport-browserconsole@1.0.5:
+ resolution: {integrity: sha512-BFZDvknATAmsqaRY3WatB2S0eEb0ziwqBYIv+H0Fnu9oe7GnPUVQzEJC1frmLdNsfEkfnyR1PCNDBAFtZE3SuQ==}
+
winston-transport@4.9.0:
resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==}
engines: {node: '>= 12.0.0'}
@@ -5903,6 +5909,11 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
+ winston-transport-browserconsole@1.0.5:
+ dependencies:
+ winston: 3.17.0
+ winston-transport: 4.9.0
+
winston-transport@4.9.0:
dependencies:
logform: 2.7.0
diff --git a/src/browser.ts b/src/browser.ts
new file mode 100644
index 00000000..ce11949a
--- /dev/null
+++ b/src/browser.ts
@@ -0,0 +1,95 @@
+/**
+ * Browser-compatible exports for mcp-use
+ *
+ * This module provides browser-safe versions of mcp-use components
+ * that avoid Node.js-specific dependencies (like fs, path for file operations).
+ *
+ * The actual MCP protocol connectors (WebSocket, HTTP/SSE) work fine in browsers.
+ */
+
+import type { BaseConnector } from './connectors/base.js'
+import { BaseMCPClient } from './client/base.js'
+import { HttpConnector } from './connectors/http.js'
+import { WebSocketConnector } from './connectors/websocket.js'
+import { logger } from './logging.js'
+import { MCPSession } from './session.js'
+
+/**
+ * Browser-compatible MCP Client
+ *
+ * Unlike the Node.js version, this doesn't support:
+ * - Loading config from files (loadConfigFile)
+ * - Saving config to files (saveConfig)
+ * - StdioConnector (requires child_process)
+ *
+ * Supported connectors:
+ * - WebSocketConnector: Connect to MCP servers via WebSocket
+ * - HttpConnector: Connect to MCP servers via HTTP/SSE
+ */
+export class MCPClient extends BaseMCPClient {
+ constructor(config?: Record) {
+ super(config)
+ }
+
+ public static fromDict(cfg: Record): MCPClient {
+ return new MCPClient(cfg)
+ }
+
+ /**
+ * Create a connector from server configuration (browser-safe version)
+ *
+ * Supports:
+ * - WebSocket connections: { ws_url: "ws://..." }
+ * - HTTP connections: { url: "http://..." }
+ *
+ * Does NOT support:
+ * - Stdio connections: { command: "...", args: [...] }
+ */
+ protected createConnectorFromConfig(serverConfig: Record): BaseConnector {
+ // WebSocket connector
+ if ('ws_url' in serverConfig) {
+ return new WebSocketConnector(serverConfig.ws_url, {
+ headers: serverConfig.headers,
+ authToken: serverConfig.auth_token || serverConfig.authToken,
+ })
+ }
+
+ // HTTP/SSE connector
+ if ('url' in serverConfig) {
+ const transport = serverConfig.transport || 'http'
+
+ return new HttpConnector(serverConfig.url, {
+ headers: serverConfig.headers,
+ authToken: serverConfig.auth_token || serverConfig.authToken,
+ preferSse: serverConfig.preferSse || transport === 'sse',
+ })
+ }
+
+ // Stdio is not supported in browser
+ if ('command' in serverConfig && 'args' in serverConfig) {
+ throw new Error(
+ 'StdioConnector is not supported in browser environments. '
+ + 'Use WebSocket (ws_url) or HTTP (url) connectors instead.',
+ )
+ }
+
+ throw new Error('Cannot determine connector type from config. Use "url" for HTTP or "ws_url" for WebSocket.')
+ }
+}
+
+// Re-export browser-safe connectors
+export { HttpConnector, WebSocketConnector }
+
+// Re-export session
+export { MCPSession }
+
+// Re-export logger (already browser-safe)
+export { logger }
+
+// Re-export OAuth helper for browser authentication
+export { createOAuthMCPConfig, LINEAR_OAUTH_CONFIG, OAuthHelper } from './oauth-helper.js'
+export type { ClientRegistration, OAuthConfig, OAuthDiscovery, OAuthResult, OAuthState } from './oauth-helper.js'
+
+// Re-export types that are safe for browser
+export type { AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages'
+export type { StreamEvent } from '@langchain/core/tracers/log_stream'
diff --git a/src/client.ts b/src/client.ts
index b7449612..17eafa85 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,23 +1,30 @@
+import type { BaseConnector } from './connectors/base.js'
import fs from 'node:fs'
import path from 'node:path'
+import { BaseMCPClient } from './client/base.js'
import { createConnectorFromConfig, loadConfigFile } from './config.js'
-import { logger } from './logging.js'
-import { MCPSession } from './session.js'
-
-export class MCPClient {
- private config: Record = {}
- private sessions: Record = {}
- public activeSessions: string[] = []
+/**
+ * Node.js-specific MCPClient implementation
+ *
+ * Extends the base client with Node.js-specific features like:
+ * - File system operations (saveConfig)
+ * - Config file loading (fromConfigFile)
+ * - All connector types including StdioConnector
+ */
+export class MCPClient extends BaseMCPClient {
constructor(config?: string | Record) {
if (config) {
if (typeof config === 'string') {
- this.config = loadConfigFile(config)
+ super(loadConfigFile(config))
}
else {
- this.config = config
+ super(config)
}
}
+ else {
+ super()
+ }
}
public static fromDict(cfg: Record): MCPClient {
@@ -28,30 +35,9 @@ export class MCPClient {
return new MCPClient(loadConfigFile(path))
}
- public addServer(name: string, serverConfig: Record): void {
- this.config.mcpServers = this.config.mcpServers || {}
- this.config.mcpServers[name] = serverConfig
- }
-
- public removeServer(name: string): void {
- if (this.config.mcpServers?.[name]) {
- delete this.config.mcpServers[name]
- this.activeSessions = this.activeSessions.filter(n => n !== name)
- }
- }
-
- public getServerNames(): string[] {
- return Object.keys(this.config.mcpServers ?? {})
- }
-
- public getServerConfig(name: string): Record {
- return this.config.mcpServers?.[name]
- }
-
- public getConfig(): Record {
- return this.config ?? {}
- }
-
+ /**
+ * Save configuration to a file (Node.js only)
+ */
public saveConfig(filepath: string): void {
const dir = path.dirname(filepath)
if (!fs.existsSync(dir)) {
@@ -60,104 +46,11 @@ export class MCPClient {
fs.writeFileSync(filepath, JSON.stringify(this.config, null, 2), 'utf-8')
}
- public async createSession(
- serverName: string,
- autoInitialize = true,
- ): Promise {
- const servers = this.config.mcpServers ?? {}
-
- if (Object.keys(servers).length === 0) {
- logger.warn('No MCP servers defined in config')
- }
-
- if (!servers[serverName]) {
- throw new Error(`Server '${serverName}' not found in config`)
- }
-
- const connector = createConnectorFromConfig(servers[serverName])
- const session = new MCPSession(connector)
- if (autoInitialize) {
- await session.initialize()
- }
-
- this.sessions[serverName] = session
- if (!this.activeSessions.includes(serverName)) {
- this.activeSessions.push(serverName)
- }
- return session
- }
-
- public async createAllSessions(
- autoInitialize = true,
- ): Promise> {
- const servers = this.config.mcpServers ?? {}
-
- if (Object.keys(servers).length === 0) {
- logger.warn('No MCP servers defined in config')
- }
-
- for (const name of Object.keys(servers)) {
- await this.createSession(name, autoInitialize)
- }
-
- return this.sessions
- }
-
- public getSession(serverName: string): MCPSession | null {
- const session = this.sessions[serverName]
- // if (!session) {
- // throw new Error(`No session exists for server '${serverName}'`)
- // }
- if (!session) {
- return null
- }
- return session
- }
-
- public getAllActiveSessions(): Record {
- return Object.fromEntries(
- this.activeSessions.map(n => [n, this.sessions[n]]),
- )
- }
-
- public async closeSession(serverName: string): Promise {
- const session = this.sessions[serverName]
- if (!session) {
- logger.warn(`No session exists for server ${serverName}, nothing to close`)
- return
- }
- try {
- logger.debug(`Closing session for server ${serverName}`)
- await session.disconnect()
- }
- catch (e) {
- logger.error(`Error closing session for server '${serverName}': ${e}`)
- }
- finally {
- delete this.sessions[serverName]
- this.activeSessions = this.activeSessions.filter(n => n !== serverName)
- }
- }
-
- public async closeAllSessions(): Promise {
- const serverNames = Object.keys(this.sessions)
- const errors: string[] = []
- for (const serverName of serverNames) {
- try {
- logger.debug(`Closing session for server ${serverName}`)
- await this.closeSession(serverName)
- }
- catch (e: any) {
- const errorMsg = `Failed to close session for server '${serverName}': ${e}`
- logger.error(errorMsg)
- errors.push(errorMsg)
- }
- }
- if (errors.length) {
- logger.error(`Encountered ${errors.length} errors while closing sessions`)
- }
- else {
- logger.debug('All sessions closed successfully')
- }
+ /**
+ * Create a connector from server configuration (Node.js version)
+ * Supports all connector types including StdioConnector
+ */
+ protected createConnectorFromConfig(serverConfig: Record): BaseConnector {
+ return createConnectorFromConfig(serverConfig)
}
}
diff --git a/src/client/base.ts b/src/client/base.ts
new file mode 100644
index 00000000..c8706867
--- /dev/null
+++ b/src/client/base.ts
@@ -0,0 +1,155 @@
+import type { BaseConnector } from '../connectors/base.js'
+import { logger } from '../logging.js'
+import { MCPSession } from '../session.js'
+
+/**
+ * Base MCPClient class with shared functionality
+ *
+ * This class contains all the common logic that works in both Node.js and browser environments.
+ * Platform-specific implementations should extend this class and override methods as needed.
+ */
+export abstract class BaseMCPClient {
+ protected config: Record = {}
+ protected sessions: Record = {}
+ public activeSessions: string[] = []
+
+ constructor(config?: Record) {
+ if (config) {
+ this.config = config
+ }
+ }
+
+ public static fromDict(_cfg: Record): BaseMCPClient {
+ // This will be overridden by concrete implementations
+ throw new Error('fromDict must be implemented by concrete class')
+ }
+
+ public addServer(name: string, serverConfig: Record): void {
+ this.config.mcpServers = this.config.mcpServers || {}
+ this.config.mcpServers[name] = serverConfig
+ }
+
+ public removeServer(name: string): void {
+ if (this.config.mcpServers?.[name]) {
+ delete this.config.mcpServers[name]
+ this.activeSessions = this.activeSessions.filter(n => n !== name)
+ }
+ }
+
+ public getServerNames(): string[] {
+ return Object.keys(this.config.mcpServers ?? {})
+ }
+
+ public getServerConfig(name: string): Record {
+ return this.config.mcpServers?.[name]
+ }
+
+ public getConfig(): Record {
+ return this.config ?? {}
+ }
+
+ /**
+ * Create a connector from server configuration
+ * This method must be implemented by platform-specific subclasses
+ */
+ protected abstract createConnectorFromConfig(serverConfig: Record): BaseConnector
+
+ public async createSession(
+ serverName: string,
+ autoInitialize = true,
+ ): Promise {
+ const servers = this.config.mcpServers ?? {}
+
+ if (Object.keys(servers).length === 0) {
+ logger.warn('No MCP servers defined in config')
+ }
+
+ if (!servers[serverName]) {
+ throw new Error(`Server '${serverName}' not found in config`)
+ }
+
+ const connector = this.createConnectorFromConfig(servers[serverName])
+ const session = new MCPSession(connector)
+
+ if (autoInitialize) {
+ await session.initialize()
+ }
+
+ this.sessions[serverName] = session
+ if (!this.activeSessions.includes(serverName)) {
+ this.activeSessions.push(serverName)
+ }
+ return session
+ }
+
+ public async createAllSessions(
+ autoInitialize = true,
+ ): Promise> {
+ const servers = this.config.mcpServers ?? {}
+
+ if (Object.keys(servers).length === 0) {
+ logger.warn('No MCP servers defined in config')
+ }
+
+ for (const name of Object.keys(servers)) {
+ await this.createSession(name, autoInitialize)
+ }
+
+ return this.sessions
+ }
+
+ public getSession(serverName: string): MCPSession | null {
+ const session = this.sessions[serverName]
+ if (!session) {
+ return null
+ }
+ return session
+ }
+
+ public getAllActiveSessions(): Record {
+ return Object.fromEntries(
+ this.activeSessions.map(n => [n, this.sessions[n]]),
+ )
+ }
+
+ public async closeSession(serverName: string): Promise {
+ const session = this.sessions[serverName]
+ if (!session) {
+ logger.warn(`No session exists for server ${serverName}, nothing to close`)
+ return
+ }
+ try {
+ logger.debug(`Closing session for server ${serverName}`)
+ await session.disconnect()
+ }
+ catch (e) {
+ logger.error(`Error closing session for server '${serverName}': ${e}`)
+ }
+ finally {
+ delete this.sessions[serverName]
+ this.activeSessions = this.activeSessions.filter(n => n !== serverName)
+ }
+ }
+
+ public async closeAllSessions(): Promise {
+ const serverNames = Object.keys(this.sessions)
+ const errors: string[] = []
+ for (const serverName of serverNames) {
+ try {
+ logger.debug(`Closing session for server ${serverName}`)
+ await this.closeSession(serverName)
+ }
+ catch (e: any) {
+ const errorMsg = `Failed to close session for server '${serverName}': ${e}`
+ logger.error(errorMsg)
+ errors.push(errorMsg)
+ }
+ }
+ if (errors.length) {
+ logger.error(`Encountered ${errors.length} errors while closing sessions`)
+ }
+ else {
+ logger.debug('All sessions closed successfully')
+ }
+ }
+}
diff --git a/src/logging.ts b/src/logging.ts
index 37b8baff..c858864e 100644
--- a/src/logging.ts
+++ b/src/logging.ts
@@ -1,8 +1,22 @@
import type { Logger as WinstonLogger } from 'winston'
-import fs from 'node:fs'
-import path from 'node:path'
import { createLogger, format, transports } from 'winston'
+// Conditional imports for Node.js-only modules
+async function getNodeModules() {
+ if (typeof process !== 'undefined' && process.platform) {
+ try {
+ // Use dynamic imports for Node.js environments
+ const fs = await import('node:fs')
+ const path = await import('node:path')
+ return { fs: fs.default, path: path.default }
+ }
+ catch {
+ return { fs: null, path: null }
+ }
+ }
+ return { fs: null, path: null }
+}
+
const { combine, timestamp, label, printf, colorize, splat } = format
export type LogLevel = 'error' | 'warn' | 'info' | 'http' | 'verbose' | 'debug' | 'silly'
@@ -38,8 +52,7 @@ function isNodeJSEnvironment(): boolean {
// Check for Node.js modules
const hasNodeModules = (
- typeof fs !== 'undefined'
- && typeof createLogger === 'function'
+ typeof createLogger === 'function'
)
return hasNodeGlobals && hasNodeModules
@@ -85,31 +98,31 @@ class SimpleConsoleLogger {
info(message: string): void {
if (this.shouldLog('info')) {
- console.info(this.formatMessage('info', message)) // eslint-disable-line no-console
+ console.info(this.formatMessage('info', message))
}
}
debug(message: string): void {
if (this.shouldLog('debug')) {
- console.debug(this.formatMessage('debug', message)) // eslint-disable-line no-console
+ console.debug(this.formatMessage('debug', message))
}
}
http(message: string): void {
if (this.shouldLog('http')) {
- console.log(this.formatMessage('http', message)) // eslint-disable-line no-console
+ console.log(this.formatMessage('http', message))
}
}
verbose(message: string): void {
if (this.shouldLog('verbose')) {
- console.log(this.formatMessage('verbose', message)) // eslint-disable-line no-console
+ console.log(this.formatMessage('verbose', message))
}
}
silly(message: string): void {
if (this.shouldLog('silly')) {
- console.log(this.formatMessage('silly', message)) // eslint-disable-line no-console
+ console.log(this.formatMessage('silly', message))
}
}
@@ -195,7 +208,7 @@ export class Logger {
}
}
- public static configure(options: LoggerOptions = {}): void {
+ public static async configure(options: LoggerOptions = {}): Promise {
const { level, console = true, file, format = 'minimal' } = options
const debugEnv = (typeof process !== 'undefined' && process.env?.DEBUG) || undefined
const resolvedLevel = level ?? resolveLevel(debugEnv)
@@ -205,16 +218,17 @@ export class Logger {
root.level = resolvedLevel
+ // Winston-specific configuration for Node.js environments
+ const winstonRoot = root as WinstonLogger
+
// For non-Node.js environments, just update the level
if (!isNodeJSEnvironment()) {
Object.values(this.simpleInstances).forEach((logger) => {
logger.level = resolvedLevel
})
+
return
}
-
- // Winston-specific configuration for Node.js environments
- const winstonRoot = root as WinstonLogger
winstonRoot.clear()
if (console) {
@@ -222,11 +236,14 @@ export class Logger {
}
if (file) {
- const dir = path.dirname(path.resolve(file))
- if (!fs.existsSync(dir)) {
- fs.mkdirSync(dir, { recursive: true })
+ const { fs: nodeFs, path: nodePath } = await getNodeModules()
+ if (nodeFs && nodePath) {
+ const dir = nodePath.dirname(nodePath.resolve(file))
+ if (!nodeFs.existsSync(dir)) {
+ nodeFs.mkdirSync(dir, { recursive: true })
+ }
+ winstonRoot.add(new transports.File({ filename: file }))
}
- winstonRoot.add(new transports.File({ filename: file }))
}
// Update all existing Winston loggers with new format
diff --git a/src/managers/server_manager.ts b/src/managers/server_manager.ts
index 84cf9be7..49878592 100644
--- a/src/managers/server_manager.ts
+++ b/src/managers/server_manager.ts
@@ -55,7 +55,7 @@ export class ServerManager {
}))
logger.info(`Server Manager State: [${context}]`)
- console.table(tableData) // eslint-disable-line no-console
+ console.table(tableData)
}
initialize(): void {
diff --git a/src/oauth-helper.ts b/src/oauth-helper.ts
new file mode 100644
index 00000000..0ced866b
--- /dev/null
+++ b/src/oauth-helper.ts
@@ -0,0 +1,546 @@
+/**
+ * OAuth helper for browser-based MCP authentication
+ *
+ * This helper provides OAuth 2.0 authorization code flow support for MCP servers
+ * that require authentication, such as Linear's MCP server.
+ */
+
+export interface OAuthConfig {
+ clientId?: string // Optional - will use dynamic registration if not provided
+ redirectUri: string
+ scope?: string
+ state?: string
+ clientName?: string // For dynamic registration
+}
+
+export interface OAuthDiscovery {
+ issuer: string
+ authorization_endpoint: string
+ token_endpoint: string
+ registration_endpoint?: string
+ response_types_supported: string[]
+ grant_types_supported: string[]
+ code_challenge_methods_supported: string[]
+ token_endpoint_auth_methods_supported?: string[]
+}
+
+export interface ClientRegistration {
+ client_id: string
+ client_secret?: string
+ registration_access_token?: string
+ registration_client_uri?: string
+ client_id_issued_at?: number
+ client_secret_expires_at?: number
+}
+
+export interface OAuthResult {
+ access_token: string
+ token_type: string
+ expires_at?: number | null
+ refresh_token?: string | null
+ scope?: string | null
+}
+
+export interface OAuthState {
+ isRequired: boolean
+ isAuthenticated: boolean
+ isAuthenticating: boolean
+ isCompletingOAuth: boolean
+ authError: string | null
+ oauthTokens: OAuthResult | null
+}
+
+export class OAuthHelper {
+ private config: OAuthConfig
+ private discovery?: OAuthDiscovery
+ private state: OAuthState
+ private clientRegistration?: ClientRegistration
+ private serverUrl?: string
+ private storageKey: string
+
+ constructor(config: OAuthConfig) {
+ this.config = config
+ this.storageKey = `mcp-oauth-${btoa(config.redirectUri)}`
+ this.state = {
+ isRequired: false,
+ isAuthenticated: false,
+ isAuthenticating: false,
+ isCompletingOAuth: false,
+ authError: null,
+ oauthTokens: null,
+ }
+
+ // Load persisted client registration
+ this.loadClientRegistration()
+ }
+
+ /**
+ * Get current OAuth state
+ */
+ getState(): OAuthState {
+ return { ...this.state }
+ }
+
+ /**
+ * Load client registration from localStorage
+ */
+ private loadClientRegistration(): void {
+ try {
+ const stored = localStorage.getItem(this.storageKey)
+ if (stored) {
+ const data = JSON.parse(stored)
+ this.clientRegistration = data.clientRegistration
+ this.serverUrl = data.serverUrl
+ console.log('🔄 [OAuthHelper] Loaded persisted client registration:', {
+ client_id: this.clientRegistration?.client_id,
+ server: this.serverUrl,
+ })
+ }
+ }
+ catch (error) {
+ console.warn('⚠️ [OAuthHelper] Failed to load client registration:', error)
+ }
+ }
+
+ /**
+ * Save client registration to localStorage
+ */
+ private saveClientRegistration(): void {
+ try {
+ const data = {
+ clientRegistration: this.clientRegistration,
+ serverUrl: this.serverUrl,
+ }
+ localStorage.setItem(this.storageKey, JSON.stringify(data))
+ console.log('💾 [OAuthHelper] Saved client registration to localStorage')
+ }
+ catch (error) {
+ console.warn('⚠️ [OAuthHelper] Failed to save client registration:', error)
+ }
+ }
+
+ /**
+ * Check if a server requires authentication by pinging the URL
+ */
+ async checkAuthRequired(serverUrl: string): Promise {
+ console.log('🔍 [OAuthHelper] Checking auth requirement for:', serverUrl)
+
+ try {
+ const response = await fetch(serverUrl, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ },
+ redirect: 'manual',
+ signal: AbortSignal.timeout(10000), // 10 second timeout
+ })
+
+ console.log('🔍 [OAuthHelper] Auth check response:', {
+ status: response.status,
+ statusText: response.statusText,
+ url: serverUrl,
+ })
+
+ // 401 Unauthorized, 403 Forbidden, or 400 Bad Request means auth is required
+ if (response.status === 401 || response.status === 403 || response.status === 400) {
+ console.log('🔐 [OAuthHelper] Authentication required for:', serverUrl)
+ return true
+ }
+
+ // Any other response (200, 404, 500, etc.) means no auth required
+ console.log('✅ [OAuthHelper] No authentication required for:', serverUrl)
+ return false
+ }
+ catch (error: any) {
+ console.warn('⚠️ [OAuthHelper] Could not check auth requirement for:', serverUrl, error)
+
+ // Handle specific error types
+ if (error.name === 'TypeError'
+ && (error.message?.includes('CORS') || error.message?.includes('Failed to fetch'))) {
+ console.log('🔍 [OAuthHelper] CORS blocked direct check, using heuristics for:', serverUrl)
+ return this.checkAuthByHeuristics(serverUrl)
+ }
+
+ if (error.name === 'AbortError') {
+ console.log('⏰ [OAuthHelper] Request timeout, assuming no auth required for:', serverUrl)
+ return false
+ }
+
+ // If we can't reach the server at all, try heuristics
+ return this.checkAuthByHeuristics(serverUrl)
+ }
+ }
+
+ /**
+ * Fallback heuristics for determining auth requirements when direct checking fails
+ */
+ private checkAuthByHeuristics(serverUrl: string): boolean {
+ console.log('🔍 [OAuthHelper] Using heuristics to determine auth for:', serverUrl)
+
+ // Known patterns that typically require auth
+ const authRequiredPatterns = [
+ /api\.githubcopilot\.com/i, // GitHub Copilot
+ /api\.github\.com/i, // GitHub API
+ /.*\.googleapis\.com/i, // Google APIs
+ /api\.openai\.com/i, // OpenAI
+ /api\.anthropic\.com/i, // Anthropic
+ /.*\.atlassian\.net/i, // Atlassian (Jira, Confluence)
+ /.*\.slack\.com/i, // Slack
+ /api\.notion\.com/i, // Notion
+ /api\.linear\.app/i, // Linear
+ ]
+
+ // Known patterns that typically don't require auth (public MCP servers)
+ const noAuthPatterns = [
+ /localhost/i, // Local development
+ /127\.0\.0\.1/, // Local development
+ /\.local/i, // Local development
+ /mcp\..*\.com/i, // Generic MCP server pattern (often public)
+ ]
+
+ // Check no-auth patterns first
+ for (const pattern of noAuthPatterns) {
+ if (pattern.test(serverUrl)) {
+ console.log('✅ [OAuthHelper] Heuristic: No auth required (matches no-auth pattern):', serverUrl)
+ return false
+ }
+ }
+
+ // Check auth-required patterns
+ for (const pattern of authRequiredPatterns) {
+ if (pattern.test(serverUrl)) {
+ console.log('🔐 [OAuthHelper] Heuristic: Auth required (matches auth pattern):', serverUrl)
+ return true
+ }
+ }
+
+ // Default: assume no auth required for unknown patterns
+ console.log('❓ [OAuthHelper] Heuristic: Unknown pattern, assuming no auth required:', serverUrl)
+ return false
+ }
+
+ /**
+ * Discover OAuth configuration from a server
+ */
+ async discoverOAuthConfig(serverUrl: string): Promise {
+ try {
+ const discoveryUrl = `${serverUrl}/.well-known/oauth-authorization-server`
+ const response = await fetch(discoveryUrl)
+
+ if (!response.ok) {
+ throw new Error(`OAuth discovery failed: ${response.status} ${response.statusText}`)
+ }
+
+ this.discovery = await response.json()
+ return this.discovery!
+ }
+ catch (error) {
+ throw new Error(`Failed to discover OAuth configuration: ${error}`)
+ }
+ }
+
+ /**
+ * Register a new OAuth client dynamically
+ */
+ async registerClient(_serverUrl: string): Promise {
+ if (!this.discovery) {
+ throw new Error('OAuth discovery not performed. Call discoverOAuthConfig first.')
+ }
+
+ if (!this.discovery.registration_endpoint) {
+ throw new Error('Server does not support dynamic client registration')
+ }
+
+ try {
+ const registrationData = {
+ client_name: this.config.clientName || 'MCP Use Example',
+ redirect_uris: [this.config.redirectUri],
+ grant_types: ['authorization_code'],
+ response_types: ['code'],
+ token_endpoint_auth_method: 'none', // Use public client (no secret)
+ scope: this.config.scope || 'read write',
+ }
+
+ console.log('🔐 [OAuthHelper] Registering OAuth client dynamically:', {
+ registration_endpoint: this.discovery.registration_endpoint,
+ client_name: registrationData.client_name,
+ redirect_uri: this.config.redirectUri,
+ })
+
+ const response = await fetch(this.discovery.registration_endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(registrationData),
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Client registration failed: ${response.status} ${response.statusText} - ${errorText}`)
+ }
+
+ this.clientRegistration = await response.json()
+ this.serverUrl = serverUrl
+ this.saveClientRegistration()
+
+ console.log('✅ [OAuthHelper] Client registered successfully:', {
+ client_id: this.clientRegistration!.client_id,
+ client_secret: this.clientRegistration!.client_secret ? '***' : 'none',
+ })
+
+ return this.clientRegistration!
+ }
+ catch (error) {
+ console.error('❌ [OAuthHelper] Client registration failed:', error)
+ throw new Error(`Failed to register OAuth client: ${error}`)
+ }
+ }
+
+ /**
+ * Generate authorization URL for OAuth flow
+ */
+ generateAuthUrl(serverUrl: string, additionalParams?: Record): string {
+ if (!this.discovery) {
+ throw new Error('OAuth discovery not performed. Call discoverOAuthConfig first.')
+ }
+
+ if (!this.clientRegistration) {
+ throw new Error('Client not registered. Call registerClient first.')
+ }
+
+ const params = new URLSearchParams({
+ client_id: this.clientRegistration.client_id,
+ redirect_uri: this.config.redirectUri,
+ response_type: 'code',
+ scope: this.config.scope || 'read',
+ state: this.config.state || this.generateState(),
+ ...additionalParams,
+ })
+
+ return `${this.discovery.authorization_endpoint}?${params.toString()}`
+ }
+
+ /**
+ * Exchange authorization code for access token
+ */
+ async exchangeCodeForToken(
+ serverUrl: string,
+ code: string,
+ codeVerifier?: string,
+ ): Promise {
+ if (!this.discovery) {
+ throw new Error('OAuth discovery not performed. Call discoverOAuthConfig first.')
+ }
+
+ if (!this.clientRegistration) {
+ throw new Error('Client not registered. Call registerClient first.')
+ }
+
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: this.clientRegistration.client_id,
+ code,
+ redirect_uri: this.config.redirectUri,
+ })
+
+ if (codeVerifier) {
+ body.append('code_verifier', codeVerifier)
+ }
+
+ const response = await fetch(this.discovery.token_endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: body.toString(),
+ })
+
+ if (!response.ok) {
+ const error = await response.text()
+ throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${error}`)
+ }
+
+ return await response.json()
+ }
+
+ /**
+ * Handle OAuth callback and extract authorization code
+ */
+ handleCallback(): { code: string, state: string } | null {
+ const urlParams = new URLSearchParams(window.location.search)
+ const code = urlParams.get('code')
+ const state = urlParams.get('state')
+
+ if (!code || !state) {
+ return null
+ }
+
+ // Clean up URL
+ const url = new URL(window.location.href)
+ url.searchParams.delete('code')
+ url.searchParams.delete('state')
+ window.history.replaceState({}, '', url.toString())
+
+ return { code, state }
+ }
+
+ /**
+ * Start OAuth flow by opening popup window (similar to your implementation)
+ */
+ async startOAuthFlow(serverUrl: string): Promise {
+ this.setState({
+ isAuthenticating: true,
+ authError: null,
+ })
+
+ try {
+ // Step 1: Discover OAuth configuration
+ await this.discoverOAuthConfig(serverUrl)
+
+ // Step 2: Register client dynamically (if not already registered)
+ if (!this.clientRegistration) {
+ await this.registerClient(serverUrl)
+ }
+
+ // Step 3: Generate authorization URL
+ const authUrl = this.generateAuthUrl(serverUrl)
+
+ // Step 4: Open popup window for authentication
+ const authWindow = window.open(
+ authUrl,
+ 'mcp-oauth',
+ 'width=500,height=600,scrollbars=yes,resizable=yes,status=yes,location=yes',
+ )
+
+ if (!authWindow) {
+ throw new Error('Failed to open authentication window. Please allow popups for this site and try again.')
+ }
+
+ console.log('✅ [OAuthHelper] OAuth popup opened successfully')
+ }
+ catch (error) {
+ console.error('❌ [OAuthHelper] Failed to start OAuth flow:', error)
+ this.setState({
+ isAuthenticating: false,
+ authError: error instanceof Error ? error.message : 'Failed to start authentication',
+ })
+ throw error
+ }
+ }
+
+ /**
+ * Complete OAuth flow by exchanging code for token
+ */
+ async completeOAuthFlow(serverUrl: string, code: string): Promise {
+ this.setState({
+ isCompletingOAuth: true,
+ authError: null,
+ })
+
+ try {
+ // If we don't have discovery data, re-discover it
+ if (!this.discovery) {
+ console.log('🔍 [OAuthHelper] Re-discovering OAuth configuration for callback')
+ await this.discoverOAuthConfig(serverUrl)
+ }
+
+ // Only re-register if we don't have a client registration for this server
+ if (!this.clientRegistration || this.serverUrl !== serverUrl) {
+ console.log('🔐 [OAuthHelper] Re-registering client for callback')
+ await this.registerClient(serverUrl)
+ }
+ else {
+ console.log('🔄 [OAuthHelper] Using existing client registration for callback')
+ }
+
+ const tokenResponse = await this.exchangeCodeForToken(serverUrl, code)
+
+ this.setState({
+ isAuthenticating: false,
+ isAuthenticated: true,
+ isCompletingOAuth: false,
+ authError: null,
+ oauthTokens: tokenResponse,
+ })
+
+ console.log('✅ [OAuthHelper] OAuth flow completed successfully')
+ return tokenResponse
+ }
+ catch (error) {
+ console.error('❌ [OAuthHelper] Failed to complete OAuth flow:', error)
+ this.setState({
+ isAuthenticating: false,
+ isCompletingOAuth: false,
+ authError: error instanceof Error ? error.message : 'Failed to complete authentication',
+ })
+ throw error
+ }
+ }
+
+ /**
+ * Reset authentication state
+ */
+ resetAuth(): void {
+ this.setState({
+ isRequired: false,
+ isAuthenticated: false,
+ isAuthenticating: false,
+ isCompletingOAuth: false,
+ authError: null,
+ oauthTokens: null,
+ })
+
+ // Clear stored client registration
+ this.clientRegistration = undefined
+ this.serverUrl = undefined
+ try {
+ localStorage.removeItem(this.storageKey)
+ console.log('🗑️ [OAuthHelper] Cleared stored client registration')
+ }
+ catch (error) {
+ console.warn('⚠️ [OAuthHelper] Failed to clear client registration:', error)
+ }
+ }
+
+ /**
+ * Set OAuth state (internal method)
+ */
+ private setState(newState: Partial): void {
+ this.state = { ...this.state, ...newState }
+ }
+
+ /**
+ * Generate a random state parameter for CSRF protection
+ */
+ private generateState(): string {
+ return Math.random().toString(36).substring(2, 15)
+ + Math.random().toString(36).substring(2, 15)
+ }
+}
+
+/**
+ * Linear-specific OAuth configuration
+ */
+export const LINEAR_OAUTH_CONFIG: OAuthConfig = {
+ // No clientId needed - will use dynamic client registration
+ redirectUri: typeof window !== 'undefined' ? window.location.origin + window.location.pathname : 'http://localhost:5174',
+ scope: 'read write',
+ clientName: 'MCP Use Example',
+}
+
+/**
+ * Helper function to create OAuth-enabled MCP configuration
+ */
+export function createOAuthMCPConfig(serverUrl: string, accessToken: string) {
+ return {
+ mcpServers: {
+ linear: {
+ url: serverUrl,
+ authToken: accessToken,
+ transport: 'sse',
+ },
+ },
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 8611fad1..bb579910 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,5 +14,5 @@
"skipLibCheck": true
},
"include": ["**/*.ts"],
- "exclude": ["node_modules", "dist"]
+ "exclude": ["node_modules", "dist", "examples"]
}