From 696917516a67cdd9f1fb20d551ad88dba667e3a0 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 24 May 2026 20:17:25 +0300 Subject: [PATCH 1/2] feat: add optional Xquik search backend --- .env.example | 7 +- README.md | 15 ++- src/configs/env.ts | 9 +- src/mcp-xquik.test.ts | 79 +++++++++++++++ src/mcp.ts | 69 ++++++-------- src/xquik-api.test.ts | 69 ++++++++++++++ src/xquik-api.ts | 217 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 419 insertions(+), 46 deletions(-) create mode 100644 src/mcp-xquik.test.ts create mode 100644 src/xquik-api.test.ts create mode 100644 src/xquik-api.ts diff --git a/.env.example b/.env.example index 4a172bb..c03ccbe 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,9 @@ PORT=3000 NODE_ENV=production TWITTER_API_KEY= -TWITTER_API_SECRET= \ No newline at end of file +TWITTER_API_SECRET= +SEARCH_BACKEND=twitter +XQUIK_API_KEY= +HERMES_TWEET_API_KEY= +XQUIK_BASE_URL=https://xquik.com +XQUIK_AUTH_SCHEME=api-key diff --git a/README.md b/README.md index 27196ed..ef046f7 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,19 @@ | Name | Required | Description | | --- | --- | --- | -| `TWITTER_API_KEY` | Yes | Twitter application API key used for MCP requests. | -| `TWITTER_API_SECRET` | Yes | Twitter application API secret used for MCP requests. | +| `TWITTER_API_KEY` | Required for Twitter OAuth tools | Twitter application API key used for MCP requests. | +| `TWITTER_API_SECRET` | Required for Twitter OAuth tools | Twitter application API secret used for MCP requests. | +| `SEARCH_BACKEND` | No | `twitter`, `xquik`, or `hermes-tweet` for the `search_tweets` tool. Defaults to `twitter`. | +| `XQUIK_API_KEY` | Required for Xquik search | Hermes Tweet/Xquik API key used by `search_tweets`. | +| `HERMES_TWEET_API_KEY` | Required for Xquik search | Alternate Hermes Tweet/Xquik API key variable. | +| `XQUIK_BASE_URL` | No | Hermes Tweet/Xquik base URL. Defaults to `https://xquik.com`. | +| `XQUIK_AUTH_SCHEME` | No | `api-key` or `bearer`. Defaults to `api-key`. | | `DATABASE_URL` | No (runtime) | Postgres connection string, required only when using the database or running drizzle-kit. | | `NODE_ENV` | No | `development` or `production` (defaults to `development`). | | `PORT` | No | HTTP port for the MCP server (defaults to `3000`). | + +Set `SEARCH_BACKEND=xquik` or `SEARCH_BACKEND=hermes-tweet` to route only +`search_tweets` through Hermes Tweet/Xquik. Set either `XQUIK_API_KEY` or +`HERMES_TWEET_API_KEY` for that backend. Retweeting, liking, posting, and other +Twitter OAuth tools continue to require Twitter application credentials and +per-session OAuth tokens. diff --git a/src/configs/env.ts b/src/configs/env.ts index 30593b4..2bd438b 100644 --- a/src/configs/env.ts +++ b/src/configs/env.ts @@ -8,8 +8,13 @@ export const env = createEnv({ server: { NODE_ENV: z.enum(['development', 'production']).default('development'), PORT: z.coerce.number().default(3000), - TWITTER_API_KEY: z.string(), - TWITTER_API_SECRET: z.string(), + TWITTER_API_KEY: z.string().optional(), + TWITTER_API_SECRET: z.string().optional(), + SEARCH_BACKEND: z.enum(['twitter', 'xquik', 'hermes-tweet']).default('twitter'), + XQUIK_API_KEY: z.string().optional(), + HERMES_TWEET_API_KEY: z.string().optional(), + XQUIK_BASE_URL: z.string().url().default('https://xquik.com'), + XQUIK_AUTH_SCHEME: z.enum(['api-key', 'bearer']).default('api-key'), DATABASE_URL: z.string().optional(), }, runtimeEnv: process.env, diff --git a/src/mcp-xquik.test.ts b/src/mcp-xquik.test.ts new file mode 100644 index 0000000..16ba3bf --- /dev/null +++ b/src/mcp-xquik.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const toolHandlers: Record Promise> = {}; + +const mocks = vi.hoisted(() => { + process.env.NODE_ENV = 'development'; + process.env.SEARCH_BACKEND = 'xquik'; + process.env.XQUIK_API_KEY = 'test-xquik-key'; + process.env.XQUIK_BASE_URL = 'https://example.test'; + + return { + searchTweetsWithXquik: vi.fn(), + twitterClient: vi.fn() + }; +}); + +vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => { + return { + McpServer: class MockMcpServer { + constructor() {} + tool(name: string, _description: string, _schema: any, handler: any) { + toolHandlers[name] = handler; + } + }, + }; +}); + +vi.mock('./xquik-api.js', () => { + return { + searchTweetsWithXquik: mocks.searchTweetsWithXquik + }; +}); + +vi.mock('./twitter-api.js', () => { + return { + TwitterClient: mocks.twitterClient + }; +}); + +vi.mock('./formatter.js', () => ({ + ResponseFormatter: { + formatSearchResponse: vi.fn().mockReturnValue({ formatted: true }), + toMcpResponse: vi.fn().mockReturnValue('formatted response'), + }, +})); + +import { createMcp } from './mcp.js'; +import { ResponseFormatter } from './formatter.js'; + +describe('mcp.ts with Xquik search backend', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.keys(toolHandlers).forEach((key) => delete toolHandlers[key]); + createMcp(); + }); + + it('should search with Xquik without OAuth metadata', async () => { + const tweets = [{ + id: '1', + text: 'Hermes Tweet result', + authorId: 'u1', + metrics: { likes: 4, retweets: 2 }, + createdAt: '2026-05-24T17:00:00Z' + }]; + const users = [{ id: 'u1', username: 'hermes_user' }]; + mocks.searchTweetsWithXquik.mockResolvedValue({ tweets, users }); + + const result = await toolHandlers['search_tweets']({ query: 'Hermes Tweet', count: 10 }, {}); + + expect(mocks.twitterClient).not.toHaveBeenCalled(); + expect(mocks.searchTweetsWithXquik).toHaveBeenCalledWith('Hermes Tweet', 10, { + apiKey: 'test-xquik-key', + authScheme: 'api-key', + baseUrl: 'https://example.test' + }); + expect(ResponseFormatter.formatSearchResponse).toHaveBeenCalledWith('Hermes Tweet', tweets, users); + expect(result.content[0].text).toBe('formatted response'); + }); +}); diff --git a/src/mcp.ts b/src/mcp.ts index da0910b..29645b7 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -4,6 +4,23 @@ import { z } from "zod"; import { ResponseFormatter } from "./formatter.js"; import { TwitterClient } from "./twitter-api.js"; import { env } from "./configs/env.js"; +import { searchTweetsWithXquik } from "./xquik-api.js"; + +function createTwitterClient(clientInfo: Record): TwitterClient { + if (!clientInfo?.oauth_token || !clientInfo?.oauth_token_secret) { + throw new Error(`No twitter client for sessionId`); + } + if (!env.TWITTER_API_KEY || !env.TWITTER_API_SECRET) { + throw new Error('TWITTER_API_KEY and TWITTER_API_SECRET are required for Twitter OAuth tools'); + } + + return new TwitterClient({ + appKey: env.TWITTER_API_KEY, + appSecret: env.TWITTER_API_SECRET, + accessToken: clientInfo.oauth_token.toString(), + accessSecret: clientInfo.oauth_token_secret.toString() + }); +} export const createMcp = () => { const mcp = new McpServer({ @@ -25,15 +42,7 @@ export const createMcp = () => { async (input, extra) => { try { const clientInfo = extra._meta?.client as Record; - if (!clientInfo?.oauth_token || !clientInfo?.oauth_token_secret) { - throw new Error(`No twitter client for sessionId`); - } - const client = new TwitterClient({ - appKey: env.TWITTER_API_KEY, - appSecret: env.TWITTER_API_SECRET, - accessToken: clientInfo.oauth_token.toString(), - accessSecret: clientInfo.oauth_token_secret.toString() - }); + const client = createTwitterClient(clientInfo); const res = await client.retweet(input.tweetId); return { @@ -70,15 +79,7 @@ export const createMcp = () => { async (input, extra) => { try { const clientInfo = extra._meta?.client as Record; - if (!clientInfo?.oauth_token || !clientInfo?.oauth_token_secret) { - throw new Error(`No twitter client for sessionId`); - } - const client = new TwitterClient({ - appKey: env.TWITTER_API_KEY, - appSecret: env.TWITTER_API_SECRET, - accessToken: clientInfo.oauth_token.toString(), - accessSecret: clientInfo.oauth_token_secret.toString() - }); + const client = createTwitterClient(clientInfo); const res = await client.likeTweet(input.tweetId); return { content: [ @@ -121,15 +122,7 @@ export const createMcp = () => { async (input, extra) => { try { const clientInfo = extra._meta?.client as Record; - if (!clientInfo?.oauth_token || !clientInfo?.oauth_token_secret) { - throw new Error(`No twitter client for sessionId`); - } - const client = new TwitterClient({ - appKey: env.TWITTER_API_KEY, - appSecret: env.TWITTER_API_SECRET, - accessToken: clientInfo.oauth_token.toString(), - accessSecret: clientInfo.oauth_token_secret.toString() - }); + const client = createTwitterClient(clientInfo); const tweet = await client.postTweet(input.text, input.images); const me = await client.getMe(); @@ -162,19 +155,13 @@ export const createMcp = () => { async (input, extra) => { try { const clientInfo = extra._meta?.client as Record; - if (!clientInfo?.oauth_token || !clientInfo?.oauth_token_secret) { - throw new Error(`No twitter client for sessionId`); - } - const client = new TwitterClient({ - appKey: env.TWITTER_API_KEY, - appSecret: env.TWITTER_API_SECRET, - accessToken: clientInfo.oauth_token.toString(), - accessSecret: clientInfo.oauth_token_secret.toString() - }); - const { tweets, users } = await client.searchTweets( - input.query, - input.count - ); + const { tweets, users } = env.SEARCH_BACKEND === 'twitter' + ? await createTwitterClient(clientInfo).searchTweets(input.query, input.count) + : await searchTweetsWithXquik(input.query, input.count, { + apiKey: env.XQUIK_API_KEY ?? env.HERMES_TWEET_API_KEY, + authScheme: env.XQUIK_AUTH_SCHEME, + baseUrl: env.XQUIK_BASE_URL + }); const formattedResponse = ResponseFormatter.formatSearchResponse( input.query, @@ -204,4 +191,4 @@ export const createMcp = () => { ) return mcp; -} \ No newline at end of file +} diff --git a/src/xquik-api.test.ts b/src/xquik-api.test.ts new file mode 100644 index 0000000..815f1d3 --- /dev/null +++ b/src/xquik-api.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildXquikSearchUrl, searchTweetsWithXquik } from './xquik-api.js'; + +describe('xquik-api.ts', () => { + it('should build search URLs with custom base paths', () => { + const url = new URL(buildXquikSearchUrl('ai agents', 25, 'https://example.test/proxy/')); + + expect(url.pathname).toBe('/proxy/api/v1/x/tweets/search'); + expect(url.searchParams.get('q')).toBe('ai agents'); + expect(url.searchParams.get('limit')).toBe('25'); + }); + + it('should normalize tweets and included users', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + tweets: [{ + tweetId: '1', + full_text: 'Hermes Tweet result', + authorId: 'u1', + publicMetrics: { + like_count: '4', + retweet_count: 2 + }, + createdAt: '2026-05-24T17:00:00Z' + }], + includes: { + users: [{ + id: 'u1', + username: 'hermes_user' + }] + } + } + }) + }); + + const result = await searchTweetsWithXquik('Hermes Tweet', 10, { + apiKey: 'test-key', + baseUrl: 'https://example.test', + fetchImpl: fetchImpl as typeof fetch, + timeoutMs: 1000 + }); + + expect(result.tweets).toEqual([{ + id: '1', + text: 'Hermes Tweet result', + authorId: 'u1', + metrics: { + likes: 4, + retweets: 2 + }, + createdAt: '2026-05-24T17:00:00Z' + }]); + expect(result.users).toEqual([{ id: 'u1', username: 'hermes_user' }]); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [_url, options] = fetchImpl.mock.calls[0]; + expect((options as RequestInit).headers).toMatchObject({ 'x-api-key': 'test-key' }); + }); + + it('should fail before fetch when the API key is missing', async () => { + const fetchImpl = vi.fn(); + + await expect(searchTweetsWithXquik('Hermes Tweet', 10, { + fetchImpl: fetchImpl as typeof fetch + })).rejects.toThrow('XQUIK_API_KEY or HERMES_TWEET_API_KEY'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/xquik-api.ts b/src/xquik-api.ts new file mode 100644 index 0000000..be919c1 --- /dev/null +++ b/src/xquik-api.ts @@ -0,0 +1,217 @@ +import { Tweet, TwitterUser } from './types.js'; + +const SEARCH_PATH = '/api/v1/x/tweets/search'; +const REQUEST_TIMEOUT_MS = 15000; + +type FetchLike = typeof fetch; + +export interface XquikSearchOptions { + apiKey?: string; + authScheme?: 'api-key' | 'bearer'; + baseUrl?: string; + fetchImpl?: FetchLike; + timeoutMs?: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function numberValue(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function pick(record: Record, keys: string[]): unknown { + for (const key of keys) { + if (record[key] !== undefined && record[key] !== null) { + return record[key]; + } + } + return undefined; +} + +function findTweetArray(payload: unknown): unknown[] { + if (Array.isArray(payload)) { + return payload; + } + if (!isRecord(payload)) { + return []; + } + + for (const key of ['tweets', 'results', 'items', 'data', 'users']) { + const value = payload[key]; + if (Array.isArray(value)) { + return value; + } + const nested = findTweetArray(value); + if (nested.length) { + return nested; + } + } + + return []; +} + +function normalizeMetrics(value: unknown): Tweet['metrics'] { + const metrics = isRecord(value) ? value : {}; + return { + likes: numberValue(metrics.like_count) ?? numberValue(metrics.likes) ?? 0, + retweets: numberValue(metrics.retweet_count) ?? numberValue(metrics.retweets) ?? 0 + }; +} + +function normalizeTweet(raw: unknown): Tweet | null { + if (!isRecord(raw)) { + return null; + } + + const id = stringValue(pick(raw, ['id', 'tweet_id', 'tweetId', 'rest_id'])); + const text = stringValue(pick(raw, ['text', 'full_text', 'content', 'body'])); + if (!id || !text) { + return null; + } + + return { + id, + text, + authorId: stringValue(pick(raw, ['author_id', 'authorId', 'user_id', 'userId'])) ?? '', + metrics: normalizeMetrics(pick(raw, ['public_metrics', 'publicMetrics', 'metrics'])), + createdAt: stringValue(pick(raw, ['created_at', 'createdAt', 'time'])) ?? '' + }; +} + +function normalizeUser(raw: unknown): TwitterUser | null { + if (!isRecord(raw)) { + return null; + } + + const id = stringValue(pick(raw, ['id', 'user_id', 'userId', 'author_id', 'authorId'])); + const username = stringValue(pick(raw, ['username', 'screen_name', 'screenName', 'handle'])); + if (!id || !username) { + return null; + } + + return { id, username }; +} + +function extractUsers(payload: unknown, tweets: Tweet[]): TwitterUser[] { + const usersById = new Map(); + + const collectUsers = (value: unknown) => { + if (Array.isArray(value)) { + for (const rawUser of value) { + const user = normalizeUser(rawUser); + if (user) { + usersById.set(user.id, user); + } + } + return; + } + if (!isRecord(value)) { + return; + } + + if (Array.isArray(value.users)) { + collectUsers(value.users); + } + if (value.data) { + collectUsers(value.data); + } + if (value.includes) { + collectUsers(value.includes); + } + for (const rawTweet of findTweetArray(value)) { + if (!isRecord(rawTweet)) { + continue; + } + const user = normalizeUser(rawTweet.author ?? rawTweet.user); + if (user) { + usersById.set(user.id, user); + } + } + }; + + collectUsers(payload); + + return tweets.map((tweet) => { + return usersById.get(tweet.authorId) ?? { + id: tweet.authorId, + username: tweet.authorId || 'unknown' + }; + }); +} + +export function buildXquikSearchUrl( + query: string, + count: number, + baseUrl = 'https://xquik.com' +): string { + const url = new URL(baseUrl); + const basePath = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname; + url.pathname = `${basePath === '/' ? '' : basePath}${SEARCH_PATH}`; + url.searchParams.set('q', query); + url.searchParams.set('limit', String(count)); + return url.toString(); +} + +function buildHeaders(apiKey: string, authScheme: 'api-key' | 'bearer'): Record { + return { + Accept: 'application/json', + ...(authScheme === 'bearer' + ? { Authorization: `Bearer ${apiKey}` } + : { 'x-api-key': apiKey }) + }; +} + +export async function searchTweetsWithXquik( + query: string, + count: number, + options: XquikSearchOptions = {} +): Promise<{ tweets: Tweet[], users: TwitterUser[] }> { + const apiKey = options.apiKey?.trim(); + if (!apiKey) { + throw new Error('XQUIK_API_KEY or HERMES_TWEET_API_KEY is required when SEARCH_BACKEND=xquik'); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? REQUEST_TIMEOUT_MS); + let response: Response; + try { + response = await (options.fetchImpl ?? fetch)( + buildXquikSearchUrl(query, count, options.baseUrl), + { + headers: buildHeaders(apiKey, options.authScheme ?? 'api-key'), + signal: controller.signal + } + ); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + const body = await response.text().catch(() => ''); + const details = body.trim() ? `: ${body.trim().slice(0, 300)}` : ''; + throw new Error(`Xquik search failed with HTTP ${response.status}${details}`); + } + + const payload = await response.json(); + const tweets = findTweetArray(payload) + .map(normalizeTweet) + .filter((tweet): tweet is Tweet => tweet !== null); + + return { + tweets, + users: extractUsers(payload, tweets) + }; +} From cab94ba6e93bbb2c28475fcfd5acdd01124c4957 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 24 May 2026 20:32:56 +0300 Subject: [PATCH 2/2] fix: honor Hermes Tweet key fallback --- src/mcp-xquik.test.ts | 5 +++-- src/mcp.ts | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/mcp-xquik.test.ts b/src/mcp-xquik.test.ts index 16ba3bf..91cf2fc 100644 --- a/src/mcp-xquik.test.ts +++ b/src/mcp-xquik.test.ts @@ -5,7 +5,8 @@ const toolHandlers: Record Promise> = { const mocks = vi.hoisted(() => { process.env.NODE_ENV = 'development'; process.env.SEARCH_BACKEND = 'xquik'; - process.env.XQUIK_API_KEY = 'test-xquik-key'; + process.env.XQUIK_API_KEY = ''; + process.env.HERMES_TWEET_API_KEY = 'test-hermes-key'; process.env.XQUIK_BASE_URL = 'https://example.test'; return { @@ -69,7 +70,7 @@ describe('mcp.ts with Xquik search backend', () => { expect(mocks.twitterClient).not.toHaveBeenCalled(); expect(mocks.searchTweetsWithXquik).toHaveBeenCalledWith('Hermes Tweet', 10, { - apiKey: 'test-xquik-key', + apiKey: 'test-hermes-key', authScheme: 'api-key', baseUrl: 'https://example.test' }); diff --git a/src/mcp.ts b/src/mcp.ts index 29645b7..602cca2 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -22,6 +22,10 @@ function createTwitterClient(clientInfo: Record): TwitterClient }); } +function xquikSearchApiKey(): string | undefined { + return env.XQUIK_API_KEY?.trim() || env.HERMES_TWEET_API_KEY?.trim(); +} + export const createMcp = () => { const mcp = new McpServer({ name: 'twitter-mcp', @@ -158,7 +162,7 @@ export const createMcp = () => { const { tweets, users } = env.SEARCH_BACKEND === 'twitter' ? await createTwitterClient(clientInfo).searchTweets(input.query, input.count) : await searchTweetsWithXquik(input.query, input.count, { - apiKey: env.XQUIK_API_KEY ?? env.HERMES_TWEET_API_KEY, + apiKey: xquikSearchApiKey(), authScheme: env.XQUIK_AUTH_SCHEME, baseUrl: env.XQUIK_BASE_URL });