Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions packages/mcp-server/src/auth.ts
Original file line number Diff line number Diff line change
@@ -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_AUTH_TOKEN = readEnv('AUTH_TOKEN') || '';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Env var name mismatch for auth token; read both to avoid silent fallback.

Constant is BEEPER_AUTH_TOKEN, but you read AUTH_TOKEN. This will ignore BEEPER_AUTH_TOKEN if set.

Apply:

-export const BEEPER_AUTH_TOKEN = readEnv('AUTH_TOKEN') || '';
+export const BEEPER_AUTH_TOKEN =
+  readEnv('BEEPER_AUTH_TOKEN') ??
+  readEnv('AUTH_TOKEN') ?? // backward-compat
+  '';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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_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('BEEPER_AUTH_TOKEN') ??
readEnv('AUTH_TOKEN') ?? // backward-compat
'';
🤖 Prompt for AI Agents
In packages/mcp-server/src/auth.ts around lines 7 to 9, the code reads process
env 'AUTH_TOKEN' into BEEPER_AUTH_TOKEN so an explicitly set BEEPER_AUTH_TOKEN
would be ignored; change the lookup to prefer BEEPER_AUTH_TOKEN then fall back
to AUTH_TOKEN and finally to empty string (i.e., check
readEnv('BEEPER_AUTH_TOKEN') || readEnv('AUTH_TOKEN') || '') so both env names
are supported and no silent fallback occurs.


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',
},
});

Comment on lines +19 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Add network timeout and fix header/body mismatch on token validation.

Prevent hangs; avoid sending Content-Type without a body.

-    verifyAccessToken: async (token: string) => {
-      try {
-        const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/v0/mcp/validate`, {
+    verifyAccessToken: async (token: string) => {
+      const controller = new AbortController();
+      const timeout = setTimeout(() => controller.abort(), 10_000);
+      try {
+        const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/v0/mcp/validate`, {
           method: 'POST',
           headers: {
             Authorization: `Bearer ${token}`,
-            'Content-Type': 'application/json',
+            Accept: 'application/json',
           },
-        });
+          // no body; endpoint validates via Authorization header
+          signal: controller.signal,
+        });
 
         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'],
+          scopes: tokenData.scopes || [],
         };
       } catch (error) {
         console.error('Token validation failed:', error);
         throw error;
+      } finally {
+        clearTimeout(timeout);
       }
     },

Note: defaulting scopes to [] avoids implicitly granting “read”. If “read” is required as a fallback, ignore this part.

Also applies to: 31-46


🏁 Script executed:

#!/bin/bash
# Show implementation of verifyAccessToken with surrounding context
rg -nC5 "verifyAccessToken" packages/mcp-server/src/auth.ts

# Show file imports to verify how fetch is being provided
sed -n '1,30p' packages/mcp-server/src/auth.ts

Length of output: 2035


🌐 Web query:

Does Node.js global fetch implementation have a default timeout? What is the recommended way to add a timeout to fetch in Node.js?

💡 Result:

Short answer

  • No reliable per-request default you should rely on: Node's global fetch uses undici's timeouts (historically 300s by default; there was a temporary change to 30s and it was reverted). There is no higher-level “fetch timeout” option you can pass other than using Abort signals. [1][2]

Recommended way to add a timeout

  • Use an AbortSignal. In modern Node.js you can use AbortSignal.timeout(ms) or create an AbortController and call controller.abort() from setTimeout. Pass the signal to fetch: fetch(url, { signal }). Handle AbortError/TimeoutError. Example patterns: AbortSignal.timeout(5000) or a small helper that creates an AbortController + timer and clears it on completion. [3][4]

Sources
[1] Node.js/undici timeout discussion & defaults (issue history).
[2] BetterStack explanation showing Node follows Chromium/undici defaults and example using AbortSignal.timeout().
[3] MDN: AbortSignal.timeout() and examples.
[4] Common patterns (AbortController + setTimeout) from community docs/StackOverflow.


