@debugg-ai/debugg-ai-mcp 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to the DebuggAI MCP project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.5.0]
9
+
10
+ ### Added — Remote transport: Streamable HTTP + OAuth Resource Server (opt-in)
11
+
12
+ The server can now run as a hosted, multi-user remote MCP over **stateless
13
+ Streamable HTTP**, in addition to the default stdio transport (which is
14
+ unchanged). Enable with `DEBUGGAI_MCP_TRANSPORT=http` (+ `PORT`, default 3000).
15
+
16
+ As an OAuth **Resource Server** (MCP 2025-06-18):
17
+ - Each `POST /mcp` request must carry `Authorization: Bearer <token>`; the token
18
+ is request-scoped (AsyncLocalStorage) and used as the backend credential —
19
+ `api.debugg.ai` is the validator, so no token-verification keys live here.
20
+ - Missing/invalid token → `401` with `WWW-Authenticate: Bearer resource_metadata=…`.
21
+ - Serves RFC 9728 metadata at `/.well-known/oauth-protected-resource` advertising
22
+ the authorization server (`auth.debugg.ai`), so clients run the OAuth flow and
23
+ retry with a token.
24
+ - `GET /health` for load-balancer / ECS health checks.
25
+
26
+ Auth became request-scoped without touching the ~20 backend-client call sites:
27
+ `config.api.key` resolves the per-request token when set (`utils/requestContext.ts`).
28
+ Config env: `DEBUGGAI_MCP_TRANSPORT`, `PORT`, `DEBUGGAI_MCP_PUBLIC_URL`,
29
+ `DEBUGGAI_OAUTH_ISSUER`, `DEBUGGAI_TOKEN_TYPE=bearer`. stdio installs need none of these.
30
+
8
31
  ## [3.4.0]
9
32
 
10
33
  ### Added — MCP Resources (browse projects / environments / executions)
package/README.md CHANGED
@@ -234,6 +234,37 @@ Response-shape changes: the bare `count` field on list responses is gone — use
234
234
  DEBUGGAI_API_KEY=your_api_key
235
235
  ```
236
236
 
237
+ ## Remote / HTTP transport (optional)
238
+
239
+ By default the server speaks **stdio** (local `npx`). It can instead run as a
240
+ hosted, multi-user remote MCP over **stateless Streamable HTTP** + OAuth:
241
+
242
+ ```bash
243
+ DEBUGGAI_MCP_TRANSPORT=http PORT=3000 DEBUGGAI_TOKEN_TYPE=bearer npx -y @debugg-ai/debugg-ai-mcp@latest
244
+ ```
245
+
246
+ It is an OAuth **Resource Server**: every `POST /mcp` needs
247
+ `Authorization: Bearer <token>`; missing/invalid tokens get a `401` with a
248
+ `WWW-Authenticate` pointing at the RFC 9728 metadata, and clients run the OAuth
249
+ flow against the advertised authorization server. The bearer is request-scoped —
250
+ `api.debugg.ai` validates it.
251
+
252
+ | Endpoint | Purpose |
253
+ |---|---|
254
+ | `POST /mcp` | MCP Streamable HTTP (bearer-protected) |
255
+ | `GET /.well-known/oauth-protected-resource` | RFC 9728 metadata (authorization server discovery) |
256
+ | `GET /health` | Load-balancer / ECS health check |
257
+
258
+ | Env var | Default | Purpose |
259
+ |---|---|---|
260
+ | `DEBUGGAI_MCP_TRANSPORT` | `stdio` | Set to `http` for the remote transport |
261
+ | `PORT` | `3000` | HTTP listen port |
262
+ | `DEBUGGAI_MCP_PUBLIC_URL` | `https://mcp.debugg.ai` | This server's public resource URL (RFC 9728 `resource`) |
263
+ | `DEBUGGAI_OAUTH_ISSUER` | `https://auth.debugg.ai` | Authorization server advertised to clients |
264
+ | `DEBUGGAI_TOKEN_TYPE` | `token` | Set to `bearer` so OAuth tokens forward as `Authorization: Bearer` |
265
+
266
+ stdio installs need none of these.
267
+
237
268
  ## Telemetry
