diff --git a/packages/mcp-server/src/auth.ts b/packages/mcp-server/src/auth.ts new file mode 100644 index 0000000..7eb2ddb --- /dev/null +++ b/packages/mcp-server/src/auth.ts @@ -0,0 +1,116 @@ +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 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_ACCESS_TOKEN = readEnv('BEEPER_ACCESS_TOKEN') || ''; + +export const createProxyProvider = (redirect_uris?: string[]): ProxyOAuthServerProvider => { + return new ProxyOAuthServerProvider({ + endpoints: { + 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(`${BEEPER_DESKTOP_BASE_URL}/v0/mcp/validate`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + console.log('Token validation response status:', response.status); + + if (!response.ok) { + throw new Error(`invalid_token (status ${response.status})`); + } + + 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); + throw error; + } + }, + getClient: async (client_id: string) => { + return { + client_id, + redirect_uris: + redirect_uris ? redirect_uris : ( + [ + BEEPER_MCP_BASE_URL, + 'http://localhost:6274/oauth/callback/debug', + 'http://localhost:6274/oauth/callback', + ] + ), + }; + }, + }); +}; + +export const createMCPAuthRouter = (redirect_uris?: string[]): express.RequestHandler => { + const proxyProvider = createProxyProvider(redirect_uris); + + return mcpAuthRouter({ + provider: proxyProvider, + issuerUrl: new URL(BEEPER_DESKTOP_BASE_URL), + baseUrl: new URL(BEEPER_MCP_BASE_URL), + }); +}; + +export const customWellKnownEndpoint = (req: express.Request, res: express.Response) => { + res.json({ + resource: BEEPER_MCP_BASE_URL, + authorization_servers: [BEEPER_DESKTOP_BASE_URL], + }); +}; + +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({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Invalid request: ${fromError(error)}`, + }, + }); +}; + +export const getTokenForStdio = async (): Promise => { + if (BEEPER_ACCESS_TOKEN) return BEEPER_ACCESS_TOKEN; + + // Needs to be implemented + const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/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..16c2704 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -1,19 +1,11 @@ -// 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, @@ -21,8 +13,8 @@ const newServer = ( res: express.Response, ): McpServer | null => { const server = newMcpServer(); - let mcpOptions: McpOptions; + try { mcpOptions = parseQueryOptions(defaultMcpOptions, req.query); } catch (error) { @@ -38,6 +30,12 @@ const newServer = ( try { const authOptions = parseAuthHeaders(req); + + if (!authOptions.accessToken) { + sendUnauthorizedResponse(res); + return null; + } + initMcpServer({ server: server, clientOptions: { @@ -48,19 +46,8 @@ 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, error); return null; } @@ -69,12 +56,14 @@ const newServer = ( 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); }; @@ -99,22 +88,16 @@ 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 => { const app = express(); + app.set('query parser', 'extended'); app.use(express.json()); - app.get('/.well-known/oauth-protected-resource', cors(), oauthMetadata); + 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)); app.delete('/', del); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 54ffb6c..8ef69d0 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'; @@ -91,6 +92,7 @@ export function initMcpServer(params: { const client = new BeeperDesktop({ logger, + skipAccessToken: true, ...params.clientOptions, defaultHeaders: { ...params.clientOptions?.defaultHeaders, @@ -119,6 +121,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..87f2e36 --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/messages/search-messages-handler.ts @@ -0,0 +1,152 @@ +import { CustomHandlerFunction } from '../types'; +import { asTextContentResult } from '@beeper/desktop-mcp/tools/types'; + +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..2d80e86 --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/types.ts @@ -0,0 +1,23 @@ +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, opts?: { isError?: boolean }): ToolCallResult { + return { + content: [ + { + type: 'text', + 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 new file mode 100644 index 0000000..81216ee --- /dev/null +++ b/packages/mcp-server/src/tool-handlers/utils.ts @@ -0,0 +1,123 @@ +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 => { + 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; +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/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" 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 || ''; } /**