🏁 Script executed:

#!/bin/bash
rg -nC3 "scopes" packages/mcp-server/src/auth.ts

Length of output: 305


Add request timeout, remove mismatched Content-Type header, and clear timer in verifyAccessToken
packages/mcp-server/src/auth.ts:19-28,31-46

  • Wrap the fetch call in an AbortController (e.g. const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000);) and pass signal to avoid indefinite hangs.
  • Remove the 'Content-Type': 'application/json' header (no request body) and replace with Accept: 'application/json'.
  • Clear the timeout in a finally block (clearTimeout(timeout)).
  • Change the fallback scopes from ['read'] to [] to avoid implicitly granting read access—if you still need a default “read” scope, you can omit this change.
🤖 Prompt for AI Agents
In packages/mcp-server/src/auth.ts around lines 19 to 28 and 31 to 46, the fetch
in verifyAccessToken lacks a timeout and uses a mismatched Content-Type header
and an unsafe default scope; wrap the fetch with an AbortController (create
controller and set a timeout e.g. 10_000 ms that calls controller.abort()), pass
controller.signal into fetch, remove the 'Content-Type' header and use 'Accept:
application/json' instead, ensure you clearTimeout(timeout) in a finally block
so the timer is cleaned up, and change the fallback scopes from ['read'] to []
(or remove the implicit read permission) to avoid granting unintended access.

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;
}
},
Comment on lines +19 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add network timeouts to external fetch calls.

Avoids hanging on network stalls.

Apply:

-        const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/v0/mcp/validate`, {
+        const controller = new AbortController();
+        const timeout = setTimeout(() => controller.abort(), 10_000);
+        const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/v0/mcp/validate`, {
           method: 'POST',
           headers: {
             Authorization: `Bearer ${token}`,
             'Content-Type': 'application/json',
           },
-        });
+          signal: controller.signal,
+        });
+        clearTimeout(timeout);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
},
verifyAccessToken: async (token: string) => {
try {
// abort after 10 seconds to avoid hanging on network stalls
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/v0/mcp/validate`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
signal: controller.signal,
});
clearTimeout(timeout);
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;
}
},
🤖 Prompt for AI Agents
In packages/mcp-server/src/auth.ts around lines 19 to 46, the external fetch to
validate tokens has no network timeout and can hang; add an AbortController with
a reasonable timeout (e.g., 5s) that aborts the fetch if exceeded, clear the
timeout on success, and convert an AbortError into a clear timeout error before
rethrowing; ensure the controller.signal is passed into fetch and the timeout is
cleaned up in both success and error paths.

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<string> => {
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: {
'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;
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Harden token acquisition; add timeout and clearer error messages.

Prevents hangs and improves diagnostics.

 export const getTokenForStdio = async (): Promise<string> => {
   if (BEEPER_AUTH_TOKEN) return BEEPER_AUTH_TOKEN;
 
   // Needs to be implemented
-  const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/oauth/token`, {
+  const controller = new AbortController();
+  const timeout = setTimeout(() => controller.abort(), 10_000);
+  const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/oauth/token`, {
     method: 'POST',
     headers: {
       'Content-Type': 'application/x-www-form-urlencoded',
+      'Accept': 'application/json',
     },
     body: new URLSearchParams({
       grant_type: 'client_credentials',
-      client_id: 'stdio-mcp-client',
+      client_id: readEnv('MCP_CLIENT_ID') ?? 'stdio-mcp-client',
       // client_secret: process.env.MCP_CLIENT_SECRET || '',
       scope: 'read write',
     }),
-  });
+    signal: controller.signal,
+  }).catch((e) => {
+    throw new Error(`Token request failed: ${e?.name === 'AbortError' ? 'timeout' : String(e)}`);
+  });
+  clearTimeout(timeout);
 
   if (!response.ok) {
-    throw new Error(`Failed to get token: ${response.status}`);
+    let details = '';
+    try { details = JSON.stringify(await response.json()); } catch {}
+    throw new Error(`Failed to get token: HTTP ${response.status}${details ? ` ${details}` : ''}`);
   }
 
   const data = await response.json();
   return (data as any).access_token;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getTokenForStdio = async (): Promise<string> => {
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: {
'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;
};
export const getTokenForStdio = async (): Promise<string> => {
if (BEEPER_AUTH_TOKEN) return BEEPER_AUTH_TOKEN;
// Needs to be implemented
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const response = await fetch(`${BEEPER_DESKTOP_BASE_URL}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: readEnv('MCP_CLIENT_ID') ?? 'stdio-mcp-client',
// client_secret: process.env.MCP_CLIENT_SECRET || '',
scope: 'read write',
}),
signal: controller.signal,
}).catch((e) => {
throw new Error(`Token request failed: ${e?.name === 'AbortError' ? 'timeout' : String(e)}`);
});
clearTimeout(timeout);
if (!response.ok) {
let details = '';
try {
details = JSON.stringify(await response.json());
} catch {}
throw new Error(
`Failed to get token: HTTP ${response.status}${details ? ` ${details}` : ''}`
);
}
const data = await response.json();
return (data as any).access_token;
};
🤖 Prompt for AI Agents
In packages/mcp-server/src/auth.ts around lines 93 to 116, the token fetch can
hang and returns opaque errors; wrap the fetch in an AbortController with a
configurable timeout (e.g., 5s) so the request is aborted on timeout, catch
fetch/abort errors and throw a descriptive Error including whether it was a
timeout, and when the response is non-ok, include the response.status and
response text/body in the thrown error to improve diagnostics; ensure the
AbortController signal is passed to fetch and the timer is cleared after
completion.

