From cb04abb6af0a85dabe112aa069bbcb92993a4211 Mon Sep 17 00:00:00 2001 From: alperdegre Date: Sat, 6 Sep 2025 05:41:39 +0300 Subject: [PATCH 1/6] Implement OAuth authentication and authorization / custom handlers for tools to format them properly --- packages/mcp-server/src/auth.ts | 139 +++++++++++++++ packages/mcp-server/src/http.ts | 85 ++++------ packages/mcp-server/src/server.ts | 7 + packages/mcp-server/src/stdio.ts | 25 ++- .../accounts/get-accounts-handler.ts | 34 ++++ .../tool-handlers/app/open-in-app-handler.ts | 25 +++ .../chats/archive-chat-handler.ts | 18 ++ .../tool-handlers/chats/get-chat-handler.ts | 23 +++ .../reminders/clear-chat-reminder-handler.ts | 18 ++ .../reminders/set-chat-reminder-handler.ts | 18 ++ .../chats/search-chats-handler.ts | 39 +++++ .../download-attachment-handler.ts | 25 +++ .../messages/search-messages-handler.ts | 158 ++++++++++++++++++ .../messages/send-message-handler.ts | 22 +++ .../mcp-server/src/tool-handlers/types.ts | 22 +++ .../mcp-server/src/tool-handlers/utils.ts | 122 ++++++++++++++ packages/mcp-server/src/tools.ts | 45 ++++- packages/mcp-server/src/tools/types.ts | 1 + packages/mcp-server/yarn.lock | 23 ++- 19 files changed, 791 insertions(+), 58 deletions(-) create mode 100644 packages/mcp-server/src/auth.ts create mode 100644 packages/mcp-server/src/tool-handlers/accounts/get-accounts-handler.ts create mode 100644 packages/mcp-server/src/tool-handlers/app/open-in-app-handler.ts create mode 100644 packages/mcp-server/src/tool-handlers/chats/archive-chat-handler.ts create mode 100644 packages/mcp-server/src/tool-handlers/chats/get-chat-handler.ts create mode 100644 packages/mcp-server/src/tool-handlers/chats/reminders/clear-chat-reminder-handler.ts create mode 100644 packages/mcp-server/src/tool-handlers/chats/reminders/set-chat-reminder-handler.ts create mode 100644 packages/mcp-server/src/tool-handlers/chats/search-chats-handler.ts create mode 100644 packages/mcp-server/src/tool-handlers/messages/attachments/download-attachment-handler.ts create mode 100644 packages/mcp-server/src/tool-handlers/messages/search-messages-handler.ts create mode 100644 packages/mcp-server/src/tool-handlers/messages/send-message-handler.ts create mode 100644 packages/mcp-server/src/tool-handlers/types.ts create mode 100644 packages/mcp-server/src/tool-handlers/utils.ts diff --git a/packages/mcp-server/src/auth.ts b/packages/mcp-server/src/auth.ts new file mode 100644 index 0000000..8b21e96 --- /dev/null +++ b/packages/mcp-server/src/auth.ts @@ -0,0 +1,139 @@ +import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; +import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; +import { readEnv } from './server'; +import express from 'express'; +import { fromError } from 'zod-validation-error/v3'; + +export const baseURL = readEnv('BEEPER_DESKTOP_BASE_URL') || 'http://localhost:23373'; + +export const getMcpUrl = (port: number | string | undefined, socket?: string): string => { + if (socket) { + return `http://unix:${socket}`; + } + + const actualPort = port || 3000; + return `http://localhost:${actualPort}`; +}; + +export const createProxyProvider = (port: number | string | undefined): ProxyOAuthServerProvider => { + const mcpUrl = getMcpUrl(port); + + return new ProxyOAuthServerProvider({ + endpoints: { + authorizationUrl: `${baseURL}/oauth/authorize`, + tokenUrl: `${baseURL}/oauth/token`, + revocationUrl: `${baseURL}/oauth/revoke`, + registrationUrl: `${baseURL}/oauth/register`, + }, + verifyAccessToken: async (token: string) => { + try { + const response = await fetch(`${baseURL}/v0/mcp/validate`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + console.log('Token validation response status:', response.status); + + if (!response.ok) { + return { + token, + clientId: 'unknown', + scopes: ['read'], + }; + } + + const tokenData: any = await response.json(); + + return { + token, + clientId: tokenData.clientInfo?.name || 'unknown', + scopes: tokenData.scopes || ['read'], + }; + } catch (error) { + console.error('Token validation failed:', error); + return { + token, + clientId: 'unknown', + scopes: ['read'], + }; + } + }, + getClient: async (client_id: string) => { + return { + client_id, + redirect_uris: [ + mcpUrl, + 'http://localhost:6274/oauth/callback/debug', + 'http://localhost:6274/oauth/callback', + ], + }; + }, + }); +}; + +export const createMCPAuthRouter = (port: number | string | undefined): express.RequestHandler => { + const proxyProvider = createProxyProvider(port); + const mcpUrl = getMcpUrl(port); + + return mcpAuthRouter({ + provider: proxyProvider, + issuerUrl: new URL(baseURL), + baseUrl: new URL(mcpUrl), + }); +}; + +export const customWellKnownEndpoint = ( + req: express.Request, + res: express.Response, + port: number | string | undefined, +) => { + const mcpUrl = getMcpUrl(port); + + res.json({ + resource: mcpUrl, + authorization_servers: [baseURL], + }); +}; + +export const sendUnauthorizedResponse = ( + res: express.Response, + port: number | string | undefined, + error?: any, +) => { + const resourceIdentifier = getMcpUrl(port); + const wwwAuth = `Bearer resource="${resourceIdentifier}", resource_metadata="${resourceIdentifier}.well-known/oauth-protected-resource"`; + + res.set('WWW-Authenticate', wwwAuth); + res.status(401).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Invalid request: ${fromError(error)}`, + }, + }); +}; + +export const getTokenForStdio = async (): Promise => { + const response = await fetch(`${baseURL}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: 'stdio-mcp-client', + // client_secret: process.env.MCP_CLIENT_SECRET || '', + scope: 'read write', + }), + }); + + if (!response.ok) { + throw new Error(`Failed to get token: ${response.status}`); + } + + const data = await response.json(); + return (data as any).access_token; +}; diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index 3165f91..852ad74 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -1,28 +1,21 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; - -import cors from 'cors'; import express from 'express'; import { fromError } from 'zod-validation-error/v3'; import { McpOptions, parseQueryOptions } from './options'; import { initMcpServer, newMcpServer } from './server'; import { parseAuthHeaders } from './headers'; - -const oauthResourceIdentifier = (req: express.Request): string => { - const protocol = req.headers['x-forwarded-proto'] ?? req.protocol; - return `${protocol}://${req.get('host')}/`; -}; +import { createMCPAuthRouter, customWellKnownEndpoint, sendUnauthorizedResponse } from './auth'; const newServer = ( defaultMcpOptions: McpOptions, req: express.Request, res: express.Response, + port: number | string | undefined, ): McpServer | null => { const server = newMcpServer(); - let mcpOptions: McpOptions; + try { mcpOptions = parseQueryOptions(defaultMcpOptions, req.query); } catch (error) { @@ -38,6 +31,12 @@ const newServer = ( try { const authOptions = parseAuthHeaders(req); + + if (!authOptions.accessToken) { + sendUnauthorizedResponse(res, port); + return null; + } + initMcpServer({ server: server, clientOptions: { @@ -48,36 +47,29 @@ const newServer = ( }, mcpOptions, }); - } catch { - const resourceIdentifier = oauthResourceIdentifier(req); - res.set( - 'WWW-Authenticate', - `Bearer resource_metadata="${resourceIdentifier}.well-known/oauth-protected-resource"`, - ); - res.status(401).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Unauthorized', - }, - }); + } catch (error) { + sendUnauthorizedResponse(res, port); return null; } return server; }; -const post = (defaultOptions: McpOptions) => async (req: express.Request, res: express.Response) => { - const server = newServer(defaultOptions, req, res); - // If we return null, we already set the authorization error. - if (server === null) return; - const transport = new StreamableHTTPServerTransport({ - // Stateless server - sessionIdGenerator: undefined, - }); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); -}; +const post = + (defaultOptions: McpOptions, port: number | string | undefined) => + async (req: express.Request, res: express.Response) => { + const server = newServer(defaultOptions, req, res, port); + + if (server === null) return; + + const transport = new StreamableHTTPServerTransport({ + // Stateless server + sessionIdGenerator: undefined, + }); + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + }; const get = async (req: express.Request, res: express.Response) => { res.status(405).json({ @@ -99,31 +91,28 @@ const del = async (req: express.Request, res: express.Response) => { }); }; -const oauthMetadata = (req: express.Request, res: express.Response) => { - const resourceIdentifier = oauthResourceIdentifier(req); - res.json({ - resource: resourceIdentifier, - authorization_servers: ['http://localhost:23373/oauth/authorize'], - bearer_methods_supported: ['header'], - scopes_supported: 'read write', - }); -}; - -export const streamableHTTPApp = (options: McpOptions): express.Express => { +export const streamableHTTPApp = ( + options: McpOptions, + port: number | string | undefined, +): express.Express => { const app = express(); + app.set('query parser', 'extended'); app.use(express.json()); - app.get('/.well-known/oauth-protected-resource', cors(), oauthMetadata); + const beeperMCPAuthRouter = createMCPAuthRouter(port); + app.get('/.well-known/oauth-protected-resource', (req, res) => customWellKnownEndpoint(req, res, port)); + app.use(beeperMCPAuthRouter); + app.get('/', get); - app.post('/', post(options)); + app.post('/', post(options, port)); app.delete('/', del); return app; }; export const launchStreamableHTTPServer = async (options: McpOptions, port: number | string | undefined) => { - const app = streamableHTTPApp(options); + const app = streamableHTTPApp(options, port); const server = app.listen(port); const address = server.address(); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 54ffb6c..de240c6 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -7,6 +7,7 @@ import { CallToolRequestSchema, Implementation, ListToolsRequestSchema, + SetLevelRequestSchema, Tool, } from '@modelcontextprotocol/sdk/types.js'; import { ClientOptions } from '@beeper/desktop-api'; @@ -119,6 +120,12 @@ export function initMcpServer(params: { return executeHandler(endpoint.tool, endpoint.handler, client, args, mcpOptions.capabilities); }); + + server.setRequestHandler(SetLevelRequestSchema, async (request) => { + const { level } = request.params; + logger.info(`Log level set to: ${level}`); + return {}; + }); } /** diff --git a/packages/mcp-server/src/stdio.ts b/packages/mcp-server/src/stdio.ts index d902a5b..4a683c1 100644 --- a/packages/mcp-server/src/stdio.ts +++ b/packages/mcp-server/src/stdio.ts @@ -1,13 +1,28 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { initMcpServer, newMcpServer } from './server'; import { McpOptions } from './options'; +import { getTokenForStdio } from './auth'; export const launchStdioServer = async (options: McpOptions) => { - const server = newMcpServer(); + try { + const token = await getTokenForStdio(); + const server = newMcpServer(); - initMcpServer({ server, mcpOptions: options }); + initMcpServer({ + server, + clientOptions: { + defaultHeaders: { + Authorization: `Bearer ${token}`, + }, + }, + mcpOptions: options, + }); - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('MCP Server running on stdio'); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('MCP Server running on stdio'); + } catch (error) { + console.error('Failed to obtain access token:', error); + process.exit(1); + } }; diff --git a/packages/mcp-server/src/tool-handlers/accounts/get-accounts-handler.ts b/packages/mcp-server/src/tool-handlers/accounts/get-accounts-handler.ts new file mode 100644 index 0000000..2d73d47 --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/accounts/get-accounts-handler.ts @@ -0,0 +1,34 @@ +import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types'; + +const CONTACT_SUPPORT = + 'Something unexpected happened. User might need to contact support at mailto:help@beeper.com?subject=Something%20wrong%20in%20the%20Beeper%20Desktop%20API'; + +export const getAccountsHandler: CustomHandlerFunction = async (client, args) => { + const output = await client.accounts.list(); + + if (!output || output.length === 0) { + return asFormattedMCPContentResult(`No accounts found. ${CONTACT_SUPPORT}`); + } + + const lines: string[] = []; + lines.push('# Accounts'); + for (const acc of output) { + if (!acc.user) { + lines.push(`\n## ${acc.network}`); + lines.push(`**Account ID**: \`${acc.accountID}\``); + lines.push('**User**: Unknown'); + continue; + } + + const name = acc.user.fullName || acc.user.username || acc.user.id; + lines.push(`\n## ${acc.network}`); + lines.push(`**Account ID**: \`${acc.accountID}\``); + lines.push(`**User**: ${name}`); + if (acc.user.email) lines.push(`**Email**: ${acc.user.email}`); + if (acc.user.phoneNumber) lines.push(`**Phone**: ${acc.user.phoneNumber}`); + } + lines.push('\n# Using this information\n'); + lines.push('- Pass accountIDs to narrow chat/message queries when known.'); + + return asFormattedMCPContentResult(lines.join('\n')); +}; diff --git a/packages/mcp-server/src/tool-handlers/app/open-in-app-handler.ts b/packages/mcp-server/src/tool-handlers/app/open-in-app-handler.ts new file mode 100644 index 0000000..f6f9d69 --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/app/open-in-app-handler.ts @@ -0,0 +1,25 @@ +import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types'; + +export const openInAppHandler: CustomHandlerFunction = async (client, args) => { + const currArgs = args as any; + const output = await client.app.open(currArgs); + + const lines: string[] = []; + lines.push('# App'); + if (output.success) { + lines.push('Beeper was opened.'); + if (currArgs?.chatID) { + const chatRef = String(currArgs.chatID); + lines.push(`Focused chat: ${chatRef}`); + } + if (currArgs?.draftText) { + lines.push('Draft text populated.'); + } + } else { + lines.push('Failed to open Beeper.'); + } + lines.push('\n# Using this information\n'); + lines.push('- Use search_chats or get_chat to retrieve chat context.'); + + return asFormattedMCPContentResult(lines.join('\n')); +}; diff --git a/packages/mcp-server/src/tool-handlers/chats/archive-chat-handler.ts b/packages/mcp-server/src/tool-handlers/chats/archive-chat-handler.ts new file mode 100644 index 0000000..bc76cc2 --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/chats/archive-chat-handler.ts @@ -0,0 +1,18 @@ +import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types'; + +export const archiveChatHandler: CustomHandlerFunction = async (client, args) => { + const currArgs = args as any; + const output = await client.chats.archive(currArgs); + + const lines: string[] = []; + lines.push('# Archive Chat'); + if (output.success) { + lines.push(`Chat ${currArgs?.chatID} ${currArgs?.archived === false ? 'unarchived' : 'archived'}.`); + } else { + lines.push('Failed to update archive state.'); + } + lines.push('\n# Using this information\n'); + lines.push('- Use search_chats to verify the chat moved to the expected inbox.'); + + return asFormattedMCPContentResult(lines.join('\n')); +}; diff --git a/packages/mcp-server/src/tool-handlers/chats/get-chat-handler.ts b/packages/mcp-server/src/tool-handlers/chats/get-chat-handler.ts new file mode 100644 index 0000000..b81a676 --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/chats/get-chat-handler.ts @@ -0,0 +1,23 @@ +import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types'; +import { formatChatToMarkdown } from '../utils'; + +export const getChatHandler: CustomHandlerFunction = async (client, args) => { + const currArgs = args as any; + const chat = await client.chats.retrieve(currArgs); + + const lines: string[] = []; + if (!chat) { + lines.push('# Chat'); + lines.push('Not found.'); + return asFormattedMCPContentResult(lines.join('\n')); + } + + for (const line of formatChatToMarkdown(chat, client.baseURL)) { + lines.push(line); + } + lines.push('\n# Using this information\n'); + lines.push('- Use search_messages to find specific content in this chat.'); + lines.push('- Link the "open" link to the user to allow them to view the chat in Beeper Desktop.'); + + return asFormattedMCPContentResult(lines.join('\n')); +}; diff --git a/packages/mcp-server/src/tool-handlers/chats/reminders/clear-chat-reminder-handler.ts b/packages/mcp-server/src/tool-handlers/chats/reminders/clear-chat-reminder-handler.ts new file mode 100644 index 0000000..0978234 --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/chats/reminders/clear-chat-reminder-handler.ts @@ -0,0 +1,18 @@ +import { CustomHandlerFunction, asFormattedMCPContentResult } from '../../types'; + +export const clearChatReminderHandler: CustomHandlerFunction = async (client, args) => { + const currArgs = args as any; + const output = await client.chats.reminders.delete(currArgs); + + const lines: string[] = []; + lines.push('# Clear Reminder'); + if (output.success) { + lines.push(`Reminder cleared for chat ${currArgs?.chatID}.`); + } else { + lines.push('Failed to clear reminder.'); + } + lines.push('\n# Using this information\n'); + lines.push('- You can set another reminder with set_chat_reminder.'); + + return asFormattedMCPContentResult(lines.join('\n')); +}; diff --git a/packages/mcp-server/src/tool-handlers/chats/reminders/set-chat-reminder-handler.ts b/packages/mcp-server/src/tool-handlers/chats/reminders/set-chat-reminder-handler.ts new file mode 100644 index 0000000..02d9a1d --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/chats/reminders/set-chat-reminder-handler.ts @@ -0,0 +1,18 @@ +import { CustomHandlerFunction, asFormattedMCPContentResult } from '../../types'; + +export const setChatReminderHandler: CustomHandlerFunction = async (client, args) => { + const currArgs = args as any; + const output = await client.chats.reminders.create(currArgs); + + const lines: string[] = []; + lines.push('# Set Reminder'); + if (output.success) { + lines.push(`Reminder set for chat ${currArgs?.chatID} at ${currArgs?.reminder?.remindAtMs}.`); + } else { + lines.push('Failed to set reminder.'); + } + lines.push('\n# Using this information\n'); + lines.push('- Use clear_chat_reminder to remove it later.'); + + return asFormattedMCPContentResult(lines.join('\n')); +}; diff --git a/packages/mcp-server/src/tool-handlers/chats/search-chats-handler.ts b/packages/mcp-server/src/tool-handlers/chats/search-chats-handler.ts new file mode 100644 index 0000000..1022b2c --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/chats/search-chats-handler.ts @@ -0,0 +1,39 @@ +import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types'; +import { formatChatToMarkdown } from '../utils'; + +export const searchChatsHandler: CustomHandlerFunction = async (client, args) => { + const output = await client.chats.search(args); + + const lines: string[] = []; + lines.push('# Chats'); + + const items = output.items || []; + const hasMore = !!output.hasMore; + + if (hasMore) { + lines.push(`\nFound ${items.length}+ chats (showing ${items.length})`); + if (output.oldestCursor) { + lines.push(`Next page (older): cursor='${output.oldestCursor}', direction='before'`); + } + if (output.newestCursor) { + lines.push(`Previous page (newer): cursor='${output.newestCursor}', direction='after'`); + } + } else if (items.length > 0) { + lines.push(`\nFound ${items.length} chat${items.length === 1 ? '' : 's'}`); + } + + if (items.length === 0) { + lines.push('\nNo chats found.'); + } else { + for (const chat of items) { + lines.push(...formatChatToMarkdown(chat, client.baseURL)); + } + } + lines.push('\n# Using this information\n'); + lines.push( + '- Pass the "chatID" to get_chat or search_messages for details about a chat, or send_message to send a message to a chat.', + ); + lines.push('- Link the "open" link to the user to allow them to view the chat in Beeper Desktop.'); + + return asFormattedMCPContentResult(lines.join('\n')); +}; diff --git a/packages/mcp-server/src/tool-handlers/messages/attachments/download-attachment-handler.ts b/packages/mcp-server/src/tool-handlers/messages/attachments/download-attachment-handler.ts new file mode 100644 index 0000000..ed8bfa1 --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/messages/attachments/download-attachment-handler.ts @@ -0,0 +1,25 @@ +import { CustomHandlerFunction, asFormattedMCPContentResult } from '../../types'; + +export const downloadAttachmentHandler: CustomHandlerFunction = async (client, args) => { + const currArgs = args as any; + const output = await client.messages.attachments.download(currArgs); + + const lines: string[] = []; + lines.push('# Attachment'); + if (output.success && output.filePath) { + lines.push(`**Path**: ${output.filePath}`); + if (currArgs?.chatID && currArgs?.messageID) { + const local = String(currArgs.chatID); + // ChatID here may be localChatID or global; link uses localChatID if provided + const attachmentURL = + /^\d+$/.test(local) ? `beeper-mcp://attachments/${local}/${currArgs.messageID}/0` : undefined; + if (attachmentURL) lines.push(`**MCP URL**: ${attachmentURL}`); + } + } else { + lines.push(`Failed to download${output.error ? `: ${output.error}` : ''}.`); + } + lines.push('\n# Using this information\n'); + lines.push('- Use the file path to open or process the file locally.'); + + return asFormattedMCPContentResult(lines.join('\n')); +}; diff --git a/packages/mcp-server/src/tool-handlers/messages/search-messages-handler.ts b/packages/mcp-server/src/tool-handlers/messages/search-messages-handler.ts new file mode 100644 index 0000000..05137ca --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/messages/search-messages-handler.ts @@ -0,0 +1,158 @@ +import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types'; +import { asTextContentResult } from '@beeper/desktop-mcp/tools/types'; +import { + formatParticipantsToMarkdown, + formatReactionsToMarkdown, + formatRelativeDate, + createOpenLink, +} from '../utils'; + +export const searchMessagesHandler: CustomHandlerFunction = async (client, args) => { + const currArgs = args as any; + const output = await client.messages.search(currArgs); + // const { items, chats, hasMore } = output; + const response = await client.messages.search(currArgs).asResponse(); + // const messageCount = items.length; + // const chatCount = Object.keys(chats).length; + + // // Determine if search filters would cause gaps in timeline + // const hasGapCausingFilters = + // currArgs && + // (currArgs.query || + // currArgs.sender || + // currArgs.onlyWithMedia || + // currArgs.onlyWithVideo || + // currArgs.onlyWithImage || + // currArgs.onlyWithLink || + // currArgs.onlyWithFile); + + // const paginationInfo: string[] = []; + // if (messageCount === 0) { + // if (!chats || chatCount === 0) { + // paginationInfo.push('No matching chats found'); + // } else { + // paginationInfo.push(`No messages found in ${chatCount} chat${chatCount === 1 ? '' : 's'}`); + // } + // } else if (hasMore) { + // paginationInfo.push( + // `Found ${messageCount}+ messages across ${chatCount} chat${chatCount === 1 ? '' : 's'} (showing ${messageCount})`, + // ); + // if (output.oldestCursor) { + // paginationInfo.push(`Next page (older): cursor='${output.oldestCursor}', direction='before'`); + // } + // if (output.newestCursor) { + // paginationInfo.push(`Previous page (newer): cursor='${output.newestCursor}', direction='after'`); + // } + // } else { + // paginationInfo.push( + // `Found ${messageCount} message${messageCount === 1 ? '' : 's'} across ${chatCount} chat${chatCount === 1 ? '' : 's'} (complete)`, + // ); + // } + + // if (hasGapCausingFilters && messageCount > 0) { + // paginationInfo.push('⚠️ Filtered results: only showing messages matching your search criteria.'); + // } + + // const messagesByChat = new Map(); + // for (const message of items) { + // const chatMessages = messagesByChat.get(message.chatID) || []; + // chatMessages.push(message); + // messagesByChat.set(message.chatID, chatMessages); + // } + + // const chatSummaries: string[] = []; + // for (const [chatID, messages] of messagesByChat) { + // const chat = chats[chatID]; + // if (chat) { + // chatSummaries.push(`# ${chat.title} [${messages.length} message${messages.length === 1 ? '' : 's'}]`); + // } + // } + + // const headerLines = [...paginationInfo, '', ...chatSummaries]; + + // const chatSections: string[] = []; + + // for (const [chatID, messages] of messagesByChat) { + // const chat = chats[chatID]; + // if (!chat) continue; + + // const participantList = + // chat.participants?.items ? formatParticipantsToMarkdown(chat.participants.items, 3) : ''; + // const participantInfo = participantList ? ` with ${participantList}` : ''; + // const openURL = ctx?.apiBaseURL ? createOpenLink(ctx.apiBaseURL, chat.localChatID ?? chat.id) : undefined; + // const title = openURL ? `[${chat.title}](${openURL})` : chat.title; + // chatSections.push(`# ${title} (chatID: ${chat.localChatID})`); + // chatSections.push(`Chat on ${chat.network}${participantInfo}.`); + // chatSections.push(''); + + // const messagesByDate = new Map(); + // for (const message of messages) { + // const date = new Date(message.timestamp); + // const dateKey = date.toISOString().split('T')[0]; + // const dateMessages = messagesByDate.get(dateKey) || []; + // dateMessages.push(message); + // messagesByDate.set(dateKey, dateMessages); + // } + + // const sortedDates = Array.from(messagesByDate.keys()).sort(); + // const participantMap = + // chat?.participants?.items ? new Map(chat.participants.items.map((p: any) => [p.id, p])) : undefined; + + // for (let i = 0; i < sortedDates.length; i++) { + // const dateKey = sortedDates[i]; + // const dateObj = new Date(dateKey); + // const relativeTime = formatRelativeDate(dateObj); + // chatSections.push(`## ${relativeTime} (${dateKey})`); + // chatSections.push(''); + + // const dateMessages = messagesByDate.get(dateKey) || []; + // dateMessages.sort( + // (a: any, b: any) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + // ); + + // for (const message of dateMessages) { + // const time = new Date(message.timestamp); + // const hours = time.getHours().toString().padStart(2, '0'); + // const minutes = time.getMinutes().toString().padStart(2, '0'); + // const timeStr = `${hours}:${minutes}`; + + // const baseSenderName = message.senderName || message.senderID; + // const senderName = message.isSender ? `${baseSenderName} (You)` : baseSenderName; + // const text = message.text || ''; + // const attachment = message.attachments?.[0]; + // const attachmentLink = + // attachment && typeof chat.localChatID === 'number' ? + // `\n📎 [${attachment.fileName || 'attachment'}](beeper-mcp://attachments/${chat.localChatID}/${message.messageID}/0)` + // : ''; + // const reactionsStr = formatReactionsToMarkdown(message.reactions, participantMap); + + // const sortKeyLink = + // chat.localChatID ? + // `([open at sort key](${createOpenLink(ctx?.apiBaseURL || '', chat.localChatID, String(message.sortKey))}))` + // : `(sortKey: ${message.sortKey})`; + // const messageStr = `**${senderName}** (${timeStr}): ${text}${attachmentLink}${reactionsStr} ${sortKeyLink}`; + + // chatSections.push(messageStr); + // chatSections.push(''); + // } + + // // Add date gap indicator when dates are not consecutive + // if (i < sortedDates.length - 1) { + // const nextDateKey = sortedDates[i + 1]; + // const currentDate = new Date(dateKey); + // const nextDate = new Date(nextDateKey); + // const dayDiff = Math.floor((nextDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)); + + // if (dayDiff > 1) { + // const gapDays = dayDiff - 1; + // chatSections.push(`*[... ${gapDays} day${gapDays === 1 ? '' : 's'} gap ...]*`); + // chatSections.push(''); + // } + // } + // } + // } + + // return asFormattedMCPContentResult([...headerLines, '', ...chatSections].join('\n')); + + return asTextContentResult(await response.json()); +}; diff --git a/packages/mcp-server/src/tool-handlers/messages/send-message-handler.ts b/packages/mcp-server/src/tool-handlers/messages/send-message-handler.ts new file mode 100644 index 0000000..0dac750 --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/messages/send-message-handler.ts @@ -0,0 +1,22 @@ +import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types'; +import { createOpenLink } from '../utils'; + +export const sendMessageHandler: CustomHandlerFunction = async (client, args) => { + const currArgs = args as any; + const output = await client.messages.send(currArgs); + + const lines: string[] = []; + lines.push('# Message Sent'); + if (output.success) { + lines.push(`**Message ID**: ${output.messageID}`); + const deeplink = + client.baseURL ? createOpenLink(client.baseURL, currArgs?.chatID ?? '', output.messageID) : undefined; + if (deeplink) lines.push(`**Open in Beeper**: ${deeplink}`); + } else { + lines.push('Failed to send message.'); + } + lines.push('\n# Using this information\n'); + lines.push('- Use get_chat to view the conversation, or search_messages for context.'); + + return asFormattedMCPContentResult(lines.join('\n')); +}; diff --git a/packages/mcp-server/src/tool-handlers/types.ts b/packages/mcp-server/src/tool-handlers/types.ts new file mode 100644 index 0000000..d9adfe5 --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/types.ts @@ -0,0 +1,22 @@ +import BeeperDesktop from '@beeper/desktop-api'; +import { ToolCallResult } from '@beeper/desktop-mcp/tools/types'; + +export interface HandlerContext { + apiBaseURL?: string; +} + +export type CustomHandlerFunction = ( + client: BeeperDesktop, + args: Record | undefined, +) => Promise; + +export function asFormattedMCPContentResult(result: string): ToolCallResult { + return { + content: [ + { + type: 'text', + text: result, + }, + ], + }; +} diff --git a/packages/mcp-server/src/tool-handlers/utils.ts b/packages/mcp-server/src/tool-handlers/utils.ts new file mode 100644 index 0000000..2d9b53a --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/utils.ts @@ -0,0 +1,122 @@ +import { Chat } from '@beeper/desktop-api/resources/index'; +import { Message, Reaction, User } from '@beeper/desktop-api/resources/shared'; + +// TODO : Participants type missing +export const getParticipantName = (participant: any, preferFirstName?: boolean): string | null => { + return participant ? + participant.nickname || + (preferFirstName ? participant.fullName?.split(' ')[0] : participant.fullName) || + participant.username || + participant.email || + participant.id + : null; +}; + +const skinToneRegex = /\uD83C[\uDFFB-\uDFFF]/g; +export const removeSkinTone = (emojiString: string): string => emojiString.replace(skinToneRegex, ''); + +export function groupReactions(reactions: Reaction[]): { [key: string]: Reaction[] } { + const map: { [key: string]: Reaction[] } = {}; + reactions.forEach((reaction) => { + if (!reaction.reactionKey) return; + const key = removeSkinTone(reaction.reactionKey); + map[key] ||= []; + map[key].push(reaction); + }); + return map; +} + +export const createOpenLink = (baseURL: string, localChatIDOrChatID: string, messageKey?: string) => + `${baseURL}/open/${encodeURIComponent(localChatIDOrChatID)}${messageKey ? `/${messageKey}` : ''}`; + +export const formatRelativeDate = (date: Date) => { + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const oneDay = 24 * 60 * 60 * 1000; + + if (diff < oneDay) { + return 'Today'; + } else if (diff < 2 * oneDay) { + return 'Yesterday'; + } else if (diff < 7 * oneDay) { + return `${Math.floor(diff / oneDay)} days ago`; + } else { + return date.toLocaleDateString(); + } +}; + +export const formatReactionsToMarkdown = ( + reactions: Message['reactions'], + participants?: Map, +): string => { + if (!reactions || reactions.length === 0) return ''; + + const reactionMap = groupReactions(reactions); + const reactionParts: string[] = []; + for (const [reactionKey, reactionList] of Object.entries(reactionMap)) { + const count = reactionList.length; + const reactorNames = reactionList + .slice(0, 5) + .map((r) => { + if (!r.participantID) return null; + const participant = participants?.get(r.participantID); + return participant ? + getParticipantName({ ...participant, fullName: participant.fullName ?? undefined }) + : r.participantID; + }) + .filter(Boolean); + + let reactorInfo = ''; + if (reactorNames.length > 0) { + if (count > 5) { + const othersCount = count - 5; + reactorInfo = ` (${reactorNames.join(', ')} & ${othersCount} other${othersCount === 1 ? '' : 's'})`; + } else { + reactorInfo = ` (${reactorNames.join(', ')})`; + } + } + + reactionParts.push(`${reactionKey} ${count}${reactorInfo}`); + } + + return reactionParts.length > 0 ? ` [${reactionParts.join(' ')}]` : ''; +}; + +export const formatParticipantsToMarkdown = (participants: User[] | undefined, limit = 3): string => { + if (!participants || participants.length === 0) return ''; + + const names = participants + .slice(0, limit) + .map((p) => p.fullName || p.username || p.id) + .filter(Boolean); + + if (participants.length > limit) { + const othersCount = participants.length - limit; + names.push(`& ${othersCount} other${othersCount === 1 ? '' : 's'}`); + } + + return names.join(', '); +}; + +export const formatChatToMarkdown = (chat: Chat, baseURL: string | undefined) => { + const openURL = baseURL ? createOpenLink(baseURL, chat.localChatID ?? chat.id) : undefined; + const title = openURL ? `[${chat.title}](${openURL})` : chat.title; + const participantList = + chat.participants?.items ? formatParticipantsToMarkdown(chat.participants.items, 3) : ''; + const participantInfo = participantList ? ` with ${participantList}` : ''; + const lines: string[] = []; + lines.push(`\n## ${title} (chatID: ${chat.localChatID})`); + let chatLine = `Chat on ${chat.network}${participantInfo}.`; + if (typeof chat.unreadCount === 'number' && chat.unreadCount > 0) { + chatLine += ` It has ${chat.unreadCount} unread message${chat.unreadCount === 1 ? '' : 's'}.`; + } + lines.push(chatLine); + lines.push(`**Type**: ${chat.type}`); + if (chat.lastActivity) lines.push(`**Last Activity**: ${chat.lastActivity}`); + const status: string[] = []; + if (chat.isArchived) status.push('archived'); + if (chat.isMuted) status.push('muted'); + if (chat.isPinned) status.push('pinned'); + if (status.length > 0) lines.push(`This chat is ${status.join(', ')}.`); + return lines; +}; diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 7e516de..6416509 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -1 +1,44 @@ -export * from './tools/index'; +// File modified to swap in custom MCP response handlers +import { Endpoint } from './tools/types'; +import { endpoints as originalEndpoints } from './tools/index'; +import { getAccountsHandler } from './tool-handlers/accounts/get-accounts-handler'; +import { openInAppHandler } from './tool-handlers/app/open-in-app-handler'; +import { archiveChatHandler } from './tool-handlers/chats/archive-chat-handler'; +import { getChatHandler } from './tool-handlers/chats/get-chat-handler'; +import { clearChatReminderHandler } from './tool-handlers/chats/reminders/clear-chat-reminder-handler'; +import { setChatReminderHandler } from './tool-handlers/chats/reminders/set-chat-reminder-handler'; +import { searchChatsHandler } from './tool-handlers/chats/search-chats-handler'; +import { downloadAttachmentHandler } from './tool-handlers/messages/attachments/download-attachment-handler'; +import { searchMessagesHandler } from './tool-handlers/messages/search-messages-handler'; +import { sendMessageHandler } from './tool-handlers/messages/send-message-handler'; +import { CustomHandlerFunction } from './tool-handlers/types'; + +const customHandlers: Record = { + get_accounts: getAccountsHandler, + open_in_app: openInAppHandler, + get_chat: getChatHandler, + search_chats: searchChatsHandler, + archive_chat: archiveChatHandler, + set_chat_reminder: setChatReminderHandler, + clear_chat_reminder: clearChatReminderHandler, + search_messages: searchMessagesHandler, + send_message: sendMessageHandler, + download_attachment: downloadAttachmentHandler, +}; + +const endpoints: Endpoint[] = originalEndpoints.map((endpoint) => { + const customHandler = customHandlers[endpoint.tool.name]; + + if (customHandler) { + return { + ...endpoint, + handler: customHandler, + }; + } + + return endpoint; +}); + +export { Metadata, Endpoint, HandlerFunction } from './tools/types'; +export { query, type Filter } from './tools/index'; +export { endpoints }; diff --git a/packages/mcp-server/src/tools/types.ts b/packages/mcp-server/src/tools/types.ts index d484ddf..883f021 100644 --- a/packages/mcp-server/src/tools/types.ts +++ b/packages/mcp-server/src/tools/types.ts @@ -58,6 +58,7 @@ export function asTextContentResult(result: unknown): ToolCallResult { }; } + export async function asBinaryContentResult(response: Response): Promise { const blob = await response.blob(); const mimeType = blob.type; diff --git a/packages/mcp-server/yarn.lock b/packages/mcp-server/yarn.lock index 707a2de..b81e4e5 100644 --- a/packages/mcp-server/yarn.lock +++ b/packages/mcp-server/yarn.lock @@ -273,6 +273,9 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@beeper/desktop-api@file:../../dist": + version "0.1.4" + "@cloudflare/cabidela@^0.2.4": version "0.2.4" resolved "https://registry.yarnpkg.com/@cloudflare/cabidela/-/cabidela-0.2.4.tgz#9a3e9212e636a24d796a8f16741c24885b326a1a" @@ -725,6 +728,13 @@ dependencies: "@types/node" "*" +"@types/cors@^2.8.19": + version "2.8.19" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342" + integrity sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg== + dependencies: + "@types/node" "*" + "@types/express-serve-static-core@^5.0.0": version "5.0.7" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz#2fa94879c9d46b11a5df4c74ac75befd6b283de6" @@ -795,7 +805,7 @@ dependencies: undici-types "~6.21.0" -"@types/qs@*": +"@types/qs@*", "@types/qs@^6.14.0": version "6.14.0" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.14.0.tgz#d8b60cecf62f2db0fb68e5e006077b9178b85de5" integrity sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ== @@ -925,6 +935,11 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== +"@valtown/deno-http-worker@^0.0.21": + version "0.0.21" + resolved "https://registry.yarnpkg.com/@valtown/deno-http-worker/-/deno-http-worker-0.0.21.tgz#9ce3b5c1d0db211fe7ea8297881fe551838474ad" + integrity sha512-16kFuUykann75lNytnXXIQlmpzreZjzdyT27ebT3yNGCS3kKaS1iZYWHc3Si9An54Cphwr4qEcviChQkEeJBlA== + accepts@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" @@ -3387,9 +3402,9 @@ ts-node@^10.5.0: v8-compile-cache-lib "^3.0.1" yn "3.1.1" -"tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz": - version "1.1.8" - resolved "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.8/tsc-multi.tgz#f544b359b8f05e607771ffacc280e58201476b04" +"tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz": + version "1.1.9" + resolved "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz#777f6f5d9e26bf0e94e5170990dd3a841d6707cd" dependencies: debug "^4.3.7" fast-glob "^3.3.2" From 3594df1274ed5af59b1053dffe3515d573db425b Mon Sep 17 00:00:00 2001 From: alperdegre Date: Sat, 6 Sep 2025 15:12:22 +0300 Subject: [PATCH 2/6] refactor(auth): streamline OAuth URL handling and improve error responses refactor(http): remove unused port parameter and simplify server creation refactor(messages): clean up searchMessagesHandler by removing unused imports fix(types): update asFormattedMCPContentResult to accept optional error flag fix(utils): enhance getParticipantName to handle null participant and ensure ID is a string --- packages/mcp-server/src/auth.ts | 83 +++++++------------ packages/mcp-server/src/http.ts | 42 ++++------ .../messages/search-messages-handler.ts | 8 +- .../mcp-server/src/tool-handlers/types.ts | 3 +- .../mcp-server/src/tool-handlers/utils.ts | 15 ++-- packages/mcp-server/src/tools/types.ts | 1 - 6 files changed, 57 insertions(+), 95 deletions(-) diff --git a/packages/mcp-server/src/auth.ts b/packages/mcp-server/src/auth.ts index 8b21e96..ec40b4d 100644 --- a/packages/mcp-server/src/auth.ts +++ b/packages/mcp-server/src/auth.ts @@ -4,30 +4,20 @@ import { readEnv } from './server'; import express from 'express'; import { fromError } from 'zod-validation-error/v3'; -export const baseURL = readEnv('BEEPER_DESKTOP_BASE_URL') || 'http://localhost:23373'; - -export const getMcpUrl = (port: number | string | undefined, socket?: string): string => { - if (socket) { - return `http://unix:${socket}`; - } - - const actualPort = port || 3000; - return `http://localhost:${actualPort}`; -}; - -export const createProxyProvider = (port: number | string | undefined): ProxyOAuthServerProvider => { - const mcpUrl = getMcpUrl(port); +export const BEEPER_DESKTOP_BASE_URL = readEnv('BEEPER_DESKTOP_BASE_URL') || 'http://localhost:23373'; +export const BEEPER_MCP_BASE_URL = readEnv('BEEPER_MCP_BASE_URL') || 'http://localhost:3000'; +export const createProxyProvider = (redirect_uris?: string[]): ProxyOAuthServerProvider => { return new ProxyOAuthServerProvider({ endpoints: { - authorizationUrl: `${baseURL}/oauth/authorize`, - tokenUrl: `${baseURL}/oauth/token`, - revocationUrl: `${baseURL}/oauth/revoke`, - registrationUrl: `${baseURL}/oauth/register`, + authorizationUrl: `${BEEPER_DESKTOP_BASE_URL}/oauth/authorize`, + tokenUrl: `${BEEPER_DESKTOP_BASE_URL}/oauth/token`, + revocationUrl: `${BEEPER_DESKTOP_BASE_URL}/oauth/revoke`, + registrationUrl: `${BEEPER_DESKTOP_BASE_URL}/oauth/register`, }, verifyAccessToken: async (token: string) => { try { - const response = await fetch(`${baseURL}/v0/mcp/validate`, { + const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/v0/mcp/validate`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, @@ -38,11 +28,7 @@ export const createProxyProvider = (port: number | string | undefined): ProxyOAu console.log('Token validation response status:', response.status); if (!response.ok) { - return { - token, - clientId: 'unknown', - scopes: ['read'], - }; + throw new Error(`invalid_token (status ${response.status})`); } const tokenData: any = await response.json(); @@ -54,57 +40,44 @@ export const createProxyProvider = (port: number | string | undefined): ProxyOAu }; } catch (error) { console.error('Token validation failed:', error); - return { - token, - clientId: 'unknown', - scopes: ['read'], - }; + throw error; } }, getClient: async (client_id: string) => { return { client_id, - redirect_uris: [ - mcpUrl, - 'http://localhost:6274/oauth/callback/debug', - 'http://localhost:6274/oauth/callback', - ], + redirect_uris: + redirect_uris ? redirect_uris : ( + [ + BEEPER_MCP_BASE_URL, + 'http://localhost:6274/oauth/callback/debug', + 'http://localhost:6274/oauth/callback', + ] + ), }; }, }); }; -export const createMCPAuthRouter = (port: number | string | undefined): express.RequestHandler => { - const proxyProvider = createProxyProvider(port); - const mcpUrl = getMcpUrl(port); +export const createMCPAuthRouter = (): express.RequestHandler => { + const proxyProvider = createProxyProvider(); return mcpAuthRouter({ provider: proxyProvider, - issuerUrl: new URL(baseURL), - baseUrl: new URL(mcpUrl), + issuerUrl: new URL(BEEPER_DESKTOP_BASE_URL), + baseUrl: new URL(BEEPER_MCP_BASE_URL), }); }; -export const customWellKnownEndpoint = ( - req: express.Request, - res: express.Response, - port: number | string | undefined, -) => { - const mcpUrl = getMcpUrl(port); - +export const customWellKnownEndpoint = (req: express.Request, res: express.Response) => { res.json({ - resource: mcpUrl, - authorization_servers: [baseURL], + resource: BEEPER_MCP_BASE_URL, + authorization_servers: [BEEPER_DESKTOP_BASE_URL], }); }; -export const sendUnauthorizedResponse = ( - res: express.Response, - port: number | string | undefined, - error?: any, -) => { - const resourceIdentifier = getMcpUrl(port); - const wwwAuth = `Bearer resource="${resourceIdentifier}", resource_metadata="${resourceIdentifier}.well-known/oauth-protected-resource"`; +export const sendUnauthorizedResponse = (res: express.Response, error?: any) => { + const wwwAuth = `Bearer resource_metadata="${BEEPER_MCP_BASE_URL}/.well-known/oauth-protected-resource"`; res.set('WWW-Authenticate', wwwAuth); res.status(401).json({ @@ -117,7 +90,7 @@ export const sendUnauthorizedResponse = ( }; export const getTokenForStdio = async (): Promise => { - const response = await fetch(`${baseURL}/oauth/token`, { + const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index 852ad74..c772954 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -11,7 +11,6 @@ const newServer = ( defaultMcpOptions: McpOptions, req: express.Request, res: express.Response, - port: number | string | undefined, ): McpServer | null => { const server = newMcpServer(); let mcpOptions: McpOptions; @@ -33,7 +32,7 @@ const newServer = ( const authOptions = parseAuthHeaders(req); if (!authOptions.accessToken) { - sendUnauthorizedResponse(res, port); + sendUnauthorizedResponse(res); return null; } @@ -48,28 +47,26 @@ const newServer = ( mcpOptions, }); } catch (error) { - sendUnauthorizedResponse(res, port); + sendUnauthorizedResponse(res); return null; } return server; }; -const post = - (defaultOptions: McpOptions, port: number | string | undefined) => - async (req: express.Request, res: express.Response) => { - const server = newServer(defaultOptions, req, res, port); +const post = (defaultOptions: McpOptions) => async (req: express.Request, res: express.Response) => { + const server = newServer(defaultOptions, req, res); - if (server === null) return; + if (server === null) return; - const transport = new StreamableHTTPServerTransport({ - // Stateless server - sessionIdGenerator: undefined, - }); + const transport = new StreamableHTTPServerTransport({ + // Stateless server + sessionIdGenerator: undefined, + }); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - }; + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}; const get = async (req: express.Request, res: express.Response) => { res.status(405).json({ @@ -91,28 +88,25 @@ const del = async (req: express.Request, res: express.Response) => { }); }; -export const streamableHTTPApp = ( - options: McpOptions, - port: number | string | undefined, -): express.Express => { +export const streamableHTTPApp = (options: McpOptions): express.Express => { const app = express(); app.set('query parser', 'extended'); app.use(express.json()); - const beeperMCPAuthRouter = createMCPAuthRouter(port); - app.get('/.well-known/oauth-protected-resource', (req, res) => customWellKnownEndpoint(req, res, port)); - app.use(beeperMCPAuthRouter); + const beeperProxyRouter = createMCPAuthRouter(); + app.get('/.well-known/oauth-protected-resource', (req, res) => customWellKnownEndpoint(req, res)); + app.use(beeperProxyRouter); app.get('/', get); - app.post('/', post(options, port)); + app.post('/', post(options)); app.delete('/', del); return app; }; export const launchStreamableHTTPServer = async (options: McpOptions, port: number | string | undefined) => { - const app = streamableHTTPApp(options, port); + const app = streamableHTTPApp(options); const server = app.listen(port); const address = server.address(); diff --git a/packages/mcp-server/src/tool-handlers/messages/search-messages-handler.ts b/packages/mcp-server/src/tool-handlers/messages/search-messages-handler.ts index 05137ca..87f2e36 100644 --- a/packages/mcp-server/src/tool-handlers/messages/search-messages-handler.ts +++ b/packages/mcp-server/src/tool-handlers/messages/search-messages-handler.ts @@ -1,11 +1,5 @@ -import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types'; +import { CustomHandlerFunction } from '../types'; import { asTextContentResult } from '@beeper/desktop-mcp/tools/types'; -import { - formatParticipantsToMarkdown, - formatReactionsToMarkdown, - formatRelativeDate, - createOpenLink, -} from '../utils'; export const searchMessagesHandler: CustomHandlerFunction = async (client, args) => { const currArgs = args as any; diff --git a/packages/mcp-server/src/tool-handlers/types.ts b/packages/mcp-server/src/tool-handlers/types.ts index d9adfe5..2d80e86 100644 --- a/packages/mcp-server/src/tool-handlers/types.ts +++ b/packages/mcp-server/src/tool-handlers/types.ts @@ -10,7 +10,7 @@ export type CustomHandlerFunction = ( args: Record | undefined, ) => Promise; -export function asFormattedMCPContentResult(result: string): ToolCallResult { +export function asFormattedMCPContentResult(result: string, opts?: { isError?: boolean }): ToolCallResult { return { content: [ { @@ -18,5 +18,6 @@ export function asFormattedMCPContentResult(result: string): ToolCallResult { text: result, }, ], + ...(opts?.isError ? { isError: true } : {}), }; } diff --git a/packages/mcp-server/src/tool-handlers/utils.ts b/packages/mcp-server/src/tool-handlers/utils.ts index 2d9b53a..81216ee 100644 --- a/packages/mcp-server/src/tool-handlers/utils.ts +++ b/packages/mcp-server/src/tool-handlers/utils.ts @@ -3,13 +3,14 @@ import { Message, Reaction, User } from '@beeper/desktop-api/resources/shared'; // TODO : Participants type missing export const getParticipantName = (participant: any, preferFirstName?: boolean): string | null => { - return participant ? - participant.nickname || - (preferFirstName ? participant.fullName?.split(' ')[0] : participant.fullName) || - participant.username || - participant.email || - participant.id - : null; + if (!participant) return null; + return ( + participant.nickname || + (preferFirstName ? participant.fullName?.split(' ')[0] : participant.fullName) || + participant.username || + participant.email || + (participant.id != null ? String(participant.id) : null) + ); }; const skinToneRegex = /\uD83C[\uDFFB-\uDFFF]/g; diff --git a/packages/mcp-server/src/tools/types.ts b/packages/mcp-server/src/tools/types.ts index 883f021..d484ddf 100644 --- a/packages/mcp-server/src/tools/types.ts +++ b/packages/mcp-server/src/tools/types.ts @@ -58,7 +58,6 @@ export function asTextContentResult(result: unknown): ToolCallResult { }; } - export async function asBinaryContentResult(response: Response): Promise { const blob = await response.blob(); const mimeType = blob.type; From b2255dc1d3d4321bd48175f3b90a4020fd7b54da Mon Sep 17 00:00:00 2001 From: alperdegre Date: Sat, 6 Sep 2025 15:53:54 +0300 Subject: [PATCH 3/6] make redirect uri configurable up to mcpauthrouter --- packages/mcp-server/src/auth.ts | 4 ++-- packages/mcp-server/src/http.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server/src/auth.ts b/packages/mcp-server/src/auth.ts index ec40b4d..a6beae2 100644 --- a/packages/mcp-server/src/auth.ts +++ b/packages/mcp-server/src/auth.ts @@ -59,8 +59,8 @@ export const createProxyProvider = (redirect_uris?: string[]): ProxyOAuthServerP }); }; -export const createMCPAuthRouter = (): express.RequestHandler => { - const proxyProvider = createProxyProvider(); +export const createMCPAuthRouter = (redirect_uris?: string[]): express.RequestHandler => { + const proxyProvider = createProxyProvider(redirect_uris); return mcpAuthRouter({ provider: proxyProvider, diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index c772954..16c2704 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -47,7 +47,7 @@ const newServer = ( mcpOptions, }); } catch (error) { - sendUnauthorizedResponse(res); + sendUnauthorizedResponse(res, error); return null; } From 64137ee54ef77ebe8451258560661cb7ca0f7c19 Mon Sep 17 00:00:00 2001 From: alperdegre Date: Sat, 6 Sep 2025 18:18:41 +0300 Subject: [PATCH 4/6] add skipAccessToken to client --- packages/mcp-server/src/server.ts | 1 + src/client.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index de240c6..8ef69d0 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -92,6 +92,7 @@ export function initMcpServer(params: { const client = new BeeperDesktop({ logger, + skipAccessToken: true, ...params.clientOptions, defaultHeaders: { ...params.clientOptions?.defaultHeaders, diff --git a/src/client.ts b/src/client.ts index f5ad48f..e0278e1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -129,6 +129,11 @@ export interface ClientOptions { * Defaults to globalThis.console. */ logger?: Logger | undefined; + + /** + * Skip access token check for stdio MCP server + */ + skipAccessToken?: boolean | undefined; } /** @@ -165,9 +170,10 @@ export class BeeperDesktop { constructor({ baseURL = readEnv('BEEPER_DESKTOP_BASE_URL'), accessToken = readEnv('BEEPER_ACCESS_TOKEN'), + skipAccessToken, ...opts }: ClientOptions = {}) { - if (accessToken === undefined) { + if (accessToken === undefined && !skipAccessToken) { throw new Errors.BeeperDesktopError( "The BEEPER_ACCESS_TOKEN environment variable is missing or empty; either provide it, or instantiate the BeeperDesktop client with an accessToken option, like new BeeperDesktop({ accessToken: 'My Access Token' }).", ); @@ -202,7 +208,7 @@ export class BeeperDesktop { this._options = options; - this.accessToken = accessToken; + this.accessToken = accessToken || ''; } /** From 59ebf0798c0d13165eeca031d6044de9dc152d2c Mon Sep 17 00:00:00 2001 From: alperdegre Date: Sat, 6 Sep 2025 18:27:36 +0300 Subject: [PATCH 5/6] use env token if it exists --- packages/mcp-server/src/auth.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/mcp-server/src/auth.ts b/packages/mcp-server/src/auth.ts index a6beae2..e38081d 100644 --- a/packages/mcp-server/src/auth.ts +++ b/packages/mcp-server/src/auth.ts @@ -6,6 +6,7 @@ import { fromError } from 'zod-validation-error/v3'; export const BEEPER_DESKTOP_BASE_URL = readEnv('BEEPER_DESKTOP_BASE_URL') || 'http://localhost:23373'; export const BEEPER_MCP_BASE_URL = readEnv('BEEPER_MCP_BASE_URL') || 'http://localhost:3000'; +export const BEEPER_AUTH_TOKEN = readEnv('AUTH_TOKEN') || ''; export const createProxyProvider = (redirect_uris?: string[]): ProxyOAuthServerProvider => { return new ProxyOAuthServerProvider({ @@ -90,6 +91,9 @@ export const sendUnauthorizedResponse = (res: express.Response, error?: any) => }; export const getTokenForStdio = async (): Promise => { + if (BEEPER_AUTH_TOKEN) return BEEPER_AUTH_TOKEN; + + // Needs to be implemented const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/oauth/token`, { method: 'POST', headers: { From 1713220677ce42d10c1ec0e88f40cb94eb48149a Mon Sep 17 00:00:00 2001 From: alperdegre Date: Sat, 6 Sep 2025 18:32:57 +0300 Subject: [PATCH 6/6] fix naming --- packages/mcp-server/src/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/auth.ts b/packages/mcp-server/src/auth.ts index e38081d..7eb2ddb 100644 --- a/packages/mcp-server/src/auth.ts +++ b/packages/mcp-server/src/auth.ts @@ -6,7 +6,7 @@ import { fromError } from 'zod-validation-error/v3'; export const BEEPER_DESKTOP_BASE_URL = readEnv('BEEPER_DESKTOP_BASE_URL') || 'http://localhost:23373'; export const BEEPER_MCP_BASE_URL = readEnv('BEEPER_MCP_BASE_URL') || 'http://localhost:3000'; -export const BEEPER_AUTH_TOKEN = readEnv('AUTH_TOKEN') || ''; +export const BEEPER_ACCESS_TOKEN = readEnv('BEEPER_ACCESS_TOKEN') || ''; export const createProxyProvider = (redirect_uris?: string[]): ProxyOAuthServerProvider => { return new ProxyOAuthServerProvider({ @@ -91,7 +91,7 @@ export const sendUnauthorizedResponse = (res: express.Response, error?: any) => }; export const getTokenForStdio = async (): Promise => { - if (BEEPER_AUTH_TOKEN) return BEEPER_AUTH_TOKEN; + if (BEEPER_ACCESS_TOKEN) return BEEPER_ACCESS_TOKEN; // Needs to be implemented const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/oauth/token`, {