@briefgate/mcp 0.7.3 → 0.7.5

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/dist/index.js CHANGED
@@ -10,201 +10,434 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
10
10
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
11
11
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
12
12
  import { createServer } from 'node:http';
13
+ import { hostname } from 'node:os';
14
+ import { AUTH_FAILED_MESSAGE } from './client.js';
13
15
  import { TOOLS, executeTool } from './tools.js';
14
- import { configForRequest, isAllowedHost, isAllowedOrigin } from './http-auth.js';
16
+ import { configForRequest, isAllowedHost, isAllowedOrigin, protectedResourceMetadata, wwwAuthenticateHeader, WELL_KNOWN_PROTECTED_RESOURCE_PATH, WELL_KNOWN_PROTECTED_RESOURCE_MCP_PATH, } from './http-auth.js';
17
+ import { PayloadTooLargeError, readBody } from './http-body.js';
18
+ import { resolveApiKey } from './credentials.js';
19
+ import { loginBlocking, logout as logoutLocal } from './login.js';
15
20
  // ─── Config ───────────────────────────────────────────────────────────────────
16
- const apiKey = process.env['BRIEFGATE_API_KEY'];
21
+ const argv = process.argv.slice(2);
22
+ const apiKeyFlagIndex = argv.indexOf('--api-key');
23
+ const cliApiKey = apiKeyFlagIndex !== -1 ? argv[apiKeyFlagIndex + 1] : undefined;
24
+ const envApiKey = process.env['BRIEFGATE_API_KEY'];
17
25
  const baseUrl = process.env['BRIEFGATE_BASE_URL'] ?? 'https://api.briefgate.dev';
26
+ // The OAuth authorization server advertised in the well-known resource
27
+ // metadata (see protectedResourceMetadata below). Deliberately separate from
28
+ // BRIEFGATE_BASE_URL: in production the MCP container reaches the API over an
29
+ // internal network (BRIEFGATE_BASE_URL=http://api:8585), which the outside
30
+ // world — a browser, claude.ai — could never open. Defaults to BASE_URL only
31
+ // because that's convenient for local dev, where they're usually the same
32
+ // address; a real deployment sets this explicitly (docker-compose.prod.yml
33
+ // sets it to https://api.briefgate.dev).
34
+ const authServer = process.env['BRIEFGATE_MCP_AUTH_SERVER'] ?? baseUrl;
18
35
  // Warn early if BRIEFGATE_BASE_URL looks unsafe — it controls where API calls
19
36
  // (including the Authorization header) are sent, so it must be an https URL in
20
- // production. http:// localhost/127.0.0.1 is allowed for local development.
37
+ // production. http:// is allowed without a warning for hosts that never leave
38
+ // a private network: localhost/127.0.0.1 (local development), a bare hostname
39
+ // with no dot (a Docker/Compose service name like "api" — only resolvable
40
+ // inside that network, e.g. BRIEFGATE_BASE_URL=http://api:8585), and the
41
+ // conventional private-network suffixes *.internal / *.local.
21
42
  if (process.env['BRIEFGATE_BASE_URL']) {
22
- const isLocalhostHttp = /^http:\/\/(?:localhost|127\.0\.0\.1)(:\d+)?(?:\/|$)/.test(baseUrl);
23
- if (!baseUrl.startsWith('https://') && !isLocalhostHttp) {
43
+ const isHttps = baseUrl.startsWith('https://');
44
+ let isSafeHttp = false;
45
+ if (!isHttps) {
46
+ try {
47
+ const urlHostname = new URL(baseUrl).hostname;
48
+ isSafeHttp =
49
+ urlHostname === 'localhost' ||
50
+ urlHostname === '127.0.0.1' ||
51
+ urlHostname === '::1' ||
52
+ !urlHostname.includes('.') ||
53
+ urlHostname.endsWith('.internal') ||
54
+ urlHostname.endsWith('.local');
55
+ }
56
+ catch {
57
+ // Malformed URL — leave isSafeHttp false so the warning fires; the
58
+ // request itself will fail with a clearer error once it's attempted.
59
+ }
60
+ }
61
+ if (!isHttps && !isSafeHttp) {
24
62
  process.stderr.write('Warning: BRIEFGATE_BASE_URL does not use https:// — API keys will be sent over an insecure connection.\n');
25
63
  }
26
64
  }
27
- if (!apiKey) {
28
- // Start anyway so clients and registries (e.g. Glama, mcp.so) can
29
- // introspect the tool list before a key is configured. Tool calls return
30
- // a clear error until BRIEFGATE_API_KEY is set (see the CallTool handler).
31
- process.stderr.write([
32
- 'Warning: BRIEFGATE_API_KEY is not set — tool calls will fail until it is.',
33
- 'Get an API key at https://briefgate.dev and set it in your MCP client config.',
34
- 'Example: BRIEFGATE_API_KEY=bg_live_... npx @briefgate/mcp',
35
- '',
36
- ].join('\n'));
37
- }
38
- const config = {
39
- apiKey: apiKey ?? '',
40
- baseUrl,
41
- };
42
- // Set to the hostname this server is published under (e.g. mcp.briefgate.dev)
43
- // to run it as a public, multi-customer endpoint. Unset — the default and the
44
- // only thing `npx @briefgate/mcp --http` does on a laptop — keeps the server on
45
- // loopback with the operator's own key, exactly as before.
46
- //
47
- // Turning it on changes two things on purpose:
48
- // * the listener binds 0.0.0.0 and the Host guard accepts this name, because
49
- // a server behind a reverse proxy is reached by its public name;
50
- // * the BRIEFGATE_API_KEY fallback is switched OFF. Leaving it on would let
51
- // an anonymous caller spend the operator's key, which is the whole failure
52
- // this mode has to avoid.
53
- const PUBLIC_HOST = process.env['BRIEFGATE_MCP_PUBLIC_HOST'];
54
- // The version handed to clients in the MCP handshake. Read from package.json
55
- // rather than repeated here: hand-maintained it had drifted to 0.1.0 while the
56
- // package shipped 0.3.0, so every client was told the wrong version.
57
- const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
58
- // ─── Server factory ───────────────────────────────────────────────────────────
59
- // Returns a fresh Server instance bound to one caller's config.
65
+ // ─── CLI subcommands: `login` / `logout` ───────────────────────────────────────
60
66
  //
61
- // HTTP mode builds one per request, which is what makes a public deployment
62
- // possible at all: the API key travels with the request rather than the
63
- // process, so two customers hitting the same server never share a credential.
64
- function buildServer(cfg) {
65
- const server = new Server({ name: '@briefgate/mcp', version: PACKAGE_VERSION }, { capabilities: { tools: {} } });
66
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
67
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
68
- const { name, arguments: rawArgs } = request.params;
69
- const args = (rawArgs ?? {});
70
- if (!cfg.apiKey) {
71
- return {
72
- content: [
73
- {
74
- type: 'text',
75
- text: PUBLIC_HOST
76
- ? 'Error: no API key was sent. Connect with an Authorization: Bearer bg_live_... header; get a key at https://briefgate.dev.'
77
- : 'Error: BRIEFGATE_API_KEY is not set. Get a key at https://briefgate.dev and add it to your MCP client config.',
78
- },
79
- ],
80
- isError: true,
81
- };
82
- }
67
+ // `npx @briefgate/mcp login` / `logout` the same device-authorization flow
68
+ // as the `login`/`logout` MCP tools (src/login.ts), for a human running the
69
+ // package directly from a terminal instead of asking an agent to call the
70
+ // tool. Checked and handled before anything else: this is a one-shot command
71
+ // that exits, not a server, so none of PUBLIC_HOST, the API-key warning below,
72
+ // or a transport is relevant to it.
73
+ const cliSubcommand = argv[0] === 'login' || argv[0] === 'logout' ? argv[0] : undefined;
74
+ if (cliSubcommand === 'login') {
75
+ // Same "already configured" gate as the login tool: --api-key/env always
76
+ // override a locally stored key, so running the flow would be pointless.
77
+ const { apiKey: preconfiguredKey, source: preconfiguredSource } = resolveApiKey({
78
+ explicitApiKey: cliApiKey,
79
+ explicitSource: 'flag',
80
+ envApiKey,
81
+ baseUrl,
82
+ allowFileFallback: true,
83
+ });
84
+ if (preconfiguredKey && (preconfiguredSource === 'flag' || preconfiguredSource === 'env')) {
85
+ const via = preconfiguredSource === 'flag' ? '--api-key' : 'BRIEFGATE_API_KEY';
86
+ process.stdout.write(`An API key is already configured via ${via} — login is not needed.\n`);
87
+ }
88
+ else {
89
+ const clientName = `briefgate-mcp CLI on ${hostname()}`;
83
90
  try {
84
- const result = await executeTool(name, cfg, args);
85
- return {
86
- content: [{ type: 'text', text: result.text }],
87
- ...(result.isError ? { isError: true } : {}),
88
- };
91
+ const result = await loginBlocking({ baseUrl, clientName }, ({ userCode, verificationUriComplete }) => {
92
+ process.stdout.write(`Open ${verificationUriComplete} and confirm code ${userCode}.\n`);
93
+ process.stdout.write('Waiting for approval (up to 10 minutes)...\n');
94
+ });
95
+ process.stdout.write(`${result.text}\n`);
96
+ process.exitCode = result.ok ? 0 : 1;
89
97
  }
90
98
  catch (err) {
91
- const message = err instanceof Error ? err.message : String(err);
92
- return {
93
- content: [{ type: 'text', text: message }],
94
- isError: true,
95
- };
99
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
100
+ process.exitCode = 1;
96
101
  }
97
- });
98
- return server;
102
+ }
99
103
  }
100
- // ─── Transport selection ──────────────────────────────────────────────────────
101
- const argv = process.argv.slice(2);
102
- const useHttp = argv.includes('--http') ||
103
- process.env['BRIEFGATE_MCP_HTTP'] === '1';
104
- if (useHttp) {
105
- // ── Streamable HTTP transport ──────────────────────────────────────────────
106
- // Parse --port N or fall back to env / default.
107
- const portIndex = argv.indexOf('--port');
108
- const portArg = portIndex !== -1 ? parseInt(argv[portIndex + 1] ?? '', 10) : NaN;
109
- const port = !isNaN(portArg)
110
- ? portArg
111
- : parseInt(process.env['BRIEFGATE_MCP_PORT'] ?? '', 10) || 3000;
112
- // Loopback unless this server is deliberately published. On a laptop the
113
- // loopback bind is what stops a browser-based attacker reaching the server at
114
- // all; behind a reverse proxy it would stop the proxy too.
115
- const host = PUBLIC_HOST ? '0.0.0.0' : '127.0.0.1';
116
- const httpServer = createServer(async (req, res) => {
117
- // DNS rebinding guard: reject requests whose Host header does not resolve
118
- // to a loopback address. A real browser attacker cannot spoof this header,
119
- // but a misconfigured proxy or custom client might send something unexpected.
120
- const host_ = req.headers['host'] ?? '';
121
- if (!isAllowedHost(host_, PUBLIC_HOST)) {
122
- res.writeHead(400, { 'Content-Type': 'application/json' });
123
- res.end(JSON.stringify({
124
- error: PUBLIC_HOST
125
- ? 'Invalid Host header'
126
- : 'Invalid Host header — only localhost connections are accepted',
127
- }));
128
- return;
129
- }
130
- // CORS: allow claude.ai and localhost origins; reject everything else.
131
- const origin = req.headers['origin'];
132
- if (origin !== undefined) {
133
- if (isAllowedOrigin(origin, PUBLIC_HOST)) {
134
- res.setHeader('Access-Control-Allow-Origin', origin);
135
- res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS');
136
- // authorization is on the list because that is how a remote caller
137
- // sends its key; without it a browser client cannot connect at all.
138
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, authorization');
139
- }
140
- else {
141
- res.writeHead(403, { 'Content-Type': 'application/json' });
142
- res.end(JSON.stringify({ error: 'Origin not allowed' }));
143
- return;
104
+ else if (cliSubcommand === 'logout') {
105
+ process.stdout.write(`${await logoutLocal(baseUrl)}\n`);
106
+ }
107
+ // Everything below only matters for actually running the server — skipped
108
+ // entirely for a CLI subcommand, which has already finished above.
109
+ if (!cliSubcommand) {
110
+ // Set to the hostname this server is published under (e.g. mcp.briefgate.dev)
111
+ // to run it as a public, multi-customer endpoint. Unset — the default and the
112
+ // only thing `npx @briefgate/mcp --http` does on a laptop keeps the server on
113
+ // loopback with the operator's own key, exactly as before.
114
+ //
115
+ // Turning it on changes several things on purpose:
116
+ // * the listener binds 0.0.0.0 and the Host guard accepts this name, because
117
+ // a server behind a reverse proxy is reached by its public name;
118
+ // * the BRIEFGATE_API_KEY and locally-stored-credential fallbacks are
119
+ // switched OFF. Leaving either on would let an anonymous caller spend the
120
+ // operator's key, or read whatever `login` last wrote to this shared
121
+ // machine which is the whole failure this mode has to avoid;
122
+ // * the server behaves as an OAuth 2.1 resource server (see the well-known
123
+ // route and the Bearer-required check below) instead of accepting an
124
+ // absent key on `initialize`/`tools/list`.
125
+ const PUBLIC_HOST = process.env['BRIEFGATE_MCP_PUBLIC_HOST'];
126
+ // Priority: --api-key > BRIEFGATE_API_KEY > a key `login` saved locally. The
127
+ // last of those is unavailable in published mode — see PUBLIC_HOST above.
128
+ const { apiKey, source: apiKeySource } = resolveApiKey({
129
+ explicitApiKey: cliApiKey,
130
+ explicitSource: 'flag',
131
+ envApiKey,
132
+ baseUrl,
133
+ allowFileFallback: !PUBLIC_HOST,
134
+ });
135
+ if (!apiKey && !PUBLIC_HOST) {
136
+ // Start anyway so clients and registries (e.g. Glama, mcp.so) can
137
+ // introspect the tool list before a key is configured. Tool calls return
138
+ // a clear error until one is available (see the CallTool handler).
139
+ process.stderr.write([
140
+ 'Warning: no BriefGate API key configured tool calls will fail until one is.',
141
+ 'Either call the `login` tool from your MCP client to sign in interactively,',
142
+ 'or get a key at https://briefgate.dev and set BRIEFGATE_API_KEY / --api-key.',
143
+ '',
144
+ ].join('\n'));
145
+ }
146
+ const config = {
147
+ apiKey,
148
+ baseUrl,
149
+ apiKeySource,
150
+ };
151
+ // The version handed to clients in the MCP handshake. Read from package.json
152
+ // rather than repeated here: hand-maintained it had drifted to 0.1.0 while the
153
+ // package shipped 0.3.0, so every client was told the wrong version.
154
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
155
+ // Returns a fresh Server instance bound to one caller's config.
156
+ //
157
+ // HTTP mode builds one per request, which is what makes a public deployment
158
+ // possible at all: the API key travels with the request rather than the
159
+ // process, so two customers hitting the same server never share a credential.
160
+ function buildServer(cfg, opts) {
161
+ const server = new Server({ name: '@briefgate/mcp', version: PACKAGE_VERSION }, { capabilities: { tools: {} } });
162
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
163
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
164
+ const { name, arguments: rawArgs } = request.params;
165
+ const args = (rawArgs ?? {});
166
+ // login/logout never need cfg.apiKey — signing in is the point of one,
167
+ // and the other only touches a local file — so they run before the
168
+ // missing-key check below, and are the one thing this handler treats
169
+ // differently between published and non-published mode.
170
+ if (name === 'login' || name === 'logout') {
171
+ if (opts.isPublicHost) {
172
+ return {
173
+ content: [
174
+ {
175
+ type: 'text',
176
+ text: 'login/logout are not available on the hosted endpoint — connecting your client already triggers OAuth automatically. Manage keys at https://briefgate.dev.',
177
+ },
178
+ ],
179
+ isError: true,
180
+ };
181
+ }
182
+ const result = await executeTool(name, cfg, args, { clientName: server.getClientVersion()?.name });
183
+ return {
184
+ content: [{ type: 'text', text: result.text }],
185
+ ...(result.isError ? { isError: true } : {}),
186
+ };
187
+ }
188
+ if (!cfg.apiKey) {
189
+ return {
190
+ content: [
191
+ {
192
+ type: 'text',
193
+ text: opts.isPublicHost
194
+ ? 'Error: no API key was sent. Connect with an Authorization: Bearer bg_live_... header; get a key at https://briefgate.dev.'
195
+ : 'Not signed in. Call the `login` tool — it opens a browser page where you approve this device.',
196
+ },
197
+ ],
198
+ isError: true,
199
+ };
200
+ }
201
+ try {
202
+ const result = await executeTool(name, cfg, args);
203
+ if (result.isError)
204
+ opts.onToolError?.(result.text);
205
+ return {
206
+ content: [{ type: 'text', text: result.text }],
207
+ ...(result.isError ? { isError: true } : {}),
208
+ };
209
+ }
210
+ catch (err) {
211
+ let message = err instanceof Error ? err.message : String(err);
212
+ // Non-published mode: point at the tool that fixes this, same as the
213
+ // missing-key message above. Published mode leaves the message as-is —
214
+ // opts.onToolError compares it verbatim against AUTH_FAILED_MESSAGE to
215
+ // decide whether to rewrite the HTTP status instead.
216
+ if (message === AUTH_FAILED_MESSAGE && !opts.isPublicHost) {
217
+ message = 'This key was revoked or expired. Call `login` again.';
218
+ }
219
+ opts.onToolError?.(message);
220
+ return {
221
+ content: [{ type: 'text', text: message }],
222
+ isError: true,
223
+ };
144
224
  }
145
- }
146
- if (req.method === 'OPTIONS') {
147
- res.writeHead(204).end();
148
- return;
149
- }
150
- // Stateless mode: one Server + one Transport per request.
151
- // This is correct because MCP tool calls are independent request/response
152
- // cycles — no shared streaming context is needed between calls.
153
- const rawBody = await readBody(req);
154
- let parsedBody;
155
- try {
156
- parsedBody = rawBody ? JSON.parse(rawBody) : undefined;
157
- }
158
- catch {
159
- res.writeHead(400, { 'Content-Type': 'application/json' });
160
- res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }));
161
- return;
162
- }
163
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
164
- const server = buildServer(configForRequest(req, { publicHost: PUBLIC_HOST, envApiKey: apiKey, baseUrl }));
165
- res.on('close', () => {
166
- transport.close();
167
- server.close();
168
225
  });
169
- try {
170
- await server.connect(transport);
171
- await transport.handleRequest(req, res, parsedBody);
172
- }
173
- catch (err) {
174
- if (!res.headersSent) {
175
- res.writeHead(500, { 'Content-Type': 'application/json' });
226
+ return server;
227
+ }
228
+ // ─── Transport selection ──────────────────────────────────────────────────────
229
+ const useHttp = argv.includes('--http') ||
230
+ process.env['BRIEFGATE_MCP_HTTP'] === '1';
231
+ if (useHttp) {
232
+ // ── Streamable HTTP transport ──────────────────────────────────────────────
233
+ // Parse --port N or fall back to env / default.
234
+ const portIndex = argv.indexOf('--port');
235
+ const portArg = portIndex !== -1 ? parseInt(argv[portIndex + 1] ?? '', 10) : NaN;
236
+ const port = !isNaN(portArg)
237
+ ? portArg
238
+ : parseInt(process.env['BRIEFGATE_MCP_PORT'] ?? '', 10) || 3000;
239
+ // Loopback unless this server is deliberately published. On a laptop the
240
+ // loopback bind is what stops a browser-based attacker reaching the server at
241
+ // all; behind a reverse proxy it would stop the proxy too.
242
+ const host = PUBLIC_HOST ? '0.0.0.0' : '127.0.0.1';
243
+ const httpServer = createServer(async (req, res) => {
244
+ // DNS rebinding guard: reject requests whose Host header does not resolve
245
+ // to a loopback address. A real browser attacker cannot spoof this header,
246
+ // but a misconfigured proxy or custom client might send something unexpected.
247
+ const host_ = req.headers['host'] ?? '';
248
+ if (!isAllowedHost(host_, PUBLIC_HOST)) {
249
+ res.writeHead(400, { 'Content-Type': 'application/json' });
176
250
  res.end(JSON.stringify({
177
- jsonrpc: '2.0',
178
- error: { code: -32603, message: 'Internal server error' },
179
- id: null,
251
+ error: PUBLIC_HOST
252
+ ? 'Invalid Host header'
253
+ : 'Invalid Host header — only localhost connections are accepted',
180
254
  }));
255
+ return;
181
256
  }
182
- process.stderr.write(`BriefGate MCP HTTP error: ${String(err)}\n`);
183
- }
184
- });
185
- httpServer.listen(port, host, () => {
186
- process.stderr.write(`BriefGate MCP server listening on http://${host}:${port}\n`);
187
- });
188
- httpServer.on('error', (err) => {
189
- process.stderr.write(`BriefGate MCP HTTP server error: ${String(err)}\n`);
190
- process.exit(1);
191
- });
192
- }
193
- else {
194
- // ── stdio transport (default) ──────────────────────────────────────────────
195
- // One process, one user, key from the environment a local client has no
196
- // other way to hand one over.
197
- const server = buildServer(config);
198
- const transport = new StdioServerTransport();
199
- await server.connect(transport);
200
- }
201
- // ─── Helpers ──────────────────────────────────────────────────────────────────
202
- function readBody(req) {
203
- return new Promise((resolve, reject) => {
204
- const chunks = [];
205
- req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
206
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
207
- req.on('error', reject);
208
- });
257
+ // OAuth resource metadata (RFC 9728): the one route on this server that
258
+ // isn't MCP JSON-RPC. Handled before the origin allowlist below because
259
+ // it must answer any origin — it's how a browser-based client discovers
260
+ // where to run OAuth in the first place, before it has any reason to be
261
+ // on our allowlist.
262
+ const pathname = (() => {
263
+ try {
264
+ return new URL(req.url ?? '/', 'http://placeholder').pathname;
265
+ }
266
+ catch {
267
+ return req.url ?? '/';
268
+ }
269
+ })();
270
+ // RFC 9728 defines the bare path; the MCP authorization spec also has
271
+ // clients look under one scoped to the resource itself — both must
272
+ // serve identical content.
273
+ if (PUBLIC_HOST &&
274
+ (pathname === WELL_KNOWN_PROTECTED_RESOURCE_PATH || pathname === WELL_KNOWN_PROTECTED_RESOURCE_MCP_PATH)) {
275
+ res.setHeader('Access-Control-Allow-Origin', '*');
276
+ if (req.method === 'OPTIONS') {
277
+ res.writeHead(204).end();
278
+ return;
279
+ }
280
+ if (req.method !== 'GET') {
281
+ res.writeHead(405, { 'Content-Type': 'application/json' });
282
+ res.end(JSON.stringify({ error: 'method_not_allowed' }));
283
+ return;
284
+ }
285
+ res.writeHead(200, { 'Content-Type': 'application/json' });
286
+ res.end(JSON.stringify(protectedResourceMetadata(PUBLIC_HOST, authServer)));
287
+ return;
288
+ }
289
+ // CORS: allow claude.ai and localhost origins; on a published server,
290
+ // any origin (see isAllowedOrigin's doc comment for why that's safe).
291
+ const origin = req.headers['origin'];
292
+ if (origin !== undefined) {
293
+ if (isAllowedOrigin(origin, PUBLIC_HOST)) {
294
+ res.setHeader('Access-Control-Allow-Origin', origin);
295
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS');
296
+ // authorization is on the list because that is how a remote caller
297
+ // sends its key; without it a browser client cannot connect at all.
298
+ // Mcp-Session-Id and Mcp-Protocol-Version are MCP transport headers
299
+ // the SDK's client sends on every request.
300
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version');
301
+ }
302
+ else {
303
+ res.writeHead(403, { 'Content-Type': 'application/json' });
304
+ res.end(JSON.stringify({ error: 'Origin not allowed' }));
305
+ return;
306
+ }
307
+ }
308
+ if (req.method === 'OPTIONS') {
309
+ res.writeHead(204).end();
310
+ return;
311
+ }
312
+ // Published mode is a full OAuth 2.1 resource server: every MCP request —
313
+ // including `initialize` and `tools/list`, which used to work without a
314
+ // key so registries could introspect the tool list — now needs a Bearer
315
+ // token, or the client has no signal to start the OAuth flow at all.
316
+ // Non-published mode is untouched: it still allows an absent key through
317
+ // to the CallTool handler, which reports the problem per-tool-call.
318
+ if (PUBLIC_HOST) {
319
+ const authHeader = req.headers['authorization'];
320
+ const hasBearer = typeof authHeader === 'string' && /^Bearer\s+\S+$/i.test(authHeader.trim());
321
+ if (!hasBearer) {
322
+ res.writeHead(401, {
323
+ 'Content-Type': 'application/json',
324
+ 'WWW-Authenticate': wwwAuthenticateHeader(PUBLIC_HOST),
325
+ });
326
+ res.end(JSON.stringify({
327
+ error: 'unauthorized',
328
+ message: 'Missing bearer token. Connect via OAuth — see the resource metadata for the authorization server.',
329
+ }));
330
+ return;
331
+ }
332
+ }
333
+ // Stateless mode: one Server + one Transport per request.
334
+ // This is correct because MCP tool calls are independent request/response
335
+ // cycles — no shared streaming context is needed between calls.
336
+ let rawBody;
337
+ try {
338
+ rawBody = await readBody(req);
339
+ }
340
+ catch (err) {
341
+ if (err instanceof PayloadTooLargeError) {
342
+ res.writeHead(413, { 'Content-Type': 'application/json' });
343
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32600, message: 'Request body too large' }, id: null }));
344
+ // The body was abandoned mid-stream, so the socket may still be sitting
345
+ // on unread bytes the client is trying to push — close it outright
346
+ // rather than leaving it open for `end` to (never) fire.
347
+ req.socket.destroy();
348
+ return;
349
+ }
350
+ res.writeHead(400, { 'Content-Type': 'application/json' });
351
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }));
352
+ return;
353
+ }
354
+ let parsedBody;
355
+ try {
356
+ parsedBody = rawBody ? JSON.parse(rawBody) : undefined;
357
+ }
358
+ catch {
359
+ res.writeHead(400, { 'Content-Type': 'application/json' });
360
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }));
361
+ return;
362
+ }
363
+ // enableJsonResponse: this server never streams notifications to the
364
+ // client mid-call, so there is nothing an SSE response buys here — every
365
+ // request really is one request, one response. It also makes the 401
366
+ // rewrite below possible at all: the SDK's default SSE mode opens the
367
+ // stream (and so sends its 200 status line) before the tool call even
368
+ // starts, long before we know whether it will fail with an expired key.
369
+ const transport = new StreamableHTTPServerTransport({
370
+ sessionIdGenerator: undefined,
371
+ enableJsonResponse: true,
372
+ });
373
+ const cfg = configForRequest(req, { publicHost: PUBLIC_HOST, cliApiKey, envApiKey, baseUrl });
374
+ // The MCP transport always answers a tool call with HTTP 200 — a failed
375
+ // tool is a JSON-RPC-level result, not a transport-level one — so an
376
+ // expired/revoked key can't turn into a real 401 from inside the CallTool
377
+ // handler. We recover that signal here instead: buildServer tells us via
378
+ // onToolError whether the just-finished call failed with exactly
379
+ // AUTH_FAILED_MESSAGE, and if so this rewrites the response the transport
380
+ // is about to send, right before it hits the socket. Node's http module
381
+ // calls res.writeHead exactly once with the final status and headers
382
+ // (confirmed against the @hono/node-server version this SDK uses), which
383
+ // is what makes intercepting it here safe rather than fragile.
384
+ let rewriteToAuthFailed = false;
385
+ if (PUBLIC_HOST) {
386
+ const originalWriteHead = res.writeHead.bind(res);
387
+ res.writeHead = ((status, ...rest) => {
388
+ if (rewriteToAuthFailed && status === 200) {
389
+ return originalWriteHead(401, {
390
+ 'Content-Type': 'application/json',
391
+ 'WWW-Authenticate': wwwAuthenticateHeader(PUBLIC_HOST, 'invalid_token'),
392
+ });
393
+ }
394
+ return originalWriteHead(status, ...rest);
395
+ });
396
+ }
397
+ const server = buildServer(cfg, {
398
+ isPublicHost: Boolean(PUBLIC_HOST),
399
+ onToolError: (message) => {
400
+ if (PUBLIC_HOST && message === AUTH_FAILED_MESSAGE)
401
+ rewriteToAuthFailed = true;
402
+ },
403
+ });
404
+ res.on('close', () => {
405
+ transport.close();
406
+ server.close();
407
+ });
408
+ try {
409
+ await server.connect(transport);
410
+ await transport.handleRequest(req, res, parsedBody);
411
+ }
412
+ catch (err) {
413
+ if (!res.headersSent) {
414
+ res.writeHead(500, { 'Content-Type': 'application/json' });
415
+ res.end(JSON.stringify({
416
+ jsonrpc: '2.0',
417
+ error: { code: -32603, message: 'Internal server error' },
418
+ id: null,
419
+ }));
420
+ }
421
+ process.stderr.write(`BriefGate MCP HTTP error: ${String(err)}\n`);
422
+ }
423
+ });
424
+ httpServer.listen(port, host, () => {
425
+ process.stderr.write(`BriefGate MCP server listening on http://${host}:${port}\n`);
426
+ });
427
+ httpServer.on('error', (err) => {
428
+ process.stderr.write(`BriefGate MCP HTTP server error: ${String(err)}\n`);
429
+ process.exit(1);
430
+ });
431
+ }
432
+ else {
433
+ // ── stdio transport (default) ──────────────────────────────────────────────
434
+ // One process, one user. `config` is a single mutable object shared by every
435
+ // request this process handles: when `login` succeeds it writes to the
436
+ // credentials file AND updates config.apiKey directly, so the very next
437
+ // tool call in this same session picks up the new key without a restart.
438
+ const server = buildServer(config, { isPublicHost: false });
439
+ const transport = new StdioServerTransport();
440
+ await server.connect(transport);
441
+ }
209
442
  }
210
443
  //# sourceMappingURL=index.js.map