238
269
 
239
270
  The MCP server ships with telemetry enabled by default — an embedded write-only PostHog project key (`phc_*`) so the team can observe cache hit rates, poll cadence, tunnel reliability, and other operational metrics across the install base. Captured events:
@@ -5,6 +5,7 @@ import { z } from 'zod';
5
5
  import { readFileSync } from 'fs';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { dirname, join } from 'path';
8
+ import { currentApiKey } from '../utils/requestContext.js';
8
9
  function findPackageVersion() {
9
10
  const __dir = dirname(fileURLToPath(import.meta.url));
10
11
  let dir = __dir;
@@ -113,7 +114,14 @@ let _config;
113
114
  export const config = {
114
115
  get server() { return getConfig().server; },
115
116
  get devMode() { return getConfig().devMode; },
116
- get api() { return getConfig().api; },
117
+ // api.key is request-scoped under the HTTP transport: if a per-request token
118
+ // is set (AsyncLocalStorage), it overrides the env key for that request only.
119
+ // stdio / tests have no request store, so the env key is returned unchanged.
120
+ get api() {
121
+ const api = getConfig().api;
122
+ const requestKey = currentApiKey();
123
+ return requestKey ? { ...api, key: requestKey } : api;
124
+ },
117
125
  get defaults() { return getConfig().defaults; },
118
126
  get logging() { return getConfig().logging; },
119
127
  get telemetry() { return getConfig().telemetry; },
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Streamable HTTP transport + OAuth Resource Server (epic lybfq).
3
+ *
4
+ * Opt-in remote transport: `DEBUGGAI_MCP_TRANSPORT=http` (stdio stays default).
5
+ * Stateless (no session id) so it scales behind a plain load balancer.
6
+ *
7
+ * Auth model — the MCP server is an OAuth **Resource Server**:
8
+ * - Every /mcp request must carry `Authorization: Bearer <token>`.
9
+ * - The token is stashed per-request (AsyncLocalStorage) and used as the
10
+ * backend credential; api.debugg.ai is the real validator (a bad token 401s
11
+ * on the first backend call). No token verification keys live here.
12
+ * - Missing token → 401 + `WWW-Authenticate: Bearer resource_metadata=...`,
13
+ * and we serve RFC 9728 metadata at /.well-known/oauth-protected-resource
14
+ * pointing clients at auth.debugg.ai to run the OAuth flow.
15
+ *
16
+ * Deployment note: set DEBUGGAI_TOKEN_TYPE=bearer so the backend client forwards
17
+ * the OAuth token as `Authorization: Bearer` (not `Token`).
18
+ */
19
+ import { createServer } from 'node:http';
20
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
21
+ import { runWithApiKey } from './utils/requestContext.js';
22
+ const PUBLIC_URL = (process.env.DEBUGGAI_MCP_PUBLIC_URL || 'https://mcp.debugg.ai').replace(/\/+$/, '');
23
+ const OAUTH_ISSUER = (process.env.DEBUGGAI_OAUTH_ISSUER || 'https://auth.debugg.ai').replace(/\/+$/, '');
24
+ const RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource';
25
+ const MCP_PATH = '/mcp';
26
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
27
+ /** RFC 9728 protected-resource metadata: tells clients which AS issues tokens. */
28
+ export function protectedResourceMetadata() {
29
+ return {
30
+ resource: PUBLIC_URL,
31
+ authorization_servers: [OAUTH_ISSUER],
32
+ bearer_methods_supported: ['header'],
33
+ };
34
+ }
35
+ /** Extract the token from `Authorization: Bearer <t>` (or `Token <t>`). */
36
+ export function bearerToken(authHeader) {
37
+ if (!authHeader)
38
+ return undefined;
39
+ const m = /^(?:Bearer|Token)\s+(.+)$/i.exec(authHeader.trim());
40
+ return m ? m[1].trim() : undefined;
41
+ }
42
+ function sendJson(res, code, body, extraHeaders = {}) {
43
+ const data = JSON.stringify(body);
44
+ res.writeHead(code, {
45
+ 'Content-Type': 'application/json',
46
+ 'Content-Length': Buffer.byteLength(data),
47
+ ...extraHeaders,
48
+ });
49
+ res.end(data);
50
+ }
51
+ function unauthorized(res) {
52
+ const metadataUrl = `${PUBLIC_URL}${RESOURCE_METADATA_PATH}`;
53
+ sendJson(res, 401, { error: 'unauthorized', error_description: 'Missing or invalid bearer token; authenticate via the linked authorization server.' }, { 'WWW-Authenticate': `Bearer resource_metadata="${metadataUrl}"` });
54
+ }
55
+ function readJsonBody(req) {
56
+ return new Promise((resolve, reject) => {
57
+ let data = '';
58
+ let aborted = false;
59
+ req.on('data', (chunk) => {
60
+ data += chunk;
61
+ if (data.length > MAX_BODY_BYTES && !aborted) {
62
+ aborted = true;
63
+ reject(new Error('request body too large'));
64
+ }
65
+ });
66
+ req.on('end', () => {
67
+ if (aborted)
68
+ return;
69
+ if (!data)
70
+ return resolve(undefined);
71
+ try {
72
+ resolve(JSON.parse(data));
73
+ }
74
+ catch (e) {
75
+ reject(e);
76
+ }
77
+ });
78
+ req.on('error', reject);
79
+ });
80
+ }
81
+ /** Start the stateless Streamable HTTP server. Resolves to the listening server. */
82
+ export async function startHttpServer(opts) {
83
+ const { port, buildServer, logger } = opts;
84
+ const httpServer = createServer(async (req, res) => {
85
+ const path = new URL(req.url || '/', 'http://localhost').pathname;
86
+ // ECS / LB health check — no auth.
87
+ if (path === '/health' && req.method === 'GET') {
88
+ return sendJson(res, 200, { status: 'ok' });
89
+ }
90
+ // RFC 9728 protected-resource metadata — public discovery, no auth.
91
+ if (path === RESOURCE_METADATA_PATH && req.method === 'GET') {
92
+ return sendJson(res, 200, protectedResourceMetadata());
93
+ }
94
+ if (path === MCP_PATH) {
95
+ const token = bearerToken(req.headers['authorization']);
96
+ if (!token) {
97
+ logger.info('HTTP MCP request without bearer token → 401');
98
+ return unauthorized(res);
99
+ }
100
+ let body;
101
+ if (req.method === 'POST') {
102
+ try {
103
+ body = await readJsonBody(req);
104
+ }
105
+ catch {
106
+ return sendJson(res, 400, { error: 'invalid_request', error_description: 'Request body must be valid JSON' });
107
+ }
108
+ }
109
+ // Stateless: a fresh server + transport per request, scoped to this
110
+ // request's bearer token via AsyncLocalStorage (so config.api.key resolves
111
+ // to it for every backend call made while handling this request).
112
+ const srv = buildServer();
113
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
114
+ res.on('close', () => {
115
+ transport.close().catch(() => { });
116
+ srv.close().catch(() => { });
117
+ });
118
+ try {
119
+ await srv.connect(transport);
120
+ await runWithApiKey(token, () => transport.handleRequest(req, res, body));
121
+ }
122
+ catch (error) {
123
+ logger.error('HTTP MCP request failed', { error: error instanceof Error ? error.message : String(error) });
124
+ if (!res.headersSent)
125
+ sendJson(res, 500, { error: 'internal_error' });
126
+ }
127
+ return;
128
+ }
129
+ sendJson(res, 404, { error: 'not_found' });
130
+ });
131
+ await new Promise((resolve) => httpServer.listen(port, resolve));
132
+ logger.info('HTTP transport listening', { port, resource: PUBLIC_URL, authorizationServer: OAUTH_ISSUER });
133
+ return httpServer;
134
+ }
package/dist/index.js CHANGED
@@ -49,12 +49,12 @@ function createMCPServer() {
49
49
  /**
50
50
  * Create progress callback for tool execution
51
51
  */
52
- function createProgressCallback(progressToken) {
52
+ function createProgressCallback(srv, progressToken) {
53
53
  if (!progressToken)
54
54
  return undefined;
55
55
  return async ({ progress, total, message }) => {
56
56
  try {
57
- await server.notification({
57
+ await srv.notification({
58
58
  method: "notifications/progress",
59
59
  params: {
60
60
  progressToken,
@@ -76,10 +76,21 @@ function createProgressCallback(progressToken) {
76
76
  };
77
77
  }
78
78
  /**
79
- * Register MCP request handlers. Called in main() after server is created.
79
+ * Build a fully-configured MCP Server (capabilities + all request handlers).
80
+ * The HTTP transport calls this once per request for stateless isolation; main()
81
+ * uses it for the long-lived stdio server.
80
82
  */
81
- function registerHandlers() {
82
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
83
+ export function buildConfiguredServer() {
84
+ const srv = createMCPServer();
85
+ registerHandlers(srv);
86
+ return srv;
87
+ }
88
+ /**
89
+ * Register MCP request handlers on a server instance. Called for the stdio
90
+ * singleton in main(), and once per request by the HTTP transport (stateless).
91
+ */
92
+ function registerHandlers(srv) {
93
+ srv.setRequestHandler(CallToolRequestSchema, async (req) => {
83
94
  const typedReq = req;
84
95
  const requestId = `req_${Date.now()}`;
85
96
  const requestLogger = logger.child({ requestId });
@@ -113,7 +124,7 @@ function registerHandlers() {
113
124
  requestId,
114
125
  timestamp: new Date(),
115
126
  };
116
- const progressCallback = createProgressCallback(typeof progressToken === 'string' || typeof progressToken === 'number' ? String(progressToken) : undefined);
127
+ const progressCallback = createProgressCallback(srv, typeof progressToken === 'string' || typeof progressToken === 'number' ? String(progressToken) : undefined);
117
128
  requestLogger.info(`Executing tool: ${name}`);
118
129
  const toolStart = Date.now();
119
130
  const result = await tool.handler(validatedInput, context, progressCallback);
@@ -134,7 +145,7 @@ function registerHandlers() {
134
145
  return createErrorResponse(mcpError, typedReq.params.name);
135
146
  }
136
147
  });
137
- server.setRequestHandler(ListToolsRequestSchema, async () => {
148
+ srv.setRequestHandler(ListToolsRequestSchema, async () => {
138
149
  const tools = getTools();
139
150
  logger.info('Tools list requested', { toolCount: tools.length });
140
151
  return { tools };
@@ -142,14 +153,14 @@ function registerHandlers() {
142
153
  // Resources (epic pglam): browse projects/environments/executions as
143
154
  // addressable read URIs. Reads dispatch to the same entity handlers as the
144
155
  // tools, so data + auth stay consistent.
145
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
156
+ srv.setRequestHandler(ListResourcesRequestSchema, async () => {
146
157
  logger.info('Resources list requested', { resourceCount: RESOURCE_COLLECTIONS.length });
147
158
  return { resources: RESOURCE_COLLECTIONS };
148
159
  });
149
- server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
160
+ srv.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
150
161
  return { resourceTemplates: RESOURCE_TEMPLATES };
151
162
  });
152
- server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
163
+ srv.setRequestHandler(ReadResourceRequestSchema, async (req) => {
153
164
  const uri = req.params?.uri;
154
165
  logger.info('Resource read requested', { uri });
155
166
  try {
@@ -170,19 +181,18 @@ async function main() {
170
181
  // Initialize logger and server here (not at module load time) so config
171
182
  // validation errors are caught by this try-catch instead of crashing.
172
183
  logger = new Logger({ module: 'main' });
173
- server = createMCPServer();
174
- // Register request handlers (they reference the `server` variable)
175
- registerHandlers();
184
+ const transportMode = (process.env.DEBUGGAI_MCP_TRANSPORT || 'stdio').toLowerCase();
176
185
  logger.info('Starting DebuggAI MCP Server', {
177
186
  nodeVersion: process.version,
178
187
  platform: process.platform,
179
188
  architecture: process.arch,
180
- pid: process.pid
189
+ pid: process.pid,
190
+ transport: transportMode,
181
191
  });
182
- // NOTE: DEBUGGAI_API_KEY validation is deferred to the first tool call so
183
- // MCP clients see a proper initialize response + a structured tool error,
184
- // rather than the subprocess dying with "Failed to reconnect" (bead cma).
185
- if (!config.api.key) {
192
+ // stdio is single-user: the API key comes from the environment and is
193
+ // validated at first tool call (bead cma). HTTP is multi-user: each request
194
+ // carries its own bearer token, so a missing env key at boot is expected.
195
+ if (transportMode !== 'http' && !config.api.key) {
186
196
  logger.warn('DEBUGGAI_API_KEY is not set. Server will boot but every tool call will return a ConfigurationError until the env var is configured.');
187
197
  }
188
198
  // Initialize telemetry: PostHog by default (public project key embedded
@@ -206,12 +216,28 @@ async function main() {
206
216
  // No API calls at boot. Project context is resolved lazily on first tool
207
217
  // invocation (list_environments / list_credentials / check_app_in_browser).
208
218
  initTools(null);
209
- const transport = new StdioServerTransport();
210
- await server.connect(transport);
211
- logger.info('DebuggAI MCP Server is running and ready to accept requests', {
212
- transport: 'stdio',
213
- toolsAvailable: getTools().map(t => t.name),
214
- });
219
+ if (transportMode === 'http') {
220
+ // Remote/hosted transport (epic lybfq): stateless Streamable HTTP + OAuth
221
+ // Resource Server. stdio stays the default and is unaffected.
222
+ const { startHttpServer } = await import('./httpServer.js');
223
+ const port = Number(process.env.PORT) || 3000;
224
+ await startHttpServer({ port, buildServer: buildConfiguredServer, logger });
225
+ logger.info('DebuggAI MCP Server is running and ready to accept requests', {
226
+ transport: 'http',
227
+ port,
228
+ toolsAvailable: getTools().map(t => t.name),
229
+ });
230
+ }
231
+ else {
232
+ server = createMCPServer();
233
+ registerHandlers(server);
234
+ const transport = new StdioServerTransport();
235
+ await server.connect(transport);
236
+ logger.info('DebuggAI MCP Server is running and ready to accept requests', {
237
+ transport: 'stdio',
238
+ toolsAvailable: getTools().map(t => t.name),
239
+ });
240
+ }
215
241
  }
216
242
  catch (error) {
217
243
  logger.error('Failed to start DebuggAI MCP Server', {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Request-scoped context (epic lybfq).
3
+ *
4
+ * stdio is single-user: one API key from the environment for the whole process.
5
+ * The HTTP transport is multi-user: each request carries its own bearer token.
6
+ * Rather than thread a token through every handler + backend-client call site,
7
+ * we stash it in AsyncLocalStorage for the duration of the request and let
8
+ * `config.api.key` resolve it (see config/index.ts). Outside an HTTP request
9
+ * (i.e. stdio, or tests) the store is empty and the env key is used — so the
10
+ * stdio path is completely unchanged.
11
+ *
12
+ * This module intentionally has NO imports so any layer (incl. config) can use
13
+ * it without creating an import cycle.
14
+ */
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
+ const storage = new AsyncLocalStorage();
17
+ /** Run `fn` with a request-scoped API key (used by the HTTP transport per request). */
18
+ export function runWithApiKey(apiKey, fn) {
19
+ return storage.run({ apiKey }, fn);
20
+ }
21
+ /** The request-scoped API key if inside runWithApiKey(), else undefined (stdio). */
22
+ export function currentApiKey() {
23
+ return storage.getStore()?.apiKey;
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "bin": {