Skip to content
Open
Changes from all 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
67 changes: 62 additions & 5 deletions services/twitter-services/src/adapters/mcp-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { EventHandler, EventHandlerRequest } from 'h3'
import type { Context } from '../core/browser/context'
import type { Tweet } from '../core/services/tweet'
import type { TwitterServices } from '../types/services'

import { Buffer } from 'node:buffer'
import { timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'

import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
Expand All @@ -16,6 +18,54 @@ import { useTwitterUserServices } from '../core/services/user'
import { errorToMessage } from '../utils/error'
import { logger } from '../utils/logger'

/**
* Constant-time string comparison to prevent timing attacks.
* Using a plain `===` / `!==` comparison leaks token length and character
* information via execution time; timingSafeEqual removes that side-channel.
*/
function timingSafeStringEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a)
const bufB = Buffer.from(b)
if (bufA.length !== bufB.length) {
// Perform a dummy comparison so the branch timing is normalised.
timingSafeEqual(bufA, bufA)
return false
}
return timingSafeEqual(bufA, bufB)
}

/**
* Wrap an event handler with a Bearer-token authentication guard.
*
* When the MCP_AUTH_TOKEN environment variable is set every incoming request
* must carry a matching `Authorization: Bearer <token>` header. Requests
* that are missing the header or supply the wrong token receive a 401
* response and the inner handler is never called.
*
* When MCP_AUTH_TOKEN is *not* set the guard is a no-op and all requests are
* passed through (preserving the previous behaviour for deployments that do
* not need auth).
*/
function defineProtectedEventHandler(label: string, handler: EventHandler<EventHandlerRequest, unknown>) {
return defineEventHandler(async (event) => {
const authToken = process.env.MCP_AUTH_TOKEN
if (authToken) {
const authHeader = event.node.req.headers['authorization']
const providedToken
= typeof authHeader === 'string' && authHeader.toLowerCase().startsWith('bearer ')
? authHeader.slice(7)
: ''
if (!timingSafeStringEqual(providedToken, authToken)) {
logger.mcp.warn(`Unauthorized ${label} rejected`)
event.node.res.statusCode = 401
event.node.res.end(JSON.stringify({ error: 'Unauthorized' }))
return
}
}
return handler(event)
})
}

/**
* MCP Protocol Adapter
* Adapts the Twitter service to MCP protocol using official MCP SDK
Expand All @@ -42,6 +92,12 @@ export class MCPAdapter {
user: useTwitterUserServices(this.ctx),
}

// Warn operators when the auth token is absent so they know the HTTP
// endpoints are publicly accessible without any authentication.
if (!process.env.MCP_AUTH_TOKEN) {
logger.mcp.warn('MCP_AUTH_TOKEN is not set – all HTTP endpoints are publicly accessible without authentication')
}

// Create MCP server
this.mcpServer = new McpServer({
name: 'Twitter Service',
Expand Down Expand Up @@ -423,11 +479,12 @@ export class MCPAdapter {
private setupRoutes(): void {
const router = createRouter()

// Set up CORS
// Set up CORS – include Authorization so that browser preflight requests
// for token-protected endpoints are allowed through.
router.use('*', defineEventHandler((event) => {
event.node.res.setHeader('Access-Control-Allow-Origin', '*')
event.node.res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
event.node.res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
event.node.res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')

if (event.node.req.method === 'OPTIONS') {
event.node.res.statusCode = 204
Expand All @@ -436,7 +493,7 @@ export class MCPAdapter {
}))

// SSE endpoint
router.get('/sse', defineEventHandler(async (event) => {
router.get('/sse', defineProtectedEventHandler('MCP SSE connection attempt', async (event) => {
const { req, res } = event.node

res.setHeader('Content-Type', 'text/event-stream')
Expand All @@ -460,7 +517,7 @@ export class MCPAdapter {
}))

// Messages endpoint - receive client requests
router.post('/messages', defineEventHandler(async (event) => {
router.post('/messages', defineProtectedEventHandler('MCP message request', async (event) => {
if (this.activeTransports.length === 0) {
logger.mcp.warn('Received message request but no active SSE connections')
event.node.res.statusCode = 503
Expand Down Expand Up @@ -492,7 +549,7 @@ export class MCPAdapter {
}))

// Root path - provide service info
router.get('/', defineEventHandler(() => {
router.get('/', defineProtectedEventHandler('MCP root request', (_event) => {
return {
name: 'Twitter MCP Service',
version: '1.0.0',
Expand Down