@debugg-ai/debugg-ai-mcp 3.3.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 +39 -0
- package/README.md +49 -0
- package/dist/config/index.js +9 -1
- package/dist/handlers/resourcesHandler.js +127 -0
- package/dist/httpServer.js +134 -0
- package/dist/index.js +74 -22
- package/dist/utils/requestContext.js +24 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ 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
|
+
|
|
31
|
+
## [3.4.0]
|
|
32
|
+
|
|
33
|
+
### Added — MCP Resources (browse projects / environments / executions)
|
|
34
|
+
|
|
35
|
+
The server now declares the `resources` capability and exposes the read-only
|
|
36
|
+
entities as addressable resources, so clients can browse and @-mention them as
|
|
37
|
+
context instead of only calling tools:
|
|
38
|
+
|
|
39
|
+
- **Collections** (`resources/list`): `debugg-ai://projects`, `debugg-ai://environments`, `debugg-ai://executions`
|
|
40
|
+
- **Templates** (`resources/templates/list`): `debugg-ai://project/{uuid}`, `debugg-ai://environment/{uuid}`, `debugg-ai://execution/{uuid}`
|
|
41
|
+
- **`resources/read`** dispatches each URI to the same entity handler the tools
|
|
42
|
+
use — identical data + auth, no drift — and returns the JSON payload.
|
|
43
|
+
|
|
44
|
+
Additive: clients without resource support keep using the tools unchanged.
|
|
45
|
+
Implementation in `handlers/resourcesHandler.ts`.
|
|
46
|
+
|
|
8
47
|
## [3.3.0]
|
|
9
48
|
|
|
10
49
|
### Added — Run artifacts returned as resource links
|
package/README.md
CHANGED
|
@@ -158,6 +158,24 @@ Every filter-mode response is paginated. Response shape:
|
|
|
158
158
|
|
|
159
159
|
Pass optional `page` (1-indexed, default 1) and `pageSize` (default 20, max 200; oversized values are clamped). No response is ever silently truncated.
|
|
160
160
|
|
|
161
|
+
## Resources
|
|
162
|
+
|
|
163
|
+
Alongside tools, the server exposes the read-only entities as MCP **resources**
|
|
164
|
+
so clients can browse and @-mention them as context:
|
|
165
|
+
|
|
166
|
+
| URI | What |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `debugg-ai://projects` | All projects (first page) |
|
|
169
|
+
| `debugg-ai://environments` | Environments for the auto-detected project |
|
|
170
|
+
| `debugg-ai://executions` | Recent executions (first page) |
|
|
171
|
+
| `debugg-ai://project/{uuid}` | One project, full detail |
|
|
172
|
+
| `debugg-ai://environment/{uuid}` | One environment (credentials inline, passwords redacted) |
|
|
173
|
+
| `debugg-ai://execution/{uuid}` | One execution, full node detail + artifact links |
|
|
174
|
+
|
|
175
|
+
Reads dispatch to the same handlers as the `project` / `environment` /
|
|
176
|
+
`executions` tools, so the data and auth are identical. Resources are additive —
|
|
177
|
+
clients without resource support keep using the tools.
|
|
178
|
+
|
|
161
179
|
### Security invariants
|
|
162
180
|
|
|
163
181
|
- Passwords are write-only. They never appear in any response body from any tool.
|
|
@@ -216,6 +234,37 @@ Response-shape changes: the bare `count` field on list responses is gone — use
|
|
|
216
234
|
DEBUGGAI_API_KEY=your_api_key
|
|
217
235
|
```
|
|
218
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
|
+
|
|
219
268
|
## Telemetry
|
|
220
269
|
|
|
221
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:
|
package/dist/config/index.js
CHANGED
|
@@ -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
|
-
|
|
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,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Resources (epic pglam).
|
|
3
|
+
*
|
|
4
|
+
* Exposes the read-only entities (projects / environments / executions) as
|
|
5
|
+
* addressable resources so clients can browse and @-mention them as context
|
|
6
|
+
* instead of (or alongside) calling the `project`/`environment`/`executions`
|
|
7
|
+
* tools. Reads reuse the exact tool handlers — same data, same auth, no drift.
|
|
8
|
+
*
|
|
9
|
+
* Collections (resources/list): debugg-ai://projects | environments | executions
|
|
10
|
+
* Items (resources/templates/list): debugg-ai://project/{uuid}
|
|
11
|
+
* debugg-ai://environment/{uuid}
|
|
12
|
+
* debugg-ai://execution/{uuid}
|
|
13
|
+
*
|
|
14
|
+
* Resources are additive: clients that don't support the capability simply
|
|
15
|
+
* keep using the tools.
|
|
16
|
+
*/
|
|
17
|
+
import { config } from '../config/index.js';
|
|
18
|
+
import { MCPError, MCPErrorCode, } from '../types/index.js';
|
|
19
|
+
import { projectHandler } from './projectHandler.js';
|
|
20
|
+
import { environmentHandler } from './environmentHandler.js';
|
|
21
|
+
import { executionsHandler } from './executionsHandler.js';
|
|
22
|
+
const SCHEME = 'debugg-ai';
|
|
23
|
+
const JSON_MIME = 'application/json';
|
|
24
|
+
/** Concrete collection resources returned by resources/list. */
|
|
25
|
+
export const RESOURCE_COLLECTIONS = [
|
|
26
|
+
{
|
|
27
|
+
uri: `${SCHEME}://projects`,
|
|
28
|
+
name: 'projects',
|
|
29
|
+
title: 'DebuggAI Projects',
|
|
30
|
+
description: 'All projects visible to this API key (first page).',
|
|
31
|
+
mimeType: JSON_MIME,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
uri: `${SCHEME}://environments`,
|
|
35
|
+
name: 'environments',
|
|
36
|
+
title: 'DebuggAI Environments',
|
|
37
|
+
description: 'Environments for the auto-detected project (credentials redacted).',
|
|
38
|
+
mimeType: JSON_MIME,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
uri: `${SCHEME}://executions`,
|
|
42
|
+
name: 'executions',
|
|
43
|
+
title: 'DebuggAI Executions',
|
|
44
|
+
description: 'Recent workflow executions (first page).',
|
|
45
|
+
mimeType: JSON_MIME,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
/** URI templates returned by resources/templates/list. */
|
|
49
|
+
export const RESOURCE_TEMPLATES = [
|
|
50
|
+
{
|
|
51
|
+
uriTemplate: `${SCHEME}://project/{uuid}`,
|
|
52
|
+
name: 'project',
|
|
53
|
+
title: 'DebuggAI Project',
|
|
54
|
+
description: 'A single project by UUID, with full detail.',
|
|
55
|
+
mimeType: JSON_MIME,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
uriTemplate: `${SCHEME}://environment/{uuid}`,
|
|
59
|
+
name: 'environment',
|
|
60
|
+
title: 'DebuggAI Environment',
|
|
61
|
+
description: 'A single environment by UUID, with credentials inline (passwords redacted).',
|
|
62
|
+
mimeType: JSON_MIME,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
uriTemplate: `${SCHEME}://execution/{uuid}`,
|
|
66
|
+
name: 'execution',
|
|
67
|
+
title: 'DebuggAI Execution',
|
|
68
|
+
description: 'A single execution by UUID, with full node detail + artifact links.',
|
|
69
|
+
mimeType: JSON_MIME,
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
function readContext() {
|
|
73
|
+
return { timestamp: new Date() };
|
|
74
|
+
}
|
|
75
|
+
/** Pull the JSON payload that every entity handler emits as its single text block. */
|
|
76
|
+
function payloadText(res) {
|
|
77
|
+
const text = res.content?.find((c) => c.type === 'text')?.text;
|
|
78
|
+
return typeof text === 'string' ? text : '{}';
|
|
79
|
+
}
|
|
80
|
+
// debugg-ai://<kind> (collection)
|
|
81
|
+
// debugg-ai://<kind>/<uuid> (item)
|
|
82
|
+
const URI_RE = new RegExp(`^${SCHEME}://([a-z]+)(?:/([^/?#]+))?$`);
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a debugg-ai:// resource URI by dispatching to the matching tool
|
|
85
|
+
* handler and wrapping its JSON payload as a resource content block.
|
|
86
|
+
*/
|
|
87
|
+
export async function readResource(uri) {
|
|
88
|
+
if (!config.api.key) {
|
|
89
|
+
throw new MCPError(MCPErrorCode.CONFIGURATION_ERROR, 'DEBUGGAI_API_KEY is not set. Configure it in your MCP server registration. Get a key at https://debugg.ai.', { missingEnvVars: ['DEBUGGAI_API_KEY'] });
|
|
90
|
+
}
|
|
91
|
+
const match = URI_RE.exec(uri);
|
|
92
|
+
if (!match) {
|
|
93
|
+
throw new MCPError(MCPErrorCode.INVALID_PARAMS, `Unrecognized resource URI: ${uri}`);
|
|
94
|
+
}
|
|
95
|
+
const [, kind, id] = match;
|
|
96
|
+
const ctx = readContext();
|
|
97
|
+
const requireId = () => {
|
|
98
|
+
if (!id) {
|
|
99
|
+
throw new MCPError(MCPErrorCode.INVALID_PARAMS, `Resource ${uri} requires a UUID: ${SCHEME}://${kind}/{uuid}`);
|
|
100
|
+
}
|
|
101
|
+
return id;
|
|
102
|
+
};
|
|
103
|
+
let res;
|
|
104
|
+
switch (kind) {
|
|
105
|
+
case 'projects':
|
|
106
|
+
res = await projectHandler({ action: 'list' }, ctx);
|
|
107
|
+
break;
|
|
108
|
+
case 'project':
|
|
109
|
+
res = await projectHandler({ action: 'get', uuid: requireId() }, ctx);
|
|
110
|
+
break;
|
|
111
|
+
case 'environments':
|
|
112
|
+
res = await environmentHandler({ action: 'list' }, ctx);
|
|
113
|
+
break;
|
|
114
|
+
case 'environment':
|
|
115
|
+
res = await environmentHandler({ action: 'get', uuid: requireId() }, ctx);
|
|
116
|
+
break;
|
|
117
|
+
case 'executions':
|
|
118
|
+
res = await executionsHandler({ action: 'list' }, ctx);
|
|
119
|
+
break;
|
|
120
|
+
case 'execution':
|
|
121
|
+
res = await executionsHandler({ action: 'get', uuid: requireId() }, ctx);
|
|
122
|
+
break;
|
|
123
|
+
default:
|
|
124
|
+
throw new MCPError(MCPErrorCode.INVALID_PARAMS, `Unknown resource kind "${kind}" in ${uri}`);
|
|
125
|
+
}
|
|
126
|
+
return { contents: [{ uri, mimeType: JSON_MIME, text: payloadText(res) }] };
|
|
127
|
+
}
|
|
@@ -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
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
20
20
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
|
-
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
22
22
|
import { config } from "./config/index.js";
|
|
23
23
|
import { initTools, getTools, getTool } from "./tools/index.js";
|
|
24
|
+
import { readResource, RESOURCE_COLLECTIONS, RESOURCE_TEMPLATES } from "./handlers/resourcesHandler.js";
|
|
24
25
|
import { Logger, validateInput, createErrorResponse, toMCPError, handleConfigurationError, Telemetry, TelemetryEvents, withStructuredContent, } from "./utils/index.js";
|
|
25
26
|
import { MCPErrorCode, MCPError, } from "./types/index.js";
|
|
26
27
|
// Logger and server are initialized lazily in main() to avoid triggering
|
|
@@ -39,18 +40,21 @@ function createMCPServer() {
|
|
|
39
40
|
tools: {
|
|
40
41
|
listChanged: false,
|
|
41
42
|
},
|
|
43
|
+
resources: {
|
|
44
|
+
listChanged: false,
|
|
45
|
+
},
|
|
42
46
|
},
|
|
43
47
|
});
|
|
44
48
|
}
|
|
45
49
|
/**
|
|
46
50
|
* Create progress callback for tool execution
|
|
47
51
|
*/
|
|
48
|
-
function createProgressCallback(progressToken) {
|
|
52
|
+
function createProgressCallback(srv, progressToken) {
|
|
49
53
|
if (!progressToken)
|
|
50
54
|
return undefined;
|
|
51
55
|
return async ({ progress, total, message }) => {
|
|
52
56
|
try {
|
|
53
|
-
await
|
|
57
|
+
await srv.notification({
|
|
54
58
|
method: "notifications/progress",
|
|
55
59
|
params: {
|
|
56
60
|
progressToken,
|
|
@@ -72,10 +76,21 @@ function createProgressCallback(progressToken) {
|
|
|
72
76
|
};
|
|
73
77
|
}
|
|
74
78
|
/**
|
|
75
|
-
*
|
|
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.
|
|
82
|
+
*/
|
|
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).
|
|
76
91
|
*/
|
|
77
|
-
function registerHandlers() {
|
|
78
|
-
|
|
92
|
+
function registerHandlers(srv) {
|
|
93
|
+
srv.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
79
94
|
const typedReq = req;
|
|
80
95
|
const requestId = `req_${Date.now()}`;
|
|
81
96
|
const requestLogger = logger.child({ requestId });
|
|
@@ -109,7 +124,7 @@ function registerHandlers() {
|
|
|
109
124
|
requestId,
|
|
110
125
|
timestamp: new Date(),
|
|
111
126
|
};
|
|
112
|
-
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);
|
|
113
128
|
requestLogger.info(`Executing tool: ${name}`);
|
|
114
129
|
const toolStart = Date.now();
|
|
115
130
|
const result = await tool.handler(validatedInput, context, progressCallback);
|
|
@@ -130,11 +145,33 @@ function registerHandlers() {
|
|
|
130
145
|
return createErrorResponse(mcpError, typedReq.params.name);
|
|
131
146
|
}
|
|
132
147
|
});
|
|
133
|
-
|
|
148
|
+
srv.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
134
149
|
const tools = getTools();
|
|
135
150
|
logger.info('Tools list requested', { toolCount: tools.length });
|
|
136
151
|
return { tools };
|
|
137
152
|
});
|
|
153
|
+
// Resources (epic pglam): browse projects/environments/executions as
|
|
154
|
+
// addressable read URIs. Reads dispatch to the same entity handlers as the
|
|
155
|
+
// tools, so data + auth stay consistent.
|
|
156
|
+
srv.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
157
|
+
logger.info('Resources list requested', { resourceCount: RESOURCE_COLLECTIONS.length });
|
|
158
|
+
return { resources: RESOURCE_COLLECTIONS };
|
|
159
|
+
});
|
|
160
|
+
srv.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
|
161
|
+
return { resourceTemplates: RESOURCE_TEMPLATES };
|
|
162
|
+
});
|
|
163
|
+
srv.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
164
|
+
const uri = req.params?.uri;
|
|
165
|
+
logger.info('Resource read requested', { uri });
|
|
166
|
+
try {
|
|
167
|
+
return await readResource(uri);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const mcpError = toMCPError(error, 'resource read');
|
|
171
|
+
logger.error('Resource read failed', { uri, errorCode: mcpError.code, message: mcpError.message });
|
|
172
|
+
throw mcpError;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
138
175
|
}
|
|
139
176
|
/**
|
|
140
177
|
* Main server initialization and startup
|
|
@@ -144,19 +181,18 @@ async function main() {
|
|
|
144
181
|
// Initialize logger and server here (not at module load time) so config
|
|
145
182
|
// validation errors are caught by this try-catch instead of crashing.
|
|
146
183
|
logger = new Logger({ module: 'main' });
|
|
147
|
-
|
|
148
|
-
// Register request handlers (they reference the `server` variable)
|
|
149
|
-
registerHandlers();
|
|
184
|
+
const transportMode = (process.env.DEBUGGAI_MCP_TRANSPORT || 'stdio').toLowerCase();
|
|
150
185
|
logger.info('Starting DebuggAI MCP Server', {
|
|
151
186
|
nodeVersion: process.version,
|
|
152
187
|
platform: process.platform,
|
|
153
188
|
architecture: process.arch,
|
|
154
|
-
pid: process.pid
|
|
189
|
+
pid: process.pid,
|
|
190
|
+
transport: transportMode,
|
|
155
191
|
});
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
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) {
|
|
160
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.');
|
|
161
197
|
}
|
|
162
198
|
// Initialize telemetry: PostHog by default (public project key embedded
|
|
@@ -180,12 +216,28 @@ async function main() {
|
|
|
180
216
|
// No API calls at boot. Project context is resolved lazily on first tool
|
|
181
217
|
// invocation (list_environments / list_credentials / check_app_in_browser).
|
|
182
218
|
initTools(null);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
}
|
|
189
241
|
}
|
|
190
242
|
catch (error) {
|
|
191
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
|
+
}
|