Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
79 changes: 79 additions & 0 deletions src/mcp-xquik.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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 = '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');
});
});
69 changes: 28 additions & 41 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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()
});
}

export const createMcp = () => {
const mcp = new McpServer({
Expand All @@ -25,15 +42,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 +79,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 +122,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 +155,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: env.XQUIK_API_KEY ?? env.HERMES_TWEET_API_KEY,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor Hermes API key when XQUIK key is blank

apiKey is selected with nullish coalescing, so an empty XQUIK_API_KEY (common when .env contains XQUIK_API_KEY=) wins over a populated HERMES_TWEET_API_KEY. In that configuration, searchTweetsWithXquik receives "", fails the trim check, and every search_tweets call errors even though the alternate key is set as documented. Use a fallback that treats blank strings as missing (e.g., trim before choosing, or use a non-empty check) so the Hermes key path actually works.

Useful? React with 👍 / 👎.

authScheme: env.XQUIK_AUTH_SCHEME,
baseUrl: env.XQUIK_BASE_URL
});

const formattedResponse = ResponseFormatter.formatSearchResponse(
input.query,
Expand Down Expand Up @@ -204,4 +191,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