@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.
Files changed (42) hide show
  1. package/README.md +17 -13
  2. package/bin/mcp-server.js +905 -168
  3. package/bin/mcp-server.js.map +26 -22
  4. package/dist/commonjs/lib/config.d.ts +2 -2
  5. package/dist/commonjs/lib/config.js +2 -2
  6. package/dist/commonjs/mcp-server/cli/start/command.d.ts.map +1 -1
  7. package/dist/commonjs/mcp-server/cli/start/command.js +12 -0
  8. package/dist/commonjs/mcp-server/cli/start/command.js.map +1 -1
  9. package/dist/commonjs/mcp-server/cli/start/impl.d.ts +2 -0
  10. package/dist/commonjs/mcp-server/cli/start/impl.d.ts.map +1 -1
  11. package/dist/commonjs/mcp-server/cli/start/impl.js +60 -20
  12. package/dist/commonjs/mcp-server/cli/start/impl.js.map +1 -1
  13. package/dist/commonjs/mcp-server/cli/start/sse-security.d.ts +5 -0
  14. package/dist/commonjs/mcp-server/cli/start/sse-security.d.ts.map +1 -0
  15. package/dist/commonjs/mcp-server/cli/start/sse-security.js +26 -0
  16. package/dist/commonjs/mcp-server/cli/start/sse-security.js.map +1 -0
  17. package/dist/commonjs/mcp-server/mcp-server.js +1 -1
  18. package/dist/commonjs/mcp-server/server.js +1 -1
  19. package/dist/esm/lib/config.d.ts +2 -2
  20. package/dist/esm/lib/config.js +2 -2
  21. package/dist/esm/mcp-server/cli/start/command.d.ts.map +1 -1
  22. package/dist/esm/mcp-server/cli/start/command.js +12 -0
  23. package/dist/esm/mcp-server/cli/start/command.js.map +1 -1
  24. package/dist/esm/mcp-server/cli/start/impl.d.ts +2 -0
  25. package/dist/esm/mcp-server/cli/start/impl.d.ts.map +1 -1
  26. package/dist/esm/mcp-server/cli/start/impl.js +60 -20
  27. package/dist/esm/mcp-server/cli/start/impl.js.map +1 -1
  28. package/dist/esm/mcp-server/cli/start/sse-security.d.ts +5 -0
  29. package/dist/esm/mcp-server/cli/start/sse-security.d.ts.map +1 -0
  30. package/dist/esm/mcp-server/cli/start/sse-security.js +21 -0
  31. package/dist/esm/mcp-server/cli/start/sse-security.js.map +1 -0
  32. package/dist/esm/mcp-server/mcp-server.js +1 -1
  33. package/dist/esm/mcp-server/server.js +1 -1
  34. package/jsr.json +1 -1
  35. package/package.json +2 -1
  36. package/src/lib/config.ts +2 -2
  37. package/src/mcp-server/cli/start/command.ts +14 -0
  38. package/src/mcp-server/cli/start/impl.ts +69 -20
  39. package/src/mcp-server/cli/start/sse-security.ts +31 -0
  40. package/src/mcp-server/mcp-server.ts +1 -1
  41. package/src/mcp-server/server.ts +1 -1
  42. 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
- const mcpServer = createMCPServer({
71
- logger,
72
- allowedTools: flags.tool,
73
- scopes: flags.scope,
74
- ...{ apiKey: flags["api-key"] ?? "" },
75
- serverURL: flags["server-url"],
76
- serverIdx: flags["server-index"],
77
- });
78
- let transport: SSEServerTransport | undefined;
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
- transport = new SSEServerTransport("/message", res);
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
- await mcpServer.connect(transport);
107
+ res.on("close", () => {
108
+ sessions.delete(transport.sessionId);
109
+ void server.close();
110
+ });
85
111
 
86
- mcpServer.server.onclose = async () => {
87
- res.end();
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
- if (!transport) {
93
- throw new Error("Server transport not initialized");
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
- await transport.handlePostMessage(req, res);
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, "0.0.0.0", () => {
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 server");
162
+ logger.info("Shutting down MCP servers");
114
163
 
115
- await mcpServer.close();
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
+ }
@@ -19,7 +19,7 @@ const routes = buildRouteMap({
19
19
  export const app = buildApplication(routes, {
20
20
  name: "mcp",
21
21
  versionInfo: {
22
- currentVersion: "0.6.2",
22
+ currentVersion: "0.7.0",
23
23
  },
24
24
  });
25
25
 
@@ -44,7 +44,7 @@ export function createMCPServer(deps: {
44
44
  }) {
45
45
  const server = new McpServer({
46
46
  name: "LaunchDarkly",
47
- version: "0.6.2",
47
+ version: "0.7.0",
48
48
  });
49
49
 
50
50
  const client = new LaunchDarklyCore({
@@ -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
+ });