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
18 changes: 18 additions & 0 deletions src/lib/protocol-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Pin the proxy's supported revisions independently of future SDK upgrades.
export const DEFAULT_PROTOCOL_VERSION = '2025-11-25';
export const LEGACY_PROTOCOL_VERSION = '2025-06-18';
const SUPPORTED_PROTOCOL_VERSIONS = [
DEFAULT_PROTOCOL_VERSION,
LEGACY_PROTOCOL_VERSION,
'2025-03-26',
'2024-11-05',
'2024-10-07',
];

export function isSupportedProtocolVersion(version: unknown): version is string {
return typeof version === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(version);
}

export function selectProtocolVersion(version: unknown): string {
return isSupportedProtocolVersion(version) ? version : DEFAULT_PROTOCOL_VERSION;
}
46 changes: 45 additions & 1 deletion src/lib/wordpress-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ import {
import { PersistentWPOAuthClientProvider } from './persistent-oauth-client-provider.js';
import { MCPOAuthProvider } from './mcp-oauth-provider.js';
import { createLazyWPAuthCoordinator } from './coordination.js';
import {
DEFAULT_PROTOCOL_VERSION,
LEGACY_PROTOCOL_VERSION,
isSupportedProtocolVersion,
selectProtocolVersion,
} from './protocol-version.js';

/**
* WordPress API request function with OAuth, JWT, and Basic Auth support
Expand All @@ -38,6 +44,7 @@ let globalEvents: EventEmitter | null = null;

// Global session ID received from WordPress server
let globalSessionId: string | null = null;
let negotiatedProtocolVersion: string | null = null;
let lastInitializeRequest: { requestData: any; useJsonRpc: boolean } | null = null;
let sessionRefreshPromise: Promise<void> | null = null;

Expand Down Expand Up @@ -231,6 +238,28 @@ function isInitializeRequest(requestData: any): boolean {
return requestData?.method === 'initialize';
}

function recordProtocolVersion(requestData: any, result: any): void {
if (!isInitializeRequest(requestData)) return;

// Preserve the legacy response fallback used by the stdio initialize handler.
const version = result?.protocolVersion || LEGACY_PROTOCOL_VERSION;
if (!isSupportedProtocolVersion(version)) {
throw new APIError(
`Unsupported WordPress MCP protocol version: ${version}`,
0,
getRequestUrl()
);
}
if (negotiatedProtocolVersion && negotiatedProtocolVersion !== version) {
throw new APIError(
'WordPress changed the negotiated MCP protocol version; reconnect the client',
0,
getRequestUrl()
);
}
negotiatedProtocolVersion = version;
}

function cacheInitializeRequest(requestData: any, useJsonRpc: boolean): void {
lastInitializeRequest = {
requestData: cloneRequestData(requestData),
Expand Down Expand Up @@ -373,6 +402,10 @@ async function executeWordPressRequest(

// Get custom headers early to check if they can serve as authentication
const customHeaders = getCustomHeaders();
// Header names are case-insensitive; configuration cannot override negotiation.
for (const name of Object.keys(customHeaders)) {
if (name.toLowerCase() === 'mcp-protocol-version') delete customHeaders[name];
}
const hasCustomHeaders = Object.keys(customHeaders).length > 0;

// Ensure we have an authorization header OR custom headers for authentication
Expand All @@ -391,8 +424,12 @@ async function executeWordPressRequest(
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': '2025-06-18', // MCP protocol version
...customHeaders, // Merge custom headers
'MCP-Protocol-Version': isInitializeRequest(requestData)
? selectProtocolVersion(
useJsonRpc ? requestData.params?.protocolVersion : requestData.protocolVersion
)
: negotiatedProtocolVersion || DEFAULT_PROTOCOL_VERSION,
};

// Add Authorization header only if we have one
Expand Down Expand Up @@ -489,6 +526,7 @@ async function executeWordPressRequest(
jsonrpcResponse.error
);
} else if (jsonrpcResponse.result !== undefined) {
recordProtocolVersion(requestData, jsonrpcResponse.result);
// Extract result from JSON-RPC response
return {
responseData: jsonrpcResponse.result as WordPressResponse,
Expand All @@ -499,6 +537,7 @@ async function executeWordPressRequest(
}

// For simple transport or non-JSON-RPC responses, return response as-is
recordProtocolVersion(requestData, responseData);
return {
responseData: responseData as WordPressResponse,
sessionIdUsed,
Expand Down Expand Up @@ -591,6 +630,11 @@ export async function wpRequest(
const allowSessionRecovery = options.allowSessionRecovery !== false;

if (isInitializeRequest(requestData)) {
const params = useJsonRpc ? requestData.params : requestData;
const protocolVersion = selectProtocolVersion(params?.protocolVersion);
requestData = useJsonRpc
? { ...requestData, params: { ...params, protocolVersion } }
: { ...requestData, protocolVersion };
cacheInitializeRequest(requestData, useJsonRpc);
}

Expand Down
5 changes: 3 additions & 2 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
ListRootsRequestSchema,
} from './lib/mcp-types.js';
import { InitializeRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { LEGACY_PROTOCOL_VERSION, selectProtocolVersion } from './lib/protocol-version.js';

// Check Node.js version
validateNodeVersion(18);
Expand Down Expand Up @@ -98,7 +99,7 @@ async function WordPressProxy() {

// Return the WordPress server's initialize response
const wordpressInitResponse = {
protocolVersion: initResult.protocolVersion || '2025-06-18',
protocolVersion: initResult.protocolVersion || LEGACY_PROTOCOL_VERSION,
serverInfo: initResult.serverInfo,
capabilities: initResult.capabilities,
instructions: initResult.instructions || 'MCP WordPress Remote Proxy Server',
Expand Down Expand Up @@ -128,7 +129,7 @@ async function WordPressProxy() {
// carrying these details to the client).
resolveInit(sessionContext, true, connectionError);

const clientProtocolVersion = request?.params?.protocolVersion || '2025-06-18';
const clientProtocolVersion = selectProtocolVersion(request?.params?.protocolVersion);

// Return a fallback response that advertises NO real capabilities — only
// `experimental.connectionFailed`. The connection is dead, so it cannot
Expand Down
81 changes: 81 additions & 0 deletions tests/unit/wordpress-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,81 @@ describe('WordPress API Module', () => {
});

describe('wpRequest function', () => {
describe('Protocol negotiation', () => {
beforeEach(() => {
restoreEnv = mockEnv({
WP_API_URL: 'https://test-site.com',
JWT_TOKEN: 'test-token',
CUSTOM_HEADERS: '{"mcp-protocol-version":"2025-06-18"}',
});
jest.resetModules();
});

it.each<[boolean, string | undefined, string, string]>([
[true, '2025-11-25', '2025-11-25', '2025-11-25'],
[true, '2025-11-25', '2025-11-25', '2025-06-18'],
[false, '2025-11-25', '2025-11-25', '2025-11-25'],
[false, '2025-06-18', '2025-06-18', '2025-06-18'],
[true, '2026-01-01', '2025-11-25', '2025-11-25'],
[true, undefined, '2025-11-25', '2025-11-25'],
])('aligns body and headers: jsonrpc=%s client=%s offered=%s accepted=%s', async (jsonrpc, client, offered, accepted) => {
const { wpRequest } = await import('../../src/lib/wordpress-api.js');
const request = jsonrpc
? { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: client } }
: { method: 'initialize', protocolVersion: client };
const original = JSON.stringify(request);
nock('https://test-site.com')
.matchHeader('MCP-Protocol-Version', offered)
.post(WP_MCP_ENDPOINT, body => (jsonrpc ? body.params : body).protocolVersion === offered)
.reply(200, jsonrpc ? createJsonRpcResult(1, { protocolVersion: accepted }) : { protocolVersion: accepted });
await wpRequest(request, jsonrpc);
expect(JSON.stringify(request)).toBe(original);
nock('https://test-site.com')
.matchHeader('MCP-Protocol-Version', accepted)
.post(WP_MCP_ENDPOINT)
.reply(200, { tools: [] });
await wpRequest({ method: 'tools/list' }, jsonrpc);
expect(nock.isDone()).toBe(true);
});

it('rejects an unsupported backend version', async () => {
const { wpRequest } = await import('../../src/lib/wordpress-api.js');
nock('https://test-site.com').post(WP_MCP_ENDPOINT)
.reply(200, createJsonRpcResult(1, { protocolVersion: '2026-01-01' }));
await expect(wpRequest({ method: 'initialize', params: {} }))
.rejects.toThrow('Unsupported WordPress MCP protocol version');
});

it('keeps headers aligned with the legacy fallback when the version is absent', async () => {
const { wpRequest } = await import('../../src/lib/wordpress-api.js');
nock('https://test-site.com').post(WP_MCP_ENDPOINT)
.reply(200, createJsonRpcResult(1, { capabilities: {} }));
await wpRequest({ method: 'initialize', params: {} });
nock('https://test-site.com').matchHeader('MCP-Protocol-Version', '2025-06-18')
.post(WP_MCP_ENDPOINT).reply(200, { tools: [] });
await wpRequest({ method: 'tools/list' });
expect(nock.isDone()).toBe(true);
});

it('uses the negotiated version after a session refresh', async () => {
const { wpRequest } = await import('../../src/lib/wordpress-api.js');
const init = { method: 'initialize', params: { protocolVersion: '2025-11-25' } };
nock('https://test-site.com').matchHeader('MCP-Protocol-Version', '2025-11-25')
.post(WP_MCP_ENDPOINT).reply(200, createJsonRpcResult(1, { protocolVersion: '2025-06-18' }), { 'Mcp-Session-Id': 'old' });
await wpRequest(init);
nock('https://test-site.com').matchHeader('MCP-Protocol-Version', '2025-06-18')
.post(WP_MCP_ENDPOINT).reply(400, createJsonRpcError(2, -32005, 'Session not found'));
nock('https://test-site.com').matchHeader('MCP-Protocol-Version', '2025-11-25')
.post(WP_MCP_ENDPOINT, body => body.method === 'initialize')
.reply(200, createJsonRpcResult(1, { protocolVersion: '2025-06-18' }), { 'Mcp-Session-Id': 'new' });
nock('https://test-site.com').matchHeader('MCP-Protocol-Version', '2025-06-18')
.matchHeader('Mcp-Session-Id', 'new').post(WP_MCP_ENDPOINT)
.reply(200, createJsonRpcResult(2, { tools: [] }));
expect(await wpRequest({ method: 'tools/list' })).toEqual({ tools: [] });
expect(nock.isDone()).toBe(true);
});
});

describe('Environment validation', () => {
it('should throw AuthError when configuration validation fails', async () => {
restoreEnv = mockEnv({
Expand Down Expand Up @@ -460,6 +535,7 @@ describe('WordPress API Module', () => {
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
clientInfo: {
name: 'test-client',
version: '1.0.0',
Expand Down Expand Up @@ -542,6 +618,7 @@ describe('WordPress API Module', () => {
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
clientInfo: {
name: 'test-client',
version: '1.0.0',
Expand Down Expand Up @@ -617,6 +694,7 @@ describe('WordPress API Module', () => {
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
clientInfo: {
name: 'test-client',
version: '1.0.0',
Expand Down Expand Up @@ -786,6 +864,7 @@ describe('WordPress API Module', () => {
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
clientInfo: {
name: 'test-client',
version: '1.0.0',
Expand Down Expand Up @@ -834,6 +913,7 @@ describe('WordPress API Module', () => {
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
clientInfo: {
name: 'test-client',
version: '1.0.0',
Expand Down Expand Up @@ -866,6 +946,7 @@ describe('WordPress API Module', () => {
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
clientInfo: {
name: 'test-client',
version: '1.0.0',
Expand Down