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