@mcpmake/core 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer/goal-crawler.d.ts.map +1 -1
- package/dist/analyzer/goal-crawler.js +6 -12
- package/dist/analyzer/goal-crawler.js.map +1 -1
- package/dist/analyzer/same-origin.d.ts +21 -0
- package/dist/analyzer/same-origin.d.ts.map +1 -0
- package/dist/analyzer/same-origin.js +32 -0
- package/dist/analyzer/same-origin.js.map +1 -0
- package/dist/analyzer/semantic-analyzer.d.ts.map +1 -1
- package/dist/analyzer/semantic-analyzer.js +3 -0
- package/dist/analyzer/semantic-analyzer.js.map +1 -1
- package/dist/analyzer/site-crawler.d.ts.map +1 -1
- package/dist/analyzer/site-crawler.js +12 -13
- package/dist/analyzer/site-crawler.js.map +1 -1
- package/dist/emitter/index.d.ts.map +1 -1
- package/dist/emitter/index.js +149 -19
- package/dist/emitter/index.js.map +1 -1
- package/dist/emitter/mcpb-bundler.d.ts +14 -0
- package/dist/emitter/mcpb-bundler.d.ts.map +1 -1
- package/dist/emitter/mcpb-bundler.js +52 -4
- package/dist/emitter/mcpb-bundler.js.map +1 -1
- package/dist/emitter/project-scaffolder.d.ts +5 -0
- package/dist/emitter/project-scaffolder.d.ts.map +1 -1
- package/dist/emitter/project-scaffolder.js +1 -1
- package/dist/emitter/project-scaffolder.js.map +1 -1
- package/dist/emitter/python-annotations.d.ts +77 -0
- package/dist/emitter/python-annotations.d.ts.map +1 -0
- package/dist/emitter/python-annotations.js +330 -0
- package/dist/emitter/python-annotations.js.map +1 -0
- package/dist/emitter/python-template-loader.js +1 -1
- package/dist/emitter/python-template-loader.js.map +1 -1
- package/dist/emitter/python-templates/server.py.hbs +69 -33
- package/dist/emitter/site-templates/browser-manager.ts.hbs +145 -32
- package/dist/emitter/site-templates/config.ts.hbs +21 -4
- package/dist/emitter/site-templates/env.example.hbs +8 -0
- package/dist/emitter/site-templates/server-main-http.ts.hbs +13 -4
- package/dist/emitter/site-templates/server-main.ts.hbs +5 -2
- package/dist/emitter/site-templates/telemetry.ts.hbs +13 -2
- package/dist/emitter/templates/config.ts.hbs +29 -2
- package/dist/emitter/templates/discovery.ts.hbs +66 -13
- package/dist/emitter/templates/http-executor.ts.hbs +4 -3
- package/dist/emitter/templates/oauth.ts.hbs +1 -2
- package/dist/emitter/templates/readme.md.hbs +3 -2
- package/dist/emitter/templates/resources.ts.hbs +3 -2
- package/dist/emitter/templates/server-main-http.ts.hbs +218 -130
- package/dist/emitter/templates/task-handlers.ts.hbs +2 -1
- package/dist/emitter/templates/task-manager.ts.hbs +5 -3
- package/dist/emitter/templates/task-sse.ts.hbs +9 -1
- package/dist/emitter/templates/tool-handler.ts.hbs +17 -4
- package/dist/emitter/worker-templates/config.ts.hbs +25 -2
- package/dist/emitter/worker-templates/worker.ts.hbs +6 -2
- package/dist/emitter/worker-templates/wrangler.toml.hbs +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/parser/har-normalizer.d.ts.map +1 -1
- package/dist/parser/har-normalizer.js +28 -3
- package/dist/parser/har-normalizer.js.map +1 -1
- package/dist/parser/openapi-loader.d.ts +19 -0
- package/dist/parser/openapi-loader.d.ts.map +1 -1
- package/dist/parser/openapi-loader.js +78 -10
- package/dist/parser/openapi-loader.js.map +1 -1
- package/dist/parser/operation-extractor.js +4 -4
- package/dist/parser/operation-extractor.js.map +1 -1
- package/dist/parser/overlay-loader.js +50 -11
- package/dist/parser/overlay-loader.js.map +1 -1
- package/dist/parser/postman-loader.d.ts.map +1 -1
- package/dist/parser/postman-loader.js +64 -6
- package/dist/parser/postman-loader.js.map +1 -1
- package/dist/parser/schema-converter.d.ts +15 -1
- package/dist/parser/schema-converter.d.ts.map +1 -1
- package/dist/parser/schema-converter.js +30 -2
- package/dist/parser/schema-converter.js.map +1 -1
- package/dist/recorder/browser-recorder.d.ts.map +1 -1
- package/dist/recorder/browser-recorder.js +2 -1
- package/dist/recorder/browser-recorder.js.map +1 -1
- package/dist/rescan/diff-engine.js +17 -10
- package/dist/rescan/diff-engine.js.map +1 -1
- package/dist/rescan/rescan-runner.js +4 -4
- package/dist/rescan/rescan-runner.js.map +1 -1
- package/dist/rescan/rescan-scheduler.d.ts +54 -2
- package/dist/rescan/rescan-scheduler.d.ts.map +1 -1
- package/dist/rescan/rescan-scheduler.js +193 -19
- package/dist/rescan/rescan-scheduler.js.map +1 -1
- package/dist/site-transformer/selector-healer.d.ts.map +1 -1
- package/dist/site-transformer/selector-healer.js +9 -0
- package/dist/site-transformer/selector-healer.js.map +1 -1
- package/dist/site-transformer/tool-generator.js +4 -7
- package/dist/site-transformer/tool-generator.js.map +1 -1
- package/dist/transformer/auth-detector.d.ts.map +1 -1
- package/dist/transformer/auth-detector.js +49 -1
- package/dist/transformer/auth-detector.js.map +1 -1
- package/dist/transformer/catalog-builder.d.ts +25 -3
- package/dist/transformer/catalog-builder.d.ts.map +1 -1
- package/dist/transformer/catalog-builder.js +32 -12
- package/dist/transformer/catalog-builder.js.map +1 -1
- package/dist/transformer/client-compat.d.ts.map +1 -1
- package/dist/transformer/client-compat.js +17 -6
- package/dist/transformer/client-compat.js.map +1 -1
- package/dist/transformer/har-dedup.js +2 -2
- package/dist/transformer/har-dedup.js.map +1 -1
- package/dist/transformer/har-to-operations.d.ts.map +1 -1
- package/dist/transformer/har-to-operations.js +10 -3
- package/dist/transformer/har-to-operations.js.map +1 -1
- package/dist/transformer/llm-namer.d.ts.map +1 -1
- package/dist/transformer/llm-namer.js +3 -0
- package/dist/transformer/llm-namer.js.map +1 -1
- package/dist/transformer/resource-builder.d.ts.map +1 -1
- package/dist/transformer/resource-builder.js +112 -18
- package/dist/transformer/resource-builder.js.map +1 -1
- package/dist/transformer/stainless-translator.d.ts.map +1 -1
- package/dist/transformer/stainless-translator.js +5 -2
- package/dist/transformer/stainless-translator.js.map +1 -1
- package/dist/transformer/tool-builder.d.ts.map +1 -1
- package/dist/transformer/tool-builder.js +67 -16
- package/dist/transformer/tool-builder.js.map +1 -1
- package/dist/types/index.d.ts +39 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/logger.d.ts +38 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +75 -1
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/playwright-loader.d.ts +21 -0
- package/dist/utils/playwright-loader.d.ts.map +1 -0
- package/dist/utils/playwright-loader.js +21 -0
- package/dist/utils/playwright-loader.js.map +1 -0
- package/dist/utils/ssrf-guard.d.ts.map +1 -1
- package/dist/utils/ssrf-guard.js +8 -0
- package/dist/utils/ssrf-guard.js.map +1 -1
- package/package.json +4 -3
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
import os
|
|
4
4
|
import json
|
|
5
5
|
import httpx
|
|
6
|
+
from typing import Annotated, Literal, Optional, Any
|
|
6
7
|
from urllib.parse import quote
|
|
7
8
|
from dotenv import load_dotenv
|
|
8
|
-
from
|
|
9
|
-
from mcp.server.
|
|
9
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
10
11
|
from mcp.types import TextContent
|
|
11
12
|
|
|
12
13
|
load_dotenv()
|
|
@@ -37,12 +38,20 @@ API_KEY = os.environ.get("{{envVarName}}", "")
|
|
|
37
38
|
{{/if}}
|
|
38
39
|
{{/each}}
|
|
39
40
|
|
|
40
|
-
server =
|
|
41
|
+
server = FastMCP("{{serverName}}")
|
|
41
42
|
|
|
42
43
|
# Hard per-request deadline (seconds) and response-size cap (bytes). A stalled or
|
|
43
44
|
# runaway upstream otherwise keeps tool calls alive indefinitely or exhausts memory.
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
try:
|
|
46
|
+
_timeout_ms = float(os.environ.get("MCP_REQUEST_TIMEOUT_MS", "30000"))
|
|
47
|
+
_REQUEST_TIMEOUT_S = _timeout_ms / 1000.0 if _timeout_ms > 0 else 30.0
|
|
48
|
+
except (ValueError, TypeError):
|
|
49
|
+
_REQUEST_TIMEOUT_S = 30.0
|
|
50
|
+
try:
|
|
51
|
+
_max_bytes = int(os.environ.get("MCP_MAX_RESPONSE_BYTES", str(10 * 1024 * 1024)))
|
|
52
|
+
_MAX_RESPONSE_BYTES = _max_bytes if _max_bytes > 0 else 10 * 1024 * 1024
|
|
53
|
+
except (ValueError, TypeError):
|
|
54
|
+
_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
|
|
46
55
|
client = httpx.AsyncClient(timeout=_REQUEST_TIMEOUT_S)
|
|
47
56
|
|
|
48
57
|
|
|
@@ -84,9 +93,17 @@ def _auth_query_params() -> dict[str, str]:
|
|
|
84
93
|
return params
|
|
85
94
|
|
|
86
95
|
|
|
96
|
+
{{#if pydanticModels}}
|
|
97
|
+
# Request-body models (A4-H2). Each plain-object request body is modelled as a
|
|
98
|
+
# Pydantic BaseModel so FastMCP infers a full-fidelity nested-object input schema
|
|
99
|
+
# (typed properties, required set, descriptions). Fields whose wire name is not a
|
|
100
|
+
# valid Python identifier use Field(alias=...) + populate_by_name; the handler
|
|
101
|
+
# serializes with by_alias=True to restore the original key on the wire.
|
|
102
|
+
{{{pydanticModels}}}
|
|
103
|
+
{{/if}}
|
|
87
104
|
{{#each tools}}
|
|
88
|
-
@server.tool()
|
|
89
|
-
async def {{functionName}}({{
|
|
105
|
+
@server.tool(name="{{pyStr name}}")
|
|
106
|
+
async def {{functionName}}({{{pySignature}}}) -> list[TextContent]:
|
|
90
107
|
"""{{pyDocstring description}}"""
|
|
91
108
|
url = BASE_URL + "{{pyStr pathTemplate}}"
|
|
92
109
|
{{#each pyPathParams}}
|
|
@@ -94,7 +111,8 @@ async def {{functionName}}({{#each pyPathParams}}{{pyName}}: str, {{/each}}{{#ea
|
|
|
94
111
|
{{/each}}
|
|
95
112
|
{{#if pyQueryParams.length}}
|
|
96
113
|
params = { {{#each pyQueryParams}}"{{pyStr apiName}}": {{pyName}}, {{/each}} }
|
|
97
|
-
|
|
114
|
+
# Drop only unset (None) optionals — a legitimate 0/false/"" must still be sent.
|
|
115
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
98
116
|
{{#if @root.hasQueryApiKey}}
|
|
99
117
|
params.update(_auth_query_params())
|
|
100
118
|
{{/if}}
|
|
@@ -108,13 +126,13 @@ async def {{functionName}}({{#each pyPathParams}}{{pyName}}: str, {{/each}}{{#ea
|
|
|
108
126
|
# names, merged over the auth headers. Empty values are omitted.
|
|
109
127
|
req_headers = dict(_headers())
|
|
110
128
|
{{#each pyHeaderParams}}
|
|
111
|
-
if {{pyName}}:
|
|
112
|
-
req_headers["{{pyStr wireName}}"] = {{pyName}}
|
|
129
|
+
if {{pyName}} is not None:
|
|
130
|
+
req_headers["{{pyStr wireName}}"] = str({{pyName}})
|
|
113
131
|
{{/each}}
|
|
114
132
|
{{#if pyCookieParams.length}}
|
|
115
133
|
_cookie_parts = []
|
|
116
134
|
{{#each pyCookieParams}}
|
|
117
|
-
if {{pyName}}:
|
|
135
|
+
if {{pyName}} is not None:
|
|
118
136
|
_cookie_parts.append("{{pyStr wireName}}=" + quote(str({{pyName}}), safe=""))
|
|
119
137
|
{{/each}}
|
|
120
138
|
if _cookie_parts:
|
|
@@ -123,45 +141,63 @@ async def {{functionName}}({{#each pyPathParams}}{{pyName}}: str, {{/each}}{{#ea
|
|
|
123
141
|
req_headers["Cookie"] = (_existing + "; " + _joined) if _existing else _joined
|
|
124
142
|
{{/if}}
|
|
125
143
|
{{/if}}
|
|
144
|
+
{{#if hasRequestBody}}
|
|
145
|
+
{{#if bodyIsModel}}
|
|
146
|
+
# Pydantic body → plain dict for httpx. by_alias restores any non-identifier
|
|
147
|
+
# wire keys; exclude_none drops unset optionals so they are omitted, not nulled.
|
|
148
|
+
_body = body.model_dump(exclude_none=True, by_alias=True) if body is not None else None
|
|
149
|
+
{{else}}
|
|
150
|
+
# Scalar / array / free-form body kept as the raw typed value.
|
|
151
|
+
_body = body
|
|
152
|
+
{{/if}}
|
|
153
|
+
{{/if}}
|
|
126
154
|
try:
|
|
127
|
-
|
|
155
|
+
async with client.stream(
|
|
128
156
|
"{{method}}",
|
|
129
157
|
url,
|
|
130
158
|
headers={{#if (or pyHeaderParams.length pyCookieParams.length)}}req_headers{{else}}_headers(){{/if}},
|
|
131
|
-
{{#if (or
|
|
159
|
+
{{#if (or pyQueryParams.length @root.hasQueryApiKey)}}
|
|
132
160
|
params=params,
|
|
133
161
|
{{/if}}
|
|
134
162
|
{{#if hasRequestBody}}
|
|
135
163
|
{{#if (eq bodyEncoding "form")}}
|
|
136
|
-
data=
|
|
164
|
+
data=_body,
|
|
137
165
|
{{else}}
|
|
138
166
|
{{#if (eq bodyEncoding "multipart")}}
|
|
139
|
-
files=
|
|
167
|
+
files=_body,
|
|
140
168
|
{{else}}
|
|
141
|
-
json=
|
|
169
|
+
json=_body,
|
|
142
170
|
{{/if}}
|
|
143
171
|
{{/if}}
|
|
144
172
|
{{/if}}
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
173
|
+
) as resp:
|
|
174
|
+
# Never echo upstream 4xx/5xx bodies to the MCP caller — they can leak
|
|
175
|
+
# internal/upstream detail. Surface only the status code + generic reason.
|
|
176
|
+
if resp.status_code >= 400:
|
|
177
|
+
return [TextContent(type="text", text=f"Error: upstream returned {resp.status_code} {resp.reason_phrase}".rstrip())]
|
|
178
|
+
# Read with a hard cap so a runaway upstream cannot exhaust memory.
|
|
179
|
+
# Streaming means we abort mid-transfer — the full body is never buffered.
|
|
180
|
+
chunks: list[bytes] = []
|
|
181
|
+
total = 0
|
|
182
|
+
async for chunk in resp.aiter_bytes():
|
|
183
|
+
total += len(chunk)
|
|
184
|
+
if total > _MAX_RESPONSE_BYTES:
|
|
185
|
+
return [TextContent(type="text", text=f"Error: response exceeded {_MAX_RESPONSE_BYTES}-byte cap")]
|
|
186
|
+
chunks.append(chunk)
|
|
187
|
+
raw = b"".join(chunks)
|
|
188
|
+
content_type = resp.headers.get("content-type", "")
|
|
189
|
+
if content_type.startswith("application/json"):
|
|
190
|
+
try:
|
|
191
|
+
data = json.loads(raw)
|
|
192
|
+
except ValueError:
|
|
193
|
+
data = raw.decode("utf-8", "replace")
|
|
194
|
+
else:
|
|
195
|
+
data = raw.decode("utf-8", "replace")
|
|
196
|
+
return [TextContent(type="text", text=json.dumps(data, indent=2) if isinstance(data, (dict, list)) else str(data))]
|
|
155
197
|
except Exception as e:
|
|
156
198
|
return [TextContent(type="text", text=f"Error: {e}")]
|
|
157
199
|
|
|
158
200
|
|
|
159
201
|
{{/each}}
|
|
160
|
-
async def main():
|
|
161
|
-
async with stdio_server() as (read_stream, write_stream):
|
|
162
|
-
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
163
|
-
|
|
164
|
-
|
|
165
202
|
if __name__ == "__main__":
|
|
166
|
-
|
|
167
|
-
asyncio.run(main())
|
|
203
|
+
server.run()
|
|
@@ -28,6 +28,10 @@ const sessionCreations = new Map<string, Promise<{ page: Page; sessionId: string
|
|
|
28
28
|
// Single-flight the browser launch so concurrent cold starts can't spawn
|
|
29
29
|
// multiple Chromium processes.
|
|
30
30
|
let browserLaunch: Promise<Browser> | undefined;
|
|
31
|
+
// Incremented whenever closeBrowser() begins. A launch captures the current
|
|
32
|
+
// generation and must close itself instead of becoming active if teardown
|
|
33
|
+
// invalidated it while chromium.launch() was still pending.
|
|
34
|
+
let browserGeneration = 0;
|
|
31
35
|
|
|
32
36
|
const DEFAULT_SESSION = '__default__';
|
|
33
37
|
|
|
@@ -99,13 +103,35 @@ async function getBrowser(): Promise<Browser> {
|
|
|
99
103
|
|
|
100
104
|
// Only disable sandbox when running as root in a container.
|
|
101
105
|
const isRoot = process.getuid?.() === 0;
|
|
106
|
+
const generation = browserGeneration;
|
|
102
107
|
browserLaunch = chromium
|
|
103
108
|
.launch({
|
|
104
109
|
headless: config.headless,
|
|
105
110
|
args: isRoot ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
|
106
111
|
})
|
|
107
|
-
.then((b) => {
|
|
112
|
+
.then(async (b) => {
|
|
113
|
+
// closeBrowser() may have run while Chromium was launching. Never publish
|
|
114
|
+
// that stale browser into manager state: close it and fail the waiting
|
|
115
|
+
// creation so no resident process escapes the pool/cap.
|
|
116
|
+
if (generation !== browserGeneration) {
|
|
117
|
+
await b.close().catch(() => {});
|
|
118
|
+
throw new Error('Browser launch aborted: browser manager shut down');
|
|
119
|
+
}
|
|
108
120
|
browser = b;
|
|
121
|
+
// If the whole browser process dies, every session's context is dead.
|
|
122
|
+
// Reclaim them all: emit one session_end each (skipping reserved slots
|
|
123
|
+
// that never started) and clear the pool + idle timer so occupancy frees
|
|
124
|
+
// promptly. No-ops for our own browser.close() (the pool is already empty
|
|
125
|
+
// and `browser` already nulled by then).
|
|
126
|
+
b.on('disconnected', () => {
|
|
127
|
+
if (browser !== b) return;
|
|
128
|
+
browser = undefined;
|
|
129
|
+
clearIdleTimer();
|
|
130
|
+
for (const [id, session] of [...sessions]) {
|
|
131
|
+
sessions.delete(id);
|
|
132
|
+
if (session.context) telemetry.sessionEnded(id, 'crash');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
109
135
|
return b;
|
|
110
136
|
})
|
|
111
137
|
.finally(() => {
|
|
@@ -125,20 +151,24 @@ async function getBrowser(): Promise<Browser> {
|
|
|
125
151
|
export async function getOrCreateSession(sessionId?: string): Promise<{ page: Page; sessionId: string }> {
|
|
126
152
|
const id = sessionId ?? DEFAULT_SESSION;
|
|
127
153
|
|
|
128
|
-
//
|
|
154
|
+
// Join an in-flight creation for this id FIRST. createSession reserves its slot
|
|
155
|
+
// synchronously (before its first await), so a concurrent second call that
|
|
156
|
+
// checked `sessions` first would observe that placeholder and return its
|
|
157
|
+
// not-yet-assigned (undefined) page. The in-flight promise resolves to the
|
|
158
|
+
// real page, so coalescing here gives every concurrent caller a usable page
|
|
159
|
+
// and a single shared context.
|
|
160
|
+
const inFlight = sessionCreations.get(id);
|
|
161
|
+
if (inFlight) return inFlight;
|
|
162
|
+
|
|
163
|
+
// Reuse a fully-created session (a placeholder reservation has no context yet
|
|
164
|
+
// and is always covered by the in-flight check above).
|
|
129
165
|
const existing = sessions.get(id);
|
|
130
|
-
if (existing) {
|
|
166
|
+
if (existing && existing.context) {
|
|
131
167
|
existing.lastAccessedAt = Date.now();
|
|
132
|
-
|
|
168
|
+
ensureIdleTimer();
|
|
133
169
|
return { page: existing.page, sessionId: id };
|
|
134
170
|
}
|
|
135
171
|
|
|
136
|
-
// Coalesce concurrent creations for the same id (notably the default
|
|
137
|
-
// session) onto one promise so we don't create duplicate contexts or
|
|
138
|
-
// double-count capacity.
|
|
139
|
-
const inFlight = sessionCreations.get(id);
|
|
140
|
-
if (inFlight) return inFlight;
|
|
141
|
-
|
|
142
172
|
const creation = createSession(id).finally(() => {
|
|
143
173
|
sessionCreations.delete(id);
|
|
144
174
|
});
|
|
@@ -153,8 +183,8 @@ async function createSession(id: string): Promise<{ page: Page; sessionId: strin
|
|
|
153
183
|
const maxSessions = config.maxSessions ?? 10;
|
|
154
184
|
if (sessions.size >= maxSessions) {
|
|
155
185
|
throw new Error(
|
|
156
|
-
`
|
|
157
|
-
'Close an existing session
|
|
186
|
+
`Browser session limit reached (${maxSessions}). ` +
|
|
187
|
+
'Close an existing session and retry.'
|
|
158
188
|
);
|
|
159
189
|
}
|
|
160
190
|
const reserved: BrowserSession = {
|
|
@@ -164,28 +194,61 @@ async function createSession(id: string): Promise<{ page: Page; sessionId: strin
|
|
|
164
194
|
};
|
|
165
195
|
sessions.set(id, reserved);
|
|
166
196
|
|
|
197
|
+
let context: BrowserContext | undefined;
|
|
167
198
|
try {
|
|
168
199
|
const activeBrowser = await getBrowser();
|
|
169
|
-
|
|
200
|
+
context = await activeBrowser.newContext({
|
|
170
201
|
viewport: config.viewport,
|
|
171
202
|
...(config.userAgent ? { userAgent: config.userAgent } : {}),
|
|
172
203
|
});
|
|
173
204
|
const page = await context.newPage();
|
|
174
205
|
|
|
206
|
+
// If the manager was torn down (shutdown/disconnect) while we awaited the
|
|
207
|
+
// browser/context/page, our reservation was cleared. Do NOT resurrect it:
|
|
208
|
+
// bail so the catch below closes this fresh context. Otherwise we would
|
|
209
|
+
// orphan a live Chromium context outside the pool — escaping the cap, never
|
|
210
|
+
// reaped, and emitting a session_start AFTER its session_end. There is no
|
|
211
|
+
// await between here and `return`, so once this check passes the rest of
|
|
212
|
+
// creation is atomic with respect to teardown.
|
|
213
|
+
if (sessions.get(id) !== reserved) {
|
|
214
|
+
throw new Error('Session creation aborted: browser manager shut down');
|
|
215
|
+
}
|
|
216
|
+
|
|
175
217
|
reserved.context = context;
|
|
176
218
|
reserved.page = page;
|
|
177
219
|
reserved.lastAccessedAt = Date.now();
|
|
178
220
|
|
|
179
|
-
|
|
221
|
+
ensureIdleTimer();
|
|
180
222
|
|
|
181
223
|
// The browser context is now resident — start billing this session (no-op
|
|
182
224
|
// unless the host injected telemetry env).
|
|
183
225
|
telemetry.sessionStarted(id);
|
|
184
226
|
|
|
227
|
+
// Reclaim the slot exactly once if this session dies on its own — a Chromium
|
|
228
|
+
// context close, a renderer (page) crash, or an external close. Every
|
|
229
|
+
// intentional teardown path deletes the map entry BEFORE closing, so the
|
|
230
|
+
// `=== reserved` guard makes this a no-op for those and lets it fire only on
|
|
231
|
+
// an unexpected death, freeing host occupancy promptly instead of waiting
|
|
232
|
+
// out the idle grace. The guard also dedupes a page crash that then closes
|
|
233
|
+
// the context (only the first reclaim wins).
|
|
234
|
+
const reclaim = (reason: string): void => {
|
|
235
|
+
if (sessions.get(id) !== reserved) return;
|
|
236
|
+
sessions.delete(id);
|
|
237
|
+
context?.close().catch(() => {});
|
|
238
|
+
telemetry.sessionEnded(id, reason);
|
|
239
|
+
if (sessions.size === 0) void closeBrowser();
|
|
240
|
+
};
|
|
241
|
+
context.on('close', () => reclaim('crash'));
|
|
242
|
+
page.on('crash', () => reclaim('crash'));
|
|
243
|
+
|
|
185
244
|
return { page, sessionId: id };
|
|
186
245
|
} catch (err) {
|
|
187
|
-
// Release the
|
|
188
|
-
|
|
246
|
+
// Release the slot AND close any context we created, so a failed or aborted
|
|
247
|
+
// creation never leaks a live Chromium context or pins the cap slot. Only
|
|
248
|
+
// delete the entry if it is still ours (a concurrent shutdown may have
|
|
249
|
+
// already cleared it).
|
|
250
|
+
if (sessions.get(id) === reserved) sessions.delete(id);
|
|
251
|
+
await context?.close().catch(() => {});
|
|
189
252
|
throw err;
|
|
190
253
|
}
|
|
191
254
|
}
|
|
@@ -198,10 +261,18 @@ export async function closeSession(sessionId?: string): Promise<void> {
|
|
|
198
261
|
const session = sessions.get(id);
|
|
199
262
|
if (!session) return;
|
|
200
263
|
|
|
201
|
-
|
|
264
|
+
// Claim the teardown AND emit session_end SYNCHRONOUSLY, before the first
|
|
265
|
+
// await. Deleting the map entry and removing the telemetry token together,
|
|
266
|
+
// up front, means: (a) a concurrent idle sweep or second close sees the slot
|
|
267
|
+
// gone and skips it; and (b) a same-id session re-created during the
|
|
268
|
+
// context-close await below gets its OWN fresh telemetry token instead of
|
|
269
|
+
// sharing this one (which would suppress its later session_end). Exactly one
|
|
270
|
+
// session_end per teardown.
|
|
202
271
|
sessions.delete(id);
|
|
203
272
|
telemetry.sessionEnded(id, 'explicit');
|
|
204
273
|
|
|
274
|
+
await session.context?.close().catch(() => {});
|
|
275
|
+
|
|
205
276
|
// If no sessions remain, close the browser
|
|
206
277
|
if (sessions.size === 0) {
|
|
207
278
|
await closeBrowser();
|
|
@@ -213,17 +284,42 @@ export async function closeSession(sessionId?: string): Promise<void> {
|
|
|
213
284
|
*/
|
|
214
285
|
export async function closeBrowser(): Promise<void> {
|
|
215
286
|
clearIdleTimer();
|
|
216
|
-
|
|
217
|
-
|
|
287
|
+
// Invalidate a chromium.launch() already in progress. Its completion handler
|
|
288
|
+
// will close the resulting process rather than assigning it to `browser`.
|
|
289
|
+
browserGeneration++;
|
|
290
|
+
const launching = browserLaunch;
|
|
291
|
+
|
|
292
|
+
// Null `browser` synchronously BEFORE awaiting its close. While close() is in
|
|
293
|
+
// flight the instance still reports isConnected() === true, so without this a
|
|
294
|
+
// concurrent getOrCreateSession would be handed the dying browser and build a
|
|
295
|
+
// context on it that dies immediately. Nulling up front forces a relaunch
|
|
296
|
+
// instead, and single-flights closeBrowser (a second concurrent call sees no
|
|
297
|
+
// browser and skips the redundant close()).
|
|
298
|
+
const dying = browser;
|
|
299
|
+
browser = undefined;
|
|
300
|
+
|
|
301
|
+
// Snapshot and clear the pool, then emit every session_end SYNCHRONOUSLY
|
|
302
|
+
// (before any await) so a same-id session re-created during the context-close
|
|
303
|
+
// awaits gets its own telemetry token rather than sharing a snapshotted one.
|
|
304
|
+
// Reserved-but-unstarted slots are skipped: no context to close, and no
|
|
305
|
+
// session_start to balance (the in-flight createSession aborts itself once it
|
|
306
|
+
// sees its reservation cleared).
|
|
307
|
+
const entries = [...sessions];
|
|
308
|
+
sessions.clear();
|
|
309
|
+
for (const [id, session] of entries) {
|
|
310
|
+
if (session.context) telemetry.sessionEnded(id, 'shutdown');
|
|
311
|
+
}
|
|
312
|
+
for (const [, session] of entries) {
|
|
218
313
|
await session.context?.close().catch(() => {});
|
|
219
|
-
sessions.delete(id);
|
|
220
|
-
telemetry.sessionEnded(id, 'shutdown');
|
|
221
314
|
}
|
|
222
315
|
|
|
223
|
-
if (
|
|
224
|
-
await
|
|
225
|
-
browser = undefined;
|
|
316
|
+
if (dying) {
|
|
317
|
+
await dying.close().catch(() => {});
|
|
226
318
|
}
|
|
319
|
+
// Wait for an invalidated launch to finish its self-cleanup. This makes
|
|
320
|
+
// closeBrowser() a real lifecycle barrier: after it resolves, no Chromium
|
|
321
|
+
// process from work that began before teardown can still become resident.
|
|
322
|
+
await launching?.catch(() => {});
|
|
227
323
|
}
|
|
228
324
|
|
|
229
325
|
/**
|
|
@@ -235,7 +331,9 @@ export async function takeScreenshot(
|
|
|
235
331
|
): Promise<Buffer> {
|
|
236
332
|
const id = sessionId ?? DEFAULT_SESSION;
|
|
237
333
|
const session = sessions.get(id);
|
|
238
|
-
|
|
334
|
+
// A reserved, mid-creation slot is in the map but has no page yet — treat it
|
|
335
|
+
// as not-yet-active so callers get the clear error, not a TypeError.
|
|
336
|
+
if (!session || !session.context) {
|
|
239
337
|
throw new Error(`No active session: ${id}`);
|
|
240
338
|
}
|
|
241
339
|
session.lastAccessedAt = Date.now();
|
|
@@ -243,10 +341,13 @@ export async function takeScreenshot(
|
|
|
243
341
|
}
|
|
244
342
|
|
|
245
343
|
/**
|
|
246
|
-
* List active session IDs.
|
|
344
|
+
* List active session IDs. Excludes reserved, mid-creation slots (no page yet)
|
|
345
|
+
* so a returned id is always usable with takeScreenshot/tool calls.
|
|
247
346
|
*/
|
|
248
347
|
export function listSessions(): string[] {
|
|
249
|
-
return Array.from(sessions.
|
|
348
|
+
return Array.from(sessions.entries())
|
|
349
|
+
.filter(([, session]) => session.context !== undefined)
|
|
350
|
+
.map(([id]) => id);
|
|
250
351
|
}
|
|
251
352
|
|
|
252
353
|
/**
|
|
@@ -258,24 +359,36 @@ export function isBrowserRunning(): boolean {
|
|
|
258
359
|
|
|
259
360
|
// ─── Idle Timer ─────────────────────────────────────────────────────
|
|
260
361
|
|
|
261
|
-
|
|
262
|
-
|
|
362
|
+
// Start the idle reaper once and let it tick at a FIXED cadence. It must NOT be
|
|
363
|
+
// reset on session access: resetting on every access would let a busy container
|
|
364
|
+
// (one session touched within each interval) starve the reaper so it never
|
|
365
|
+
// fires, leaving genuinely-idle sibling sessions resident forever. Eviction
|
|
366
|
+
// keys off each session's own lastAccessedAt, so a steady tick is sufficient —
|
|
367
|
+
// touching a session keeps only that session alive.
|
|
368
|
+
function ensureIdleTimer(): void {
|
|
369
|
+
if (idleTimer) return;
|
|
263
370
|
idleTimer = setInterval(() => {
|
|
264
371
|
const now = Date.now();
|
|
265
372
|
// Close idle sessions. Skip slots still being created (no context yet).
|
|
266
373
|
for (const [id, session] of sessions) {
|
|
267
374
|
if (!session.context) continue;
|
|
268
375
|
if (now - session.lastAccessedAt > config.idleTimeoutMs) {
|
|
269
|
-
|
|
376
|
+
// Delete before closing so the context's own 'close' handler (and any
|
|
377
|
+
// concurrent teardown) sees the slot already claimed — session_end is
|
|
378
|
+
// emitted once, here, with reason 'idle'.
|
|
270
379
|
sessions.delete(id);
|
|
380
|
+
session.context.close().catch(() => {});
|
|
271
381
|
telemetry.sessionEnded(id, 'idle');
|
|
272
382
|
}
|
|
273
383
|
}
|
|
274
|
-
// Close browser if no sessions
|
|
384
|
+
// Close browser if no sessions. `void` so a rejected closeBrowser()
|
|
385
|
+
// (e.g. Playwright throwing during browser.close()) can't become an
|
|
386
|
+
// unhandled promise rejection in this sync interval callback.
|
|
275
387
|
if (sessions.size === 0) {
|
|
276
|
-
closeBrowser();
|
|
388
|
+
void closeBrowser();
|
|
277
389
|
}
|
|
278
390
|
}, 30_000); // Check every 30 seconds
|
|
391
|
+
idleTimer.unref?.();
|
|
279
392
|
}
|
|
280
393
|
|
|
281
394
|
function clearIdleTimer(): void {
|
|
@@ -11,18 +11,35 @@ export interface AppConfig {
|
|
|
11
11
|
browser: BrowserConfig;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const posInt = (v: string | undefined, d: number): number => {
|
|
15
|
+
const n = parseInt(v ?? '', 10);
|
|
16
|
+
return Number.isInteger(n) && n > 0 ? n : d;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Hard ceiling on concurrently-resident browser sessions for THIS container.
|
|
20
|
+
// The cloud host injects MCP_MAX_BROWSER_SESSIONS at container start to pin the
|
|
21
|
+
// owner's per-plan concurrent-browser limit, making the container's hard cap the
|
|
22
|
+
// plan cap. MAX_SESSIONS is the legacy/self-host alias. Either env var, when a
|
|
23
|
+
// valid positive integer, overrides the baked-in default; an unset or invalid
|
|
24
|
+
// value falls through to the next source. DEFAULT_MAX_SESSIONS is the safe
|
|
25
|
+
// fallback. The browser manager refuses to launch a session past this cap.
|
|
26
|
+
const DEFAULT_MAX_SESSIONS = {{#if browserConfig.maxSessions}}{{browserConfig.maxSessions}}{{else}}10{{/if}};
|
|
27
|
+
|
|
14
28
|
export function loadConfig(): AppConfig {
|
|
15
29
|
return {
|
|
16
30
|
baseUrl: process.env.BASE_URL ?? '{{{baseUrl}}}',
|
|
17
31
|
browser: {
|
|
18
32
|
headless: process.env.HEADLESS !== 'false',
|
|
19
|
-
idleTimeoutMs:
|
|
33
|
+
idleTimeoutMs: posInt(process.env.IDLE_TIMEOUT_MS, {{browserConfig.idleTimeoutMs}}),
|
|
20
34
|
viewport: {
|
|
21
|
-
width:
|
|
22
|
-
height:
|
|
35
|
+
width: posInt(process.env.VIEWPORT_WIDTH, {{browserConfig.viewport.width}}),
|
|
36
|
+
height: posInt(process.env.VIEWPORT_HEIGHT, {{browserConfig.viewport.height}}),
|
|
23
37
|
},
|
|
24
38
|
userAgent: process.env.USER_AGENT || undefined,
|
|
25
|
-
maxSessions:
|
|
39
|
+
maxSessions: posInt(
|
|
40
|
+
process.env.MCP_MAX_BROWSER_SESSIONS,
|
|
41
|
+
posInt(process.env.MAX_SESSIONS, DEFAULT_MAX_SESSIONS),
|
|
42
|
+
),
|
|
26
43
|
},
|
|
27
44
|
};
|
|
28
45
|
}
|
|
@@ -10,6 +10,14 @@ VIEWPORT_WIDTH={{browserConfig.viewport.width}}
|
|
|
10
10
|
VIEWPORT_HEIGHT={{browserConfig.viewport.height}}
|
|
11
11
|
# USER_AGENT=
|
|
12
12
|
|
|
13
|
+
# Hard cap on concurrently-resident browser sessions (Chromium contexts) this
|
|
14
|
+
# server will hold. A tool call that would open one past the cap is refused with
|
|
15
|
+
# a clear error rather than launching another browser. Managed hosting injects
|
|
16
|
+
# this to enforce the plan's concurrent-browser limit. MAX_SESSIONS is a legacy
|
|
17
|
+
# alias; MCP_MAX_BROWSER_SESSIONS wins when both are set.
|
|
18
|
+
MCP_MAX_BROWSER_SESSIONS={{#if browserConfig.maxSessions}}{{browserConfig.maxSessions}}{{else}}10{{/if}}
|
|
19
|
+
# MAX_SESSIONS={{#if browserConfig.maxSessions}}{{browserConfig.maxSessions}}{{else}}10{{/if}}
|
|
20
|
+
|
|
13
21
|
# Browser-session telemetry (managed hosting only — leave UNSET to self-host).
|
|
14
22
|
# When the host sets these, the server reports exact browser-session lifetime
|
|
15
23
|
# for metering. It NEVER phones home unless MCPMAKE_TELEMETRY_URL is set.
|
|
@@ -74,6 +74,10 @@ let isReady = false;
|
|
|
74
74
|
|
|
75
75
|
if (process.env.TRANSPORT === 'http') {
|
|
76
76
|
const port = parseInt(process.env.PORT ?? '3000', 10);
|
|
77
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
78
|
+
log('error', 'Invalid PORT value, must be 1-65535', { PORT: process.env.PORT });
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
77
81
|
const allowedOrigin = process.env.ALLOWED_ORIGIN;
|
|
78
82
|
|
|
79
83
|
const httpServer = http.createServer(async (req, res) => {
|
|
@@ -159,8 +163,10 @@ if (process.env.TRANSPORT === 'http') {
|
|
|
159
163
|
const shutdown = async () => {
|
|
160
164
|
log('info', 'Shutting down...');
|
|
161
165
|
await closeBrowser();
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
// Exit as soon as the server finishes draining; the timer is only a
|
|
167
|
+
// forced-exit backstop and is unref'd so it never holds the loop open.
|
|
168
|
+
httpServer.close(() => process.exit(0));
|
|
169
|
+
setTimeout(() => process.exit(0), 5000).unref();
|
|
164
170
|
};
|
|
165
171
|
|
|
166
172
|
process.on('SIGTERM', shutdown);
|
|
@@ -172,8 +178,11 @@ if (process.env.TRANSPORT === 'http') {
|
|
|
172
178
|
await server.connect(transport);
|
|
173
179
|
isReady = true;
|
|
174
180
|
|
|
175
|
-
|
|
181
|
+
const shutdown = async () => {
|
|
176
182
|
await closeBrowser();
|
|
177
183
|
process.exit(0);
|
|
178
|
-
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
process.on('SIGTERM', shutdown);
|
|
187
|
+
process.on('SIGINT', shutdown);
|
|
179
188
|
}
|
|
@@ -17,7 +17,10 @@ registerAllTools(server, config);
|
|
|
17
17
|
const transport = new StdioServerTransport();
|
|
18
18
|
await server.connect(transport);
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const shutdown = async () => {
|
|
21
21
|
await closeBrowser();
|
|
22
22
|
process.exit(0);
|
|
23
|
-
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
process.on('SIGTERM', shutdown);
|
|
26
|
+
process.on('SIGINT', shutdown);
|
|
@@ -66,10 +66,16 @@ export function sessionStarted(sessionId: string): void {
|
|
|
66
66
|
ensureTimer();
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/** Record that a browser session was torn down (idle | explicit | shutdown
|
|
69
|
+
/** Record that a browser session was torn down (idle | explicit | shutdown |
|
|
70
|
+
* crash). Idempotent: emits at most one session_end per session. A second end
|
|
71
|
+
* for the same id (e.g. an explicit close that resumes after flushOnShutdown
|
|
72
|
+
* already ended it on SIGTERM, or a crash racing a teardown) is a no-op, and an
|
|
73
|
+
* end for a session that never started (a reserved-but-unstarted slot) emits
|
|
74
|
+
* nothing — so the host's occupancy ledger can never be double-decremented. */
|
|
70
75
|
export function sessionEnded(sessionId: string, reason: string): void {
|
|
71
76
|
if (!ENABLED) return;
|
|
72
|
-
openSessions.
|
|
77
|
+
// openSessions is the source of truth: only the call that removes the id emits.
|
|
78
|
+
if (!openSessions.delete(sessionId)) return;
|
|
73
79
|
pushEvent({ type: 'session_end', sessionId, ts: Date.now(), reason });
|
|
74
80
|
// Flush promptly so the host can finalize this session's billing.
|
|
75
81
|
void flush();
|
|
@@ -108,6 +114,11 @@ async function flush(): Promise<void> {
|
|
|
108
114
|
/** Best-effort final flush on shutdown: end every open session and send. */
|
|
109
115
|
export async function flushOnShutdown(reason = 'shutdown'): Promise<void> {
|
|
110
116
|
if (!ENABLED) return;
|
|
117
|
+
// Stop the heartbeat interval so no flush can fire after this final boundary.
|
|
118
|
+
if (flushTimer) {
|
|
119
|
+
clearInterval(flushTimer);
|
|
120
|
+
flushTimer = undefined;
|
|
121
|
+
}
|
|
111
122
|
const now = Date.now();
|
|
112
123
|
for (const id of [...openSessions]) {
|
|
113
124
|
openSessions.delete(id);
|