Skip to content

Commit c80b83d

Browse files
committed
2.0.0
1 parent dedaa22 commit c80b83d

12 files changed

Lines changed: 235 additions & 46 deletions

.mcp/server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
"url": "https://github.com/ihor-sokoliuk/mcp-searxng",
88
"source": "github"
99
},
10-
"version": "1.16.0",
10+
"version": "2.0.0",
1111
"packages": [
1212
{
1313
"registryType": "npm",
1414
"identifier": "mcp-searxng",
15-
"version": "1.16.0",
15+
"version": "2.0.0",
1616
"transport": {
1717
"type": "stdio"
1818
},

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@ Versions follow [Semantic Versioning](https://semver.org/).
55

66
## Unreleased
77

8+
## [2.0.0] - 2026-08-20
9+
10+
### Breaking Changes
11+
12+
- **Node.js 22 or later is now required:** Node.js 20 is no longer supported, and the PDF extraction runtime now uses `unpdf` 1.8.1 with loading-task teardown. Consumers and deployments must upgrade to Node.js 22 or a newer supported release before installing mcp-searxng 2.0.0. ([#251](https://github.com/ihor-sokoliuk/mcp-searxng/pull/251), [#253](https://github.com/ihor-sokoliuk/mcp-searxng/pull/253))
13+
14+
- **STDIO post-connect logging notifications were removed:** The modern protocol deprecates MCP logging notifications, so clients must no longer rely on the former post-connect version, log-level, environment, or configured-instance notices. Interactive launches retain version and configured-instance connection diagnostics on sanitized stderr; non-TTY launches retain configured-instance status there. Tool and resource behavior is unchanged. ([#255](https://github.com/ihor-sokoliuk/mcp-searxng/pull/255))
15+
16+
- **The npm package is now executable-only:** The accidental programmatic module entry point has been removed, and direct package imports such as `createMcpServer` are no longer a supported integration surface. Run mcp-searxng through its CLI, `npx`, or container and communicate with it through MCP. Standard MCP client configuration, tool calls, resources, CLI usage, and container deployments are unchanged. ([#255](https://github.com/ihor-sokoliuk/mcp-searxng/pull/255))
17+
18+
- **HTTP requests now use the modern protocol's validation and capacity boundaries:** Every `POST /mcp` requires `Content-Type: application/json` or receives HTTP 415. When HTTP hardening enables Host validation, it now covers retained stateful as well as stateless requests. Modern sessionless POSTs require the matching `MCP-Protocol-Version: 2026-07-28` header, use the initialization rate-limit bucket, and are bounded by `MCP_HTTP_STATELESS_MAX_IN_FLIGHT`, `MCP_HTTP_STATELESS_MAX_IN_FLIGHT_PER_IP`, and `MCP_HTTP_STATELESS_REQUEST_TIMEOUT_MS` even when legacy `MCP_HTTP_STATELESS` mode is disabled; overload and timeout responses are HTTP 503 and 504. Before upgrading, custom HTTP clients must send the required content type and protocol header, hardened deployments must allow the intended Host values, and operators should tune the documented capacity limits for expected modern traffic. STDIO users are unaffected. ([#255](https://github.com/ihor-sokoliuk/mcp-searxng/pull/255))
19+
20+
### Added
21+
22+
- **Modern MCP protocol serving with the official split SDK v2 packages:** HTTP and STDIO now support the modern `2026-07-28` protocol while retaining the documented legacy transports, tool and resource contracts, admission controls, credential-safe diagnostics, and bounded HTTP cleanup. Modern HTTP requests are sessionless POST operations; legacy stateful and stateless behavior keeps its documented session boundaries. ([#255](https://github.com/ihor-sokoliuk/mcp-searxng/pull/255))
23+
24+
### Security
25+
26+
- **Process-wide tool invocation admission now protects every transport:** STDIO, stateful HTTP, and stateless HTTP share a bounded fixed-window tool-call rate and a hard no-queue in-flight ceiling. Operators can tune `MCP_TOOL_RATE_WINDOW_MS`, `MCP_TOOL_RATE_MAX`, and `MCP_TOOL_MAX_IN_FLIGHT`; exhausted calls receive a sanitized retryable result while initialization, discovery, resources, logging, and health checks remain available. ([#254](https://github.com/ihor-sokoliuk/mcp-searxng/pull/254))
27+
28+
### Contributors
29+
30+
- @app/dependabot - [#252](https://github.com/ihor-sokoliuk/mcp-searxng/pull/252) chore(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 in the github-actions group across 1 directory
31+
832
## [1.16.0] - 2026-08-19
933

1034
### Added

__tests__/integration/http-server.test.ts

Lines changed: 122 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -260,22 +260,33 @@ async function runTests() {
260260
assert.equal(await isLegacyRequest(webRequest, body), false);
261261
}, results);
262262

263-
await testFunction('non-JSON legacy POST returns 415 before classification or server construction', async () => {
263+
await testFunction('non-JSON POST uses the init limiter and returns 415 before server construction', async () => {
264+
envManager.set('MCP_RATE_INIT_MAX', '1');
264265
let constructions = 0;
265-
const app = await createHttpServer(() => {
266-
constructions += 1;
267-
return createTestMcpServer();
268-
});
269-
const response = await request(app).post('/mcp')
270-
.set('Content-Type', 'text/plain')
271-
.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }));
272-
assert.equal(response.status, 415);
273-
assert.deepEqual(response.body, {
274-
jsonrpc: '2.0',
275-
error: { code: -32000, message: 'Unsupported Media Type' },
276-
id: null,
277-
});
278-
assert.equal(constructions, 0);
266+
try {
267+
const app = await createHttpServer(() => {
268+
constructions += 1;
269+
return createTestMcpServer();
270+
});
271+
const response = await request(app).post('/mcp')
272+
.set('Content-Type', 'text/plain')
273+
.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }));
274+
assert.equal(response.status, 415);
275+
assert.equal(response.headers['ratelimit-limit'], '1');
276+
assert.deepEqual(response.body, {
277+
jsonrpc: '2.0',
278+
error: { code: -32000, message: 'Unsupported Media Type' },
279+
id: null,
280+
});
281+
282+
const exhausted = await request(app).post('/mcp')
283+
.set('Content-Type', 'text/plain')
284+
.send('{}');
285+
assert.equal(exhausted.status, 429);
286+
assert.equal(constructions, 0);
287+
} finally {
288+
envManager.restore();
289+
}
279290
}, results);
280291

281292
await testFunction('temporary missing-version guard is pinned to the published server 2.0.0 package', () => {
@@ -677,6 +688,31 @@ async function runTests() {
677688
}
678689
}, results);
679690

691+
await testFunction('hardened stateful GET and DELETE reject disallowed Host values', async () => {
692+
envManager.set('MCP_HTTP_HARDEN', 'true');
693+
envManager.set('MCP_HTTP_AUTH_TOKEN', 'stateful-host-test-token');
694+
envManager.set('MCP_HTTP_ALLOWED_ORIGINS', 'https://client.example');
695+
envManager.set('MCP_HTTP_ALLOWED_HOSTS', 'allowed.example');
696+
697+
try {
698+
const app = await createHttpServer(() => createTestMcpServer());
699+
const expected = {
700+
jsonrpc: '2.0',
701+
error: { code: -32000, message: 'Invalid Host header' },
702+
id: null,
703+
};
704+
for (const method of ['get', 'delete'] as const) {
705+
const response = await request(app)[method]('/mcp')
706+
.set('Authorization', 'Bearer stateful-host-test-token')
707+
.set('Host', 'attacker.example');
708+
assert.equal(response.status, 403);
709+
assert.deepEqual(response.body, expected);
710+
}
711+
} finally {
712+
envManager.restore();
713+
}
714+
}, results);
715+
680716

681717
await testFunction('stateless POST limiter selection uses the request body and ignores session headers', async () => {
682718
envManager.set('MCP_HTTP_STATELESS', 'true');
@@ -932,6 +968,57 @@ async function runTests() {
932968
}
933969
}, results);
934970

971+
await testFunction('modern default-mode timeout returns 504 and restores capacity', async () => {
972+
envManager.delete('MCP_HTTP_STATELESS');
973+
envManager.set('MCP_HTTP_STATELESS_MAX_IN_FLIGHT', '1');
974+
envManager.set('MCP_HTTP_STATELESS_MAX_IN_FLIGHT_PER_IP', '1');
975+
envManager.set('MCP_HTTP_STATELESS_REQUEST_TIMEOUT_MS', '1000');
976+
const never = new Promise<void>(() => undefined);
977+
let constructions = 0;
978+
979+
try {
980+
const app = await createHttpServer(() => {
981+
constructions += 1;
982+
const server = createTestMcpServer();
983+
if (constructions === 1) {
984+
server.connect = async () => {
985+
await never;
986+
};
987+
}
988+
return server;
989+
});
990+
const modernBody = (id: number) => ({
991+
jsonrpc: '2.0', id, method: 'tools/list',
992+
params: {
993+
_meta: {
994+
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
995+
'io.modelcontextprotocol/clientCapabilities': {},
996+
},
997+
},
998+
});
999+
const postModern = (id: number) => request(app).post('/mcp')
1000+
.set('Content-Type', 'application/json')
1001+
.set('Accept', 'application/json')
1002+
.set('MCP-Protocol-Version', '2026-07-28')
1003+
.set('MCP-Method', 'tools/list')
1004+
.send(modernBody(id));
1005+
1006+
const timedOut = await postModern(1).timeout({ deadline: 2000 });
1007+
assert.equal(timedOut.status, 504);
1008+
assert.deepEqual(timedOut.body, {
1009+
jsonrpc: '2.0',
1010+
error: { code: -32000, message: 'Stateless request timed out' },
1011+
id: null,
1012+
});
1013+
1014+
const recovered = await postModern(2);
1015+
assert.equal(recovered.status, 200);
1016+
assert.equal(constructions, 2);
1017+
} finally {
1018+
envManager.restore();
1019+
}
1020+
}, results);
1021+
9351022
await testFunction('stateless capacity rejections consume the selected rate-limit bucket', async () => {
9361023
envManager.set('MCP_HTTP_STATELESS', 'true');
9371024
envManager.set('MCP_HTTP_STATELESS_MAX_IN_FLIGHT', '1');
@@ -1824,7 +1911,7 @@ async function runTests() {
18241911
}, results);
18251912

18261913
await testFunction('Rate limiting: modern POSTs cannot borrow a live legacy session bucket', async () => {
1827-
envManager.set('MCP_RATE_INIT_MAX', '2');
1914+
envManager.set('MCP_RATE_INIT_MAX', '3');
18281915
envManager.set('MCP_RATE_SESSION_MAX', '11');
18291916
envManager.set('MCP_RATE_WINDOW_MS', '60000');
18301917
const app = await createHttpServer((modern) => createMcpServer(new ToolAdmissionController({
@@ -1865,13 +1952,29 @@ async function runTests() {
18651952
.send(modernBody(id));
18661953

18671954
const accepted = await modernPost(2);
1868-
const blocked = await modernPost(3);
1955+
const unsupportedClaim = await request(app)
1956+
.post('/mcp')
1957+
.set('Content-Type', 'application/json')
1958+
.set('Accept', 'application/json')
1959+
.set('mcp-session-id', sessionId)
1960+
.send({
1961+
jsonrpc: '2.0', id: 3, method: 'server/discover',
1962+
params: {
1963+
_meta: {
1964+
'io.modelcontextprotocol/protocolVersion': '2099-01-01',
1965+
'io.modelcontextprotocol/clientCapabilities': {},
1966+
},
1967+
},
1968+
});
1969+
const blocked = await modernPost(4);
18691970
envManager.restore();
18701971

18711972
assert.equal(accepted.status, 200);
1872-
assert.equal(accepted.headers['ratelimit-limit'], '2');
1973+
assert.equal(accepted.headers['ratelimit-limit'], '3');
1974+
assert.notEqual(unsupportedClaim.status, 429);
1975+
assert.equal(unsupportedClaim.headers['ratelimit-limit'], '3');
18731976
assert.equal(blocked.status, 429);
1874-
assert.equal(blocked.headers['ratelimit-limit'], '2');
1977+
assert.equal(blocked.headers['ratelimit-limit'], '3');
18751978
assert.equal(blocked.body.error.code, -32029);
18761979
}, results);
18771980

__tests__/unit/documentation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ function hasDependabotIgnoreEntry(source: string, dependencyName: string): boole
153153
function assertPackageMetadata(): void {
154154
const packageManifest = JSON.parse(readText(new URL('../../package.json', import.meta.url))) as PackageManifest;
155155
const packageLock = JSON.parse(readText(new URL('../../package-lock.json', import.meta.url))) as PackageLock;
156-
assert.equal(packageManifest.version, '1.16.0');
156+
assert.equal(packageManifest.version, '2.0.0');
157157
assert.equal(packageManifest.engines?.node, '>=22');
158158
assert.equal(packageManifest.dependencies?.unpdf, '1.8.1');
159159
assert.equal(packageLock.packages?.[''].engines?.node, '>=22');

__tests__/unit/packed-consumer.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ async function runWorkflowContractTests(): Promise<void> {
226226
},
227227
{
228228
name: 'mcp-searxng',
229+
exports: {},
229230
dependencies: {
230231
'@modelcontextprotocol/server': '2.0.0',
231232
},
@@ -242,7 +243,7 @@ async function runWorkflowContractTests(): Promise<void> {
242243
{ path: 'npm-shrinkwrap.json' },
243244
],
244245
},
245-
{ name: 'mcp-searxng', dependencies: {} },
246+
{ name: 'mcp-searxng', exports: {}, dependencies: {} },
246247
),
247248
/artifact_metadata:.*shrinkwrap/,
248249
);
@@ -254,6 +255,7 @@ async function runWorkflowContractTests(): Promise<void> {
254255
},
255256
{
256257
name: 'mcp-searxng',
258+
exports: {},
257259
dependencies: { '@modelcontextprotocol/sdk': '1.30.0' },
258260
},
259261
),
@@ -262,7 +264,7 @@ async function runWorkflowContractTests(): Promise<void> {
262264
assert.throws(
263265
() => assertArtifactMetadata(
264266
{ filename: 'mcp-searxng-1.12.0.tgz', files: [null] },
265-
{ name: 'mcp-searxng', dependencies: {} },
267+
{ name: 'mcp-searxng', exports: {}, dependencies: {} },
266268
),
267269
/artifact_metadata:.*file entry/,
268270
);
@@ -272,10 +274,38 @@ async function runWorkflowContractTests(): Promise<void> {
272274
filename: 'mcp-searxng-1.12.0.tgz',
273275
files: [{ path: 'package.json' }, { path: 'dist/cli.js' }],
274276
},
275-
{ name: 'mcp-searxng', dependencies: {} },
277+
{ name: 'mcp-searxng', exports: {}, dependencies: {} },
276278
),
277279
/artifact_metadata:.*pdf-worker\.js/,
278280
);
281+
assert.throws(
282+
() => assertArtifactMetadata(
283+
{
284+
filename: 'mcp-searxng-1.12.0.tgz',
285+
files: [{ path: 'package.json' }, { path: 'dist/pdf-worker.js' }],
286+
},
287+
{
288+
name: 'mcp-searxng',
289+
main: 'dist/index.js',
290+
exports: {},
291+
dependencies: {},
292+
},
293+
),
294+
/artifact_metadata:.*main entrypoint/,
295+
);
296+
assert.throws(
297+
() => assertArtifactMetadata(
298+
{
299+
filename: 'mcp-searxng-1.12.0.tgz',
300+
files: [{ path: 'package.json' }, { path: 'dist/pdf-worker.js' }],
301+
},
302+
{
303+
name: 'mcp-searxng',
304+
dependencies: {},
305+
},
306+
),
307+
/artifact_metadata:.*exports/,
308+
);
279309
}, results);
280310

281311
await testFunction('the public npm release workflow enforces the packed-consumer gate', () => {
@@ -339,6 +369,7 @@ async function runOrchestrationTests(): Promise<void> {
339369
path.join(installedPackageDirectory, 'package.json'),
340370
JSON.stringify({
341371
name: 'mcp-searxng',
372+
exports: {},
342373
dependencies: { '@modelcontextprotocol/server': '2.0.0' },
343374
}),
344375
);
@@ -457,6 +488,7 @@ async function runOrchestrationTests(): Promise<void> {
457488
path.join(installedPackageDirectory, 'package.json'),
458489
JSON.stringify({
459490
name: 'mcp-searxng',
491+
exports: {},
460492
dependencies: { '@modelcontextprotocol/server': '2.0.0' },
461493
}),
462494
);

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcp-searxng",
3-
"version": "1.16.0",
3+
"version": "2.0.0",
44
"mcpName": "io.github.ihor-sokoliuk/mcp-searxng",
55
"description": "MCP server for SearXNG integration",
66
"license": "MIT",
@@ -27,7 +27,7 @@
2727
"bin": {
2828
"mcp-searxng": "dist/cli.js"
2929
},
30-
"main": "dist/index.js",
30+
"exports": {},
3131
"files": [
3232
"dist"
3333
],

scripts/packed-consumer-contracts.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ function assertInstalledPackageSafe(installedPackage) {
150150
if (!installedPackage || typeof installedPackage !== 'object') {
151151
fail('artifact_metadata', 'installed package manifest is missing');
152152
}
153+
if (installedPackage.main !== undefined) {
154+
fail('artifact_metadata', 'programmatic main entrypoint is forbidden');
155+
}
156+
if (
157+
!installedPackage.exports
158+
|| typeof installedPackage.exports !== 'object'
159+
|| Array.isArray(installedPackage.exports)
160+
|| Object.keys(installedPackage.exports).length !== 0
161+
) {
162+
fail('artifact_metadata', 'package exports must block programmatic imports');
163+
}
153164
if (installedPackage.dependencies?.['@modelcontextprotocol/sdk'] !== undefined) {
154165
fail('artifact_metadata', 'legacy monolithic SDK dependency is forbidden');
155166
}

scripts/verify-packed-consumer.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ function packedPdfSmokeScript() {
154154
return [
155155
"import path from 'node:path';",
156156
"import { pathToFileURL } from 'node:url';",
157+
"for (const target of ['mcp-searxng', 'mcp-searxng/dist/index.js']) {",
158+
" let blocked = false;",
159+
" try { await import(target); } catch (error) {",
160+
" if (error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error;",
161+
" blocked = true;",
162+
" }",
163+
" if (!blocked) process.exit(1);",
164+
"}",
157165
"const moduleUrl = pathToFileURL(path.resolve('node_modules/mcp-searxng/dist/pdf-reader.js')).href;",
158166
"const { extractPdfText } = await import(moduleUrl);",
159167
`const bytes = new Uint8Array(Buffer.from('${fixture}', 'base64'));`,

0 commit comments

Comments
 (0)