Skip to content
Open
Show file tree
Hide file tree
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
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
PORT=3000
NODE_ENV=production
TWITTER_API_KEY=
TWITTER_API_SECRET=
TWITTER_API_SECRET=
SEARCH_BACKEND=twitter
XQUIK_API_KEY=
HERMES_TWEET_API_KEY=
XQUIK_BASE_URL=https://xquik.com
XQUIK_AUTH_SCHEME=api-key
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 7 additions & 2 deletions src/configs/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions src/mcp-xquik.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const toolHandlers: Record<string, (input: any, extra: any) => Promise<any>> = {};

const mocks = vi.hoisted(() => {
process.env.NODE_ENV = 'development';
process.env.SEARCH_BACKEND = 'xquik';
process.env.XQUIK_API_KEY = '';
process.env.HERMES_TWEET_API_KEY = 'test-hermes-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-hermes-key',
authScheme: 'api-key',
baseUrl: 'https://example.test'
});
expect(ResponseFormatter.formatSearchResponse).toHaveBeenCalledWith('Hermes Tweet', tweets, users);
expect(result.content[0].text).toBe('formatted response');
});
});
73 changes: 32 additions & 41 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ 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<string, string>): 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()
});
}

function xquikSearchApiKey(): string | undefined {
return env.XQUIK_API_KEY?.trim() || env.HERMES_TWEET_API_KEY?.trim();
}

export const createMcp = () => {
const mcp = new McpServer({
Expand All @@ -25,15 +46,7 @@ export const createMcp = () => {
async (input, extra) => {
try {
const clientInfo = extra._meta?.client as Record<string, string>;
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 {
Expand Down Expand Up @@ -70,15 +83,7 @@ export const createMcp = () => {
async (input, extra) => {
try {
const clientInfo = extra._meta?.client as Record<string, string>;
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: [
Expand Down Expand Up @@ -121,15 +126,7 @@ export const createMcp = () => {
async (input, extra) => {
try {
const clientInfo = extra._meta?.client as Record<string, string>;
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();

Expand Down Expand Up @@ -162,19 +159,13 @@ export const createMcp = () => {
async (input, extra) => {
try {
const clientInfo = extra._meta?.client as Record<string, string>;
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: xquikSearchApiKey(),
authScheme: env.XQUIK_AUTH_SCHEME,
baseUrl: env.XQUIK_BASE_URL
});

const formattedResponse = ResponseFormatter.formatSearchResponse(
input.query,
Expand Down Expand Up @@ -204,4 +195,4 @@ export const createMcp = () => {
)

return mcp;
}
}
69 changes: 69 additions & 0 deletions src/xquik-api.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading