@iris-eval/mcp-server 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -1
- package/dist/audit-log-reader.js +3 -3
- package/dist/config/defaults.d.ts +1 -0
- package/dist/config/defaults.js +6 -3
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +14 -5
- package/dist/custom-rule-store.js +190 -35
- package/dist/dashboard/assets/index-ChcHJDDJ.js +10 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/routes/rules.js +1 -1
- package/dist/dashboard/server.js +60 -5
- package/dist/dashboard/validation.d.ts +36 -62
- package/dist/eval/citation-verify/resolve.js +101 -15
- package/dist/eval/engine.js +25 -10
- package/dist/eval/rules/config-keys.d.ts +14 -0
- package/dist/eval/rules/config-keys.js +43 -0
- package/dist/eval/rules/custom.js +43 -14
- package/dist/eval/rules/regex-budget.d.ts +5 -0
- package/dist/eval/rules/regex-budget.js +0 -0
- package/dist/eval/rules/relevance.d.ts +1 -0
- package/dist/eval/rules/relevance.js +3 -1
- package/dist/eval/rules/safety.d.ts +5 -0
- package/dist/eval/rules/safety.js +43 -5
- package/dist/index.js +13 -2
- package/dist/middleware/error-handler.js +19 -1
- package/dist/middleware/rebinding-guard.d.ts +21 -0
- package/dist/middleware/rebinding-guard.js +77 -0
- package/dist/otel/mapper.js +2 -1
- package/dist/preferences.d.ts +43 -93
- package/dist/preferences.js +5 -10
- package/dist/storage/migrations/005-normalize-created-at.d.ts +3 -0
- package/dist/storage/migrations/005-normalize-created-at.js +34 -0
- package/dist/storage/migrations/index.js +8 -1
- package/dist/storage/sqlite-adapter.js +28 -4
- package/dist/tools/deploy-rule.js +2 -2
- package/dist/tools/evaluate-output.js +1 -1
- package/dist/tools/log-trace.js +3 -3
- package/dist/transport/http.js +68 -4
- package/dist/types/config.d.ts +7 -0
- package/dist/utils/iris-home.d.ts +1 -0
- package/dist/utils/iris-home.js +21 -0
- package/dist/utils/write-atomic.d.ts +1 -0
- package/dist/utils/write-atomic.js +64 -0
- package/package.json +10 -5
- package/server.json +2 -2
- package/dist/dashboard/assets/index-DNflCqmJ.js +0 -12
package/dist/transport/http.js
CHANGED
|
@@ -23,7 +23,74 @@ export async function createHttpTransport(mcpServer, config, logger) {
|
|
|
23
23
|
});
|
|
24
24
|
// Authentication
|
|
25
25
|
app.use(createAuthMiddleware(config));
|
|
26
|
-
|
|
26
|
+
/*
|
|
27
|
+
* DNS-rebinding protection (MCP spec: servers MUST validate Origin on
|
|
28
|
+
* HTTP transports; when local, SHOULD bind loopback).
|
|
29
|
+
*
|
|
30
|
+
* iris bound loopback but validated nothing, and `security.apiKey` is
|
|
31
|
+
* undefined by default — so `createAuthMiddleware` is a pass-through. A
|
|
32
|
+
* default `--transport http` server was therefore reachable from any web
|
|
33
|
+
* page the operator visited: the page resolves an attacker-controlled
|
|
34
|
+
* hostname to 127.0.0.1, the browser treats it as same-origin, and the
|
|
35
|
+
* request carries no credentials to be missing. That exposes traces and
|
|
36
|
+
* eval history and allows rule deployment.
|
|
37
|
+
*
|
|
38
|
+
* Origin validation is the fix, and it is safe to switch on by default
|
|
39
|
+
* because the SDK only rejects when an Origin header is PRESENT (see
|
|
40
|
+
* validateRequestHeaders). Real MCP clients — Claude Desktop, Cursor, the
|
|
41
|
+
* CLI — send none, so they are unaffected; browsers always do.
|
|
42
|
+
*
|
|
43
|
+
* Host validation is applied only when bound to loopback. Binding
|
|
44
|
+
* elsewhere is a deliberate network deployment that usually sits behind a
|
|
45
|
+
* proxy rewriting Host, and an exact-match list would break it — the case
|
|
46
|
+
* where the operator has already taken ownership of the boundary.
|
|
47
|
+
*/
|
|
48
|
+
const isLoopbackBind = config.transport.host === '127.0.0.1' ||
|
|
49
|
+
config.transport.host === 'localhost' ||
|
|
50
|
+
config.transport.host === '::1';
|
|
51
|
+
/*
|
|
52
|
+
* Bind FIRST, then build the allowlists from the port actually bound.
|
|
53
|
+
* `config.transport.port` is 0 when the caller wants an ephemeral port
|
|
54
|
+
* (tests and embedders do this), and the OS then picks something else —
|
|
55
|
+
* so allowlists derived from the configured value would contain
|
|
56
|
+
* `127.0.0.1:0` and reject every real request with a 403 that looks
|
|
57
|
+
* exactly like an attack. Routes are registered immediately after, and
|
|
58
|
+
* the port is not discoverable by any client until this function returns.
|
|
59
|
+
*/
|
|
60
|
+
const httpServer = await new Promise((resolve) => {
|
|
61
|
+
const server = app.listen(config.transport.port, config.transport.host, () => resolve(server));
|
|
62
|
+
});
|
|
63
|
+
const address = httpServer.address();
|
|
64
|
+
const port = typeof address === 'object' && address ? address.port : config.transport.port;
|
|
65
|
+
const loopbackOrigins = [
|
|
66
|
+
`http://127.0.0.1:${port}`,
|
|
67
|
+
`http://localhost:${port}`,
|
|
68
|
+
`http://[::1]:${port}`,
|
|
69
|
+
];
|
|
70
|
+
/*
|
|
71
|
+
* The SDK matches origins EXACTLY (`allowedOrigins.includes(origin)`),
|
|
72
|
+
* while iris's own CORS allowlist accepts glob patterns like the shipped
|
|
73
|
+
* default `http://localhost:*`. A pattern entry can never match here, so
|
|
74
|
+
* it is dropped rather than passed through to sit in the list looking
|
|
75
|
+
* effective. The concrete loopback origins added above already express
|
|
76
|
+
* what `http://localhost:*` means for this server's port.
|
|
77
|
+
*
|
|
78
|
+
* Note this rejection is what actually stops the attack. Emitting CORS
|
|
79
|
+
* headers would not: the browser only withholds the RESPONSE, after the
|
|
80
|
+
* server has already executed the request — so a rebound page could still
|
|
81
|
+
* deploy rules or delete traces and simply not read the reply.
|
|
82
|
+
*/
|
|
83
|
+
const configuredOrigins = (config.security.allowedOrigins ?? []).filter((origin) => !origin.includes('*'));
|
|
84
|
+
const allowedOrigins = [...new Set([...loopbackOrigins, ...configuredOrigins])];
|
|
85
|
+
const allowedHosts = isLoopbackBind
|
|
86
|
+
? [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]
|
|
87
|
+
: undefined;
|
|
88
|
+
const transport = new StreamableHTTPServerTransport({
|
|
89
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
90
|
+
enableDnsRebindingProtection: true,
|
|
91
|
+
allowedOrigins,
|
|
92
|
+
...(allowedHosts ? { allowedHosts } : {}),
|
|
93
|
+
});
|
|
27
94
|
// Rate limiter for MCP POST/DELETE (not GET — SSE streaming)
|
|
28
95
|
const mcpLimiter = createMcpRateLimiter(config);
|
|
29
96
|
app.post('/mcp', mcpLimiter, async (req, res) => {
|
|
@@ -37,8 +104,5 @@ export async function createHttpTransport(mcpServer, config, logger) {
|
|
|
37
104
|
});
|
|
38
105
|
// Error handler (must be last)
|
|
39
106
|
app.use(createErrorHandler(logger));
|
|
40
|
-
const httpServer = await new Promise((resolve) => {
|
|
41
|
-
const server = app.listen(config.transport.port, config.transport.host, () => resolve(server));
|
|
42
|
-
});
|
|
43
107
|
return { transport, httpServer };
|
|
44
108
|
}
|
package/dist/types/config.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export interface IrisConfig {
|
|
|
15
15
|
dashboard: {
|
|
16
16
|
enabled: boolean;
|
|
17
17
|
port: number;
|
|
18
|
+
/**
|
|
19
|
+
* Bind address. Defaults to loopback: the dashboard is unauthenticated
|
|
20
|
+
* by default (security.apiKey is undefined) and serves the full trace
|
|
21
|
+
* history, so binding it to every interface exposes agent inputs and
|
|
22
|
+
* outputs to the local network. Set explicitly to share it.
|
|
23
|
+
*/
|
|
24
|
+
host: string;
|
|
18
25
|
};
|
|
19
26
|
eval: {
|
|
20
27
|
defaultThreshold: number;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function irisHome(): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
/*
|
|
4
|
+
* Single resolver for the iris home directory (default: ~/.iris).
|
|
5
|
+
*
|
|
6
|
+
* Every per-user file iris touches lives under this directory — the
|
|
7
|
+
* SQLite DB default, config.json, custom-rules.json, audit.log,
|
|
8
|
+
* preferences.json. Before this helper each module joined
|
|
9
|
+
* homedir() + '.iris' itself, which meant there was no way to point a
|
|
10
|
+
* spawned server at a scratch directory: the E2E suite isolated the DB
|
|
11
|
+
* via IRIS_DB_PATH but still wiped the real audit.log, deployed test
|
|
12
|
+
* rules into the real custom-rules.json, and overwrote the real
|
|
13
|
+
* preferences.json on every run.
|
|
14
|
+
*
|
|
15
|
+
* IRIS_HOME redirects all of them at once. Read at call time — not
|
|
16
|
+
* module load — so a test harness that sets the env var before
|
|
17
|
+
* spawning (or between in-process calls) always wins.
|
|
18
|
+
*/
|
|
19
|
+
export function irisHome() {
|
|
20
|
+
return process.env.IRIS_HOME ?? join(homedir(), '.iris');
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeAtomic(targetPath: string, contents: string): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
/*
|
|
5
|
+
* Atomic file write: write a temp file, then rename it over the target.
|
|
6
|
+
*
|
|
7
|
+
* This lives in one place because it was duplicated verbatim in
|
|
8
|
+
* preferences.ts and custom-rule-store.ts, and both copies carried the same
|
|
9
|
+
* two Windows bugs.
|
|
10
|
+
*
|
|
11
|
+
* 1. The temp path was `${targetPath}.tmp.${process.pid}` — keyed on the
|
|
12
|
+
* PROCESS, not the call. Two concurrent writes to the same target inside
|
|
13
|
+
* one process (which is exactly what a vitest file does) therefore raced
|
|
14
|
+
* on a single temp path: one call renamed it away while the other was
|
|
15
|
+
* still writing, and the loser got
|
|
16
|
+
* EPERM: operation not permitted, rename '...preferences.json.tmp.38468'
|
|
17
|
+
* Observed twice in one session, on different suites. A random suffix
|
|
18
|
+
* makes each call's temp file its own.
|
|
19
|
+
*
|
|
20
|
+
* 2. Even with unique names, Windows can briefly deny a rename while a
|
|
21
|
+
* virus scanner or indexer holds the file. POSIX rename() has no such
|
|
22
|
+
* behaviour, so this never reproduces on CI. A few short retries turn a
|
|
23
|
+
* transient lock into a small delay instead of a lost write.
|
|
24
|
+
*
|
|
25
|
+
* The retry is deliberately narrow: only the error codes Windows raises for
|
|
26
|
+
* transient sharing violations. Anything else (ENOSPC, EROFS, a bad path)
|
|
27
|
+
* still throws immediately rather than being retried into a slow failure.
|
|
28
|
+
*/
|
|
29
|
+
const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
30
|
+
const MAX_ATTEMPTS = 5;
|
|
31
|
+
function sleepSync(ms) {
|
|
32
|
+
// Synchronous by necessity — writeAtomic is sync, and making it async
|
|
33
|
+
// would ripple through every caller for a Windows-only edge case.
|
|
34
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
35
|
+
}
|
|
36
|
+
export function writeAtomic(targetPath, contents) {
|
|
37
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
38
|
+
const tmp = `${targetPath}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
|
|
39
|
+
writeFileSync(tmp, contents, 'utf-8');
|
|
40
|
+
let lastError;
|
|
41
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
42
|
+
try {
|
|
43
|
+
renameSync(tmp, targetPath);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
lastError = err;
|
|
48
|
+
const code = err?.code;
|
|
49
|
+
if (!code || !TRANSIENT_RENAME_ERRORS.has(code))
|
|
50
|
+
break;
|
|
51
|
+
sleepSync(10 * (attempt + 1));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Don't leave the temp file behind on a genuine failure — a stray
|
|
55
|
+
// `preferences.json.tmp.1234.ab12cd` next to the real file is confusing
|
|
56
|
+
// and never cleaned up otherwise.
|
|
57
|
+
try {
|
|
58
|
+
unlinkSync(tmp);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Best effort; the original error is the one worth reporting.
|
|
62
|
+
}
|
|
63
|
+
throw lastError;
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iris-eval/mcp-server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "The agent eval standard for MCP. Score every agent output for quality, safety, and cost.",
|
|
5
5
|
"mcpName": "io.github.iris-eval/mcp-server",
|
|
6
6
|
"type": "module",
|
|
@@ -76,27 +76,32 @@
|
|
|
76
76
|
"node": ">=20.0.0"
|
|
77
77
|
},
|
|
78
78
|
"overrides": {
|
|
79
|
-
"
|
|
79
|
+
"brace-expansion": "^5.0.7",
|
|
80
|
+
"fast-uri": "^3.1.5",
|
|
81
|
+
"ip-address": "^10.4.0",
|
|
82
|
+
"postcss": "^8.5.25",
|
|
83
|
+
"@hono/node-server": "^2.1.0"
|
|
80
84
|
},
|
|
81
85
|
"dependencies": {
|
|
82
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
86
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
83
87
|
"better-sqlite3": "^12.8.0",
|
|
84
88
|
"express": "^5.1.0",
|
|
85
89
|
"express-rate-limit": "^8.3.2",
|
|
86
90
|
"helmet": "^8.1.0",
|
|
87
91
|
"pino": "^10.3.1",
|
|
88
92
|
"safe-regex2": "^5.1.0",
|
|
89
|
-
"zod": "^
|
|
93
|
+
"zod": "^4.4.3"
|
|
90
94
|
},
|
|
91
95
|
"devDependencies": {
|
|
92
96
|
"@playwright/test": "^1.59.1",
|
|
93
97
|
"@types/better-sqlite3": "^7.6.0",
|
|
94
98
|
"@types/express": "^5.0.0",
|
|
95
|
-
"@types/node": "^
|
|
99
|
+
"@types/node": "^26.1.1",
|
|
96
100
|
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
|
97
101
|
"@typescript-eslint/parser": "^8.58.0",
|
|
98
102
|
"@vitest/coverage-v8": "^4.1.1",
|
|
99
103
|
"eslint": "^10.2.0",
|
|
104
|
+
"fast-check": "^4.8.0",
|
|
100
105
|
"prettier": "^3.0.0",
|
|
101
106
|
"tsx": "^4.0.0",
|
|
102
107
|
"typescript": "^5.7.0",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/iris-eval/mcp-server",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.4.
|
|
9
|
+
"version": "0.4.6",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@iris-eval/mcp-server",
|
|
14
|
-
"version": "0.4.
|
|
14
|
+
"version": "0.4.6",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|