@launchdarkly/mcp-server 0.6.2 → 0.7.0
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 +17 -13
- package/bin/mcp-server.js +905 -168
- package/bin/mcp-server.js.map +26 -22
- package/dist/commonjs/lib/config.d.ts +2 -2
- package/dist/commonjs/lib/config.js +2 -2
- package/dist/commonjs/mcp-server/cli/start/command.d.ts.map +1 -1
- package/dist/commonjs/mcp-server/cli/start/command.js +12 -0
- package/dist/commonjs/mcp-server/cli/start/command.js.map +1 -1
- package/dist/commonjs/mcp-server/cli/start/impl.d.ts +2 -0
- package/dist/commonjs/mcp-server/cli/start/impl.d.ts.map +1 -1
- package/dist/commonjs/mcp-server/cli/start/impl.js +60 -20
- package/dist/commonjs/mcp-server/cli/start/impl.js.map +1 -1
- package/dist/commonjs/mcp-server/cli/start/sse-security.d.ts +5 -0
- package/dist/commonjs/mcp-server/cli/start/sse-security.d.ts.map +1 -0
- package/dist/commonjs/mcp-server/cli/start/sse-security.js +26 -0
- package/dist/commonjs/mcp-server/cli/start/sse-security.js.map +1 -0
- package/dist/commonjs/mcp-server/mcp-server.js +1 -1
- package/dist/commonjs/mcp-server/server.js +1 -1
- package/dist/esm/lib/config.d.ts +2 -2
- package/dist/esm/lib/config.js +2 -2
- package/dist/esm/mcp-server/cli/start/command.d.ts.map +1 -1
- package/dist/esm/mcp-server/cli/start/command.js +12 -0
- package/dist/esm/mcp-server/cli/start/command.js.map +1 -1
- package/dist/esm/mcp-server/cli/start/impl.d.ts +2 -0
- package/dist/esm/mcp-server/cli/start/impl.d.ts.map +1 -1
- package/dist/esm/mcp-server/cli/start/impl.js +60 -20
- package/dist/esm/mcp-server/cli/start/impl.js.map +1 -1
- package/dist/esm/mcp-server/cli/start/sse-security.d.ts +5 -0
- package/dist/esm/mcp-server/cli/start/sse-security.d.ts.map +1 -0
- package/dist/esm/mcp-server/cli/start/sse-security.js +21 -0
- package/dist/esm/mcp-server/cli/start/sse-security.js.map +1 -0
- package/dist/esm/mcp-server/mcp-server.js +1 -1
- package/dist/esm/mcp-server/server.js +1 -1
- package/jsr.json +1 -1
- package/package.json +2 -1
- package/src/lib/config.ts +2 -2
- package/src/mcp-server/cli/start/command.ts +14 -0
- package/src/mcp-server/cli/start/impl.ts +69 -20
- package/src/mcp-server/cli/start/sse-security.ts +31 -0
- package/src/mcp-server/mcp-server.ts +1 -1
- package/src/mcp-server/server.ts +1 -1
- package/test/sse-security.test.mjs +84 -0
|
@@ -27,6 +27,20 @@ export const startCommand = buildCommand({
|
|
|
27
27
|
parse: (val: string) =>
|
|
28
28
|
z.coerce.number().int().gte(0).lt(65536).parse(val),
|
|
29
29
|
},
|
|
30
|
+
host: {
|
|
31
|
+
kind: "parsed",
|
|
32
|
+
brief:
|
|
33
|
+
"The host to listen on when the SSE transport is enabled. Non-loopback hosts require --auth-token",
|
|
34
|
+
default: "127.0.0.1",
|
|
35
|
+
parse: (value) => z.string().nonempty().parse(value),
|
|
36
|
+
},
|
|
37
|
+
"auth-token": {
|
|
38
|
+
kind: "parsed",
|
|
39
|
+
brief:
|
|
40
|
+
"Bearer token that SSE clients must send in the Authorization header",
|
|
41
|
+
optional: true,
|
|
42
|
+
parse: (value) => z.string().min(16).parse(value),
|
|
43
|
+
},
|
|
30
44
|
tool: {
|
|
31
45
|
kind: "parsed",
|
|
32
46
|
brief: "Specify tools to mount on the server",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { localhostHostValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js";
|
|
7
8
|
import express from "express";
|
|
8
9
|
import { SDKOptions } from "../../../lib/config.js";
|
|
9
10
|
import { LocalContext } from "../../cli.js";
|
|
@@ -13,10 +14,17 @@ import {
|
|
|
13
14
|
} from "../../console-logger.js";
|
|
14
15
|
import { MCPScope } from "../../scopes.js";
|
|
15
16
|
import { createMCPServer } from "../../server.js";
|
|
17
|
+
import {
|
|
18
|
+
assertSafeSSEBind,
|
|
19
|
+
isLoopbackHost,
|
|
20
|
+
requireBearerToken,
|
|
21
|
+
} from "./sse-security.js";
|
|
16
22
|
|
|
17
23
|
interface StartCommandFlags {
|
|
18
24
|
readonly transport: "stdio" | "sse";
|
|
19
25
|
readonly port: number;
|
|
26
|
+
readonly host: string;
|
|
27
|
+
readonly "auth-token"?: string | undefined;
|
|
20
28
|
readonly tool?: string[];
|
|
21
29
|
readonly scope?: MCPScope[];
|
|
22
30
|
readonly "api-key"?: string | undefined;
|
|
@@ -66,37 +74,78 @@ async function startStdio(flags: StartCommandFlags) {
|
|
|
66
74
|
|
|
67
75
|
async function startSSE(flags: StartCommandFlags) {
|
|
68
76
|
const logger = createConsoleLogger(flags["log-level"]);
|
|
77
|
+
const host = flags.host;
|
|
78
|
+
const authToken = flags["auth-token"];
|
|
79
|
+
assertSafeSSEBind(host, authToken);
|
|
80
|
+
|
|
69
81
|
const app = express();
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
82
|
+
if (isLoopbackHost(host)) {
|
|
83
|
+
app.use(localhostHostValidation());
|
|
84
|
+
}
|
|
85
|
+
if (authToken) {
|
|
86
|
+
app.use(requireBearerToken(authToken));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sessions = new Map<
|
|
90
|
+
string,
|
|
91
|
+
{ transport: SSEServerTransport; server: ReturnType<typeof createMCPServer> }
|
|
92
|
+
>();
|
|
79
93
|
const controller = new AbortController();
|
|
80
94
|
|
|
81
95
|
app.get("/sse", async (_req, res) => {
|
|
82
|
-
|
|
96
|
+
const server = createMCPServer({
|
|
97
|
+
logger,
|
|
98
|
+
allowedTools: flags.tool,
|
|
99
|
+
scopes: flags.scope,
|
|
100
|
+
...{ apiKey: flags["api-key"] ?? "" },
|
|
101
|
+
serverURL: flags["server-url"],
|
|
102
|
+
serverIdx: flags["server-index"],
|
|
103
|
+
});
|
|
104
|
+
const transport = new SSEServerTransport("/message", res);
|
|
105
|
+
sessions.set(transport.sessionId, { transport, server });
|
|
83
106
|
|
|
84
|
-
|
|
107
|
+
res.on("close", () => {
|
|
108
|
+
sessions.delete(transport.sessionId);
|
|
109
|
+
void server.close();
|
|
110
|
+
});
|
|
85
111
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
112
|
+
try {
|
|
113
|
+
await server.connect(transport);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
logger.error("Failed to open SSE session", {
|
|
116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
117
|
+
});
|
|
118
|
+
sessions.delete(transport.sessionId);
|
|
119
|
+
void server.close();
|
|
120
|
+
if (!res.headersSent) {
|
|
121
|
+
res.status(500).send("Internal Server Error");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
89
124
|
});
|
|
90
125
|
|
|
91
126
|
app.post("/message", async (req, res) => {
|
|
92
|
-
|
|
93
|
-
|
|
127
|
+
const sessionId = typeof req.query["sessionId"] === "string"
|
|
128
|
+
? req.query["sessionId"]
|
|
129
|
+
: "";
|
|
130
|
+
const session = sessions.get(sessionId);
|
|
131
|
+
if (!session) {
|
|
132
|
+
res.status(404).send("Unknown session");
|
|
133
|
+
return;
|
|
94
134
|
}
|
|
95
135
|
|
|
96
|
-
|
|
136
|
+
try {
|
|
137
|
+
await session.transport.handlePostMessage(req, res);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logger.error("Failed to handle SSE message", {
|
|
140
|
+
error: err instanceof Error ? err.message : String(err),
|
|
141
|
+
});
|
|
142
|
+
if (!res.headersSent) {
|
|
143
|
+
res.status(500).send("Internal Server Error");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
97
146
|
});
|
|
98
147
|
|
|
99
|
-
const httpServer = app.listen(flags.port,
|
|
148
|
+
const httpServer = app.listen(flags.port, host, () => {
|
|
100
149
|
const ha = httpServer.address();
|
|
101
150
|
const host = typeof ha === "string" ? ha : `${ha?.address}:${ha?.port}`;
|
|
102
151
|
logger.info("MCP HTTP server started", { host });
|
|
@@ -110,9 +159,9 @@ async function startSSE(flags: StartCommandFlags) {
|
|
|
110
159
|
}
|
|
111
160
|
closing = true;
|
|
112
161
|
|
|
113
|
-
logger.info("Shutting down MCP
|
|
162
|
+
logger.info("Shutting down MCP servers");
|
|
114
163
|
|
|
115
|
-
await
|
|
164
|
+
await Promise.all([...sessions.values()].map(({ server }) => server.close()));
|
|
116
165
|
|
|
117
166
|
logger.info("Shutting down HTTP server");
|
|
118
167
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { RequestHandler } from "express";
|
|
2
|
+
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export function isLoopbackHost(host: string): boolean {
|
|
5
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function assertSafeSSEBind(
|
|
9
|
+
host: string,
|
|
10
|
+
authToken: string | undefined,
|
|
11
|
+
): void {
|
|
12
|
+
if (!authToken && !isLoopbackHost(host)) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`--auth-token is required when the SSE transport listens on a non-loopback host (${host})`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function requireBearerToken(token: string): RequestHandler {
|
|
20
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
21
|
+
return (req, res, next) => {
|
|
22
|
+
const actual = Buffer.from(req.headers.authorization ?? "");
|
|
23
|
+
if (
|
|
24
|
+
actual.length !== expected.length || !timingSafeEqual(actual, expected)
|
|
25
|
+
) {
|
|
26
|
+
res.status(401).send("Unauthorized");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
next();
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/mcp-server/server.ts
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { after, before, describe, test } from "node:test";
|
|
4
|
+
import express from "express";
|
|
5
|
+
import { main } from "../dist/esm/mcp-server/cli/start/impl.js";
|
|
6
|
+
import {
|
|
7
|
+
assertSafeSSEBind,
|
|
8
|
+
requireBearerToken,
|
|
9
|
+
} from "../dist/esm/mcp-server/cli/start/sse-security.js";
|
|
10
|
+
|
|
11
|
+
const TOKEN = "0123456789abcdef";
|
|
12
|
+
|
|
13
|
+
describe("assertSafeSSEBind", () => {
|
|
14
|
+
for (const host of ["0.0.0.0", "::", "192.168.1.10", "example.com"]) {
|
|
15
|
+
test(`refuses ${host} without a token`, () => {
|
|
16
|
+
assert.throws(
|
|
17
|
+
() => assertSafeSSEBind(host, undefined),
|
|
18
|
+
/--auth-token is required/,
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test(`allows ${host} with a token`, () => {
|
|
23
|
+
assert.doesNotThrow(() => assertSafeSSEBind(host, TOKEN));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const host of ["127.0.0.1", "localhost", "::1"]) {
|
|
28
|
+
test(`allows loopback ${host} without a token`, () => {
|
|
29
|
+
assert.doesNotThrow(() => assertSafeSSEBind(host, undefined));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("start --transport sse refuses a non-loopback host without a token", async () => {
|
|
35
|
+
await assert.rejects(
|
|
36
|
+
main.call({ process }, {
|
|
37
|
+
transport: "sse",
|
|
38
|
+
host: "0.0.0.0",
|
|
39
|
+
port: 0,
|
|
40
|
+
"log-level": "error",
|
|
41
|
+
}),
|
|
42
|
+
/--auth-token is required/,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("requireBearerToken", () => {
|
|
47
|
+
let server;
|
|
48
|
+
let url;
|
|
49
|
+
|
|
50
|
+
before(async () => {
|
|
51
|
+
const app = express();
|
|
52
|
+
app.use(requireBearerToken(TOKEN));
|
|
53
|
+
app.get("/", (_req, res) => res.send("ok"));
|
|
54
|
+
await new Promise((resolve) => {
|
|
55
|
+
server = app.listen(0, "127.0.0.1", resolve);
|
|
56
|
+
});
|
|
57
|
+
url = `http://127.0.0.1:${server.address().port}/`;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
after(() => new Promise((resolve) => server.close(resolve)));
|
|
61
|
+
|
|
62
|
+
const cases = [
|
|
63
|
+
["a missing header", undefined],
|
|
64
|
+
["a wrong token of the same length", "Bearer fedcba9876543210"],
|
|
65
|
+
["a token prefix", `Bearer ${TOKEN.slice(0, -1)}`],
|
|
66
|
+
["the token without the Bearer scheme", TOKEN],
|
|
67
|
+
];
|
|
68
|
+
for (const [name, header] of cases) {
|
|
69
|
+
test(`returns 401 for ${name}`, async () => {
|
|
70
|
+
const res = await fetch(url, {
|
|
71
|
+
headers: header === undefined ? {} : { Authorization: header },
|
|
72
|
+
});
|
|
73
|
+
assert.equal(res.status, 401);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("passes the request through for the right token", async () => {
|
|
78
|
+
const res = await fetch(url, {
|
|
79
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
80
|
+
});
|
|
81
|
+
assert.equal(res.status, 200);
|
|
82
|
+
assert.equal(await res.text(), "ok");
|
|
83
|
+
});
|
|
84
|
+
});
|