53 changes: 18 additions & 35 deletions packages/mcp-server/src/http.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,20 @@
// 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,
): McpServer | null => {
const server = newMcpServer();

let mcpOptions: McpOptions;

try {
mcpOptions = parseQueryOptions(defaultMcpOptions, req.query);
} catch (error) {
Expand All @@ -38,6 +30,12 @@ const newServer = (

try {
const authOptions = parseAuthHeaders(req);

if (!authOptions.accessToken) {
sendUnauthorizedResponse(res);
return null;
}

Comment on lines 31 to +38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate the bearer token before initializing the MCP server

Presence-only check accepts any token string. Validate it and propagate the specific error in the 401 challenge.

-    const authOptions = parseAuthHeaders(req);
-
-    if (!authOptions.accessToken) {
-      sendUnauthorizedResponse(res);
-      return null;
-    }
+    const authOptions = parseAuthHeaders(req);
+    if (!authOptions.accessToken) {
+      sendUnauthorizedResponse(req, res);
+      return null;
+    }
+    try {
+      await validateAccessToken(authOptions.accessToken);
+    } catch (error) {
+      sendUnauthorizedResponse(req, res, error);
+      return null;
+    }

Note: import validateAccessToken and updated sendUnauthorizedResponse.

-import { createMCPAuthRouter, customWellKnownEndpoint, sendUnauthorizedResponse } from './auth';
+import {
+  createMCPAuthRouter,
+  customWellKnownEndpoint,
+  sendUnauthorizedResponse,
+  validateAccessToken,
+} from './auth';
🤖 Prompt for AI Agents
In packages/mcp-server/src/http.ts around lines 31 to 38, the code currently
only checks presence of authOptions.accessToken and returns 401 for any
non-empty value; update this to call
validateAccessToken(authOptions.accessToken) and handle its result/errors before
proceeding to initialize the MCP server. If validation fails, call
sendUnauthorizedResponse(res, { error: specificError }) (or update
sendUnauthorizedResponse to accept and include the specific error in the
WWW-Authenticate challenge) so the 401 response contains the precise validation
failure; ensure successful validation lets execution continue.

initMcpServer({
server: server,
clientOptions: {
Expand All @@ -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;
}

Expand All @@ -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);
};
Expand All @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions packages/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CallToolRequestSchema,
Implementation,
ListToolsRequestSchema,
SetLevelRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
Comment on lines +10 to 12
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Also import LoggingLevel to validate and handle the full set of MCP log levels.

You’re adding a SetLevel handler; pull in LoggingLevel so we can type-check and support all levels (debug, info, notice, warning, error, critical, alert, emergency).

   CallToolRequestSchema,
   Implementation,
   ListToolsRequestSchema,
-  SetLevelRequestSchema,
+  SetLevelRequestSchema,
+  LoggingLevel,
   Tool,
 } from '@modelcontextprotocol/sdk/types.js';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
SetLevelRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
SetLevelRequestSchema,
LoggingLevel,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
🤖 Prompt for AI Agents
In packages/mcp-server/src/server.ts around lines 10 to 12, the new SetLevel
handler is missing the LoggingLevel import and therefore doesn’t type-check or
validate all MCP log levels; add LoggingLevel to the existing import list from
'@modelcontextprotocol/sdk/types.js' and update the SetLevel handler to
accept/validate the incoming level against the LoggingLevel enum (or set) —
reject or map unsupported values and handle each valid level (debug, info,
notice, warning, error, critical, alert, emergency) explicitly so the handler is
fully typed and covers the full set of MCP log levels.

import { ClientOptions } from '@beeper/desktop-api';
Expand Down Expand Up @@ -91,6 +92,7 @@ export function initMcpServer(params: {

const client = new BeeperDesktop({
logger,
skipAccessToken: true,
...params.clientOptions,
defaultHeaders: {
...params.clientOptions?.defaultHeaders,
Expand Down Expand Up @@ -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 {};
});
Comment on lines +125 to +129
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

SetLevel handler is a no-op; it should update the server’s minimum log level or defer to the SDK’s default.

At present it only logs; it doesn’t change behavior. Implement level validation and update the gate. Per spec, reject invalid params with an error. (spec.modelcontextprotocol.io)

-  server.setRequestHandler(SetLevelRequestSchema, async (request) => {
-    const { level } = request.params;
-    logger.info(`Log level set to: ${level}`);
-    return {};
-  });
+  server.setRequestHandler(SetLevelRequestSchema, async (request) => {
+    const { level } = request.params;
+    const valid: LoggingLevel[] = [
+      'debug','info','notice','warning','error','critical','alert','emergency'
+    ];
+    if (!valid.includes(level)) {
+      throw new Error(`Invalid params: unsupported level "${level}"`);
+    }
+    currentLevel = level;
+    // Optionally acknowledge at info (will be gated appropriately)
+    logger.info(`Log level set to: ${level}`);
+    return {};
+  });

Committable suggestion skipped: line range outside the PR's diff.

}

/**
Expand Down
25 changes: 20 additions & 5 deletions packages/mcp-server/src/stdio.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types';

const CONTACT_SUPPORT =
'Something unexpected happened. User might need to contact support at mailto:[email protected]?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'));
};
25 changes: 25 additions & 0 deletions packages/mcp-server/src/tool-handlers/app/open-in-app-handler.ts
Original file line number Diff line number Diff line change
@@ -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'));
};
Original file line number Diff line number Diff line change
@@ -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'));
};
Comment on lines +3 to +18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate args and catch client errors; avoid leading newline in headings

Type args, guard chatID, handle exceptions, and remove the embedded newline heading.

 import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types';
 
+type ArchiveChatArgs = { chatID: string | number; archived?: boolean };
+
 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'));
+  const currArgs = args as ArchiveChatArgs;
+  if (!currArgs?.chatID) {
+    return asFormattedMCPContentResult('Failed to update archive state: chatID is required.');
+  }
+  try {
+    const output = await client.chats.archive(currArgs);
+    const lines: string[] = [];
+    lines.push('# Archive Chat');
+    if (output.success) {
+      const action = currArgs?.archived === false ? 'unarchived' : 'archived';
+      lines.push(`Chat ${currArgs.chatID} ${action}.`);
+    } else {
+      lines.push('Failed to update archive state.');
+    }
+    lines.push('');
+    lines.push('# Using this information');
+    lines.push('- Use search_chats to verify the chat moved to the expected inbox.');
+    return asFormattedMCPContentResult(lines.join('\n'));
+  } catch (err: any) {
+    return asFormattedMCPContentResult(
+      `Failed to update archive state: ${err?.message || String(err)}`,
+    );
+  }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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'));
};
import { CustomHandlerFunction, asFormattedMCPContentResult } from '../types';
type ArchiveChatArgs = { chatID: string | number; archived?: boolean };
export const archiveChatHandler: CustomHandlerFunction = async (client, args) => {
const currArgs = args as ArchiveChatArgs;
if (!currArgs?.chatID) {
return asFormattedMCPContentResult('Failed to update archive state: chatID is required.');
}
try {
const output = await client.chats.archive(currArgs);
const lines: string[] = [];
lines.push('# Archive Chat');
if (output.success) {
const action = currArgs.archived === false ? 'unarchived' : 'archived';
lines.push(`Chat ${currArgs.chatID} ${action}.`);
} else {
lines.push('Failed to update archive state.');
}
lines.push('');
lines.push('# Using this information');
lines.push('- Use search_chats to verify the chat moved to the expected inbox.');
return asFormattedMCPContentResult(lines.join('\n'));
} catch (err: any) {
return asFormattedMCPContentResult(
`Failed to update archive state: ${err?.message || String(err)}`,
);
}
};
🤖 Prompt for AI Agents
In packages/mcp-server/src/tool-handlers/chats/archive-chat-handler.ts around
lines 3 to 18, the handler lacks arg validation, doesn't catch client errors,
and contains an embedded leading newline before the second heading; change the
function to accept a properly typed args interface (or cast then validate) and
guard that chatID is present and valid before calling client.chats.archive, wrap
the await client.chats.archive(...) call in a try/catch to log/return a clear
failure result on exception, and remove the '\n' prefix from the "# Using this
information" heading so headings don't start with a blank line; return the
formatted result as before after these checks and error handling.

23 changes: 23 additions & 0 deletions packages/mcp-server/src/tool-handlers/chats/get-chat-handler.ts
Original file line number Diff line number Diff line change
@@ -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'));
Comment on lines +4 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Handle exceptions and surface isError on unexpected failures

Wrap retrieval in try/catch; 404-style “Not found” can remain non-error.

-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'));
-};
+export const getChatHandler: CustomHandlerFunction = async (client, args) => {
+  const currArgs = args as any;
+  const lines: string[] = [];
+  try {
+    const chat = await client.chats.retrieve(currArgs);
+    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);
+    }
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err);
+    lines.push('# Chat');
+    lines.push(`Failed to retrieve chat: ${msg}.`);
+    return asFormattedMCPContentResult(lines.join('\n'), { isError: true });
+  }
+  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'));
+};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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'));
export const getChatHandler: CustomHandlerFunction = async (client, args) => {
const currArgs = args as any;
const lines: string[] = [];
try {
const chat = await client.chats.retrieve(currArgs);
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);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
lines.push('# Chat');
lines.push(`Failed to retrieve chat: ${msg}.`);
return asFormattedMCPContentResult(lines.join('\n'), { isError: true });
}
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'));
};
🤖 Prompt for AI Agents
In packages/mcp-server/src/tool-handlers/chats/get-chat-handler.ts around lines
4 to 22, wrap the chat retrieval in a try/catch: call
client.chats.retrieve(currArgs) inside try and preserve the existing “Not
found.” flow when the result is null/404, but on any unexpected exception catch
it, build an error message (include the exception message/stack) and return
asFormattedMCPContentResult with that message and isError set to true so
failures are surfaced to the caller.

};
Loading