@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/README.md +118 -19
- package/dist/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +6 -1
- package/dist/client.js.map +1 -1
- package/dist/credentials.d.ts +31 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +89 -0
- package/dist/credentials.js.map +1 -0
- package/dist/device-auth.d.ts +41 -0
- package/dist/device-auth.d.ts.map +1 -0
- package/dist/device-auth.js +119 -0
- package/dist/device-auth.js.map +1 -0
- package/dist/http-auth.d.ts +39 -5
- package/dist/http-auth.d.ts.map +1 -1
- package/dist/http-auth.js +63 -7
- package/dist/http-auth.js.map +1 -1
- package/dist/index.js +392 -187
- package/dist/index.js.map +1 -1
- package/dist/login.d.ts +49 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +205 -0
- package/dist/login.js.map +1 -0
- package/dist/tools.d.ts +65 -5
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +87 -2
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
47
|
+
const urlHostname = new URL(baseUrl).hostname;
|
|
32
48
|
isSafeHttp =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
!
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
253
|
+
error: PUBLIC_HOST
|
|
254
|
+
? 'Invalid Host header'
|
|
255
|
+
: 'Invalid Host header — only localhost connections are accepted',
|
|
219
256
|
}));
|
|
257
|
+
return;
|
|
220
258
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|