@contentrain/mcp 1.3.0 → 1.4.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.
Files changed (43) hide show
  1. package/dist/index.mjs +1 -1
  2. package/dist/providers/github/index.d.mts +60 -3
  3. package/dist/providers/github/index.d.mts.map +1 -1
  4. package/dist/providers/github/index.mjs +85 -5
  5. package/dist/providers/github/index.mjs.map +1 -1
  6. package/dist/server/http/index.d.mts +63 -21
  7. package/dist/server/http/index.d.mts.map +1 -1
  8. package/dist/server/http/index.mjs +156 -45
  9. package/dist/server/http/index.mjs.map +1 -1
  10. package/dist/{server-B1OJrUQ9.mjs → server-BkhhtEE2.mjs} +2 -2
  11. package/dist/{server-B1OJrUQ9.mjs.map → server-BkhhtEE2.mjs.map} +1 -1
  12. package/dist/server.mjs +1 -1
  13. package/dist/testing/conformance.d.mts +59 -0
  14. package/dist/testing/conformance.d.mts.map +1 -0
  15. package/dist/testing/conformance.mjs +59 -0
  16. package/dist/testing/conformance.mjs.map +1 -0
  17. package/package.json +10 -5
  18. package/testing/conformance/01-new-collection-entry/expected/.contentrain/config.json +15 -0
  19. package/testing/conformance/01-new-collection-entry/expected/.contentrain/content/blog/blog/en.json +6 -0
  20. package/testing/conformance/01-new-collection-entry/expected/.contentrain/context.json +22 -0
  21. package/testing/conformance/01-new-collection-entry/expected/.contentrain/meta/blog/en.json +7 -0
  22. package/testing/conformance/01-new-collection-entry/expected/.contentrain/models/blog.json +16 -0
  23. package/testing/conformance/01-new-collection-entry/scenario.json +19 -0
  24. package/testing/conformance/01-new-collection-entry/setup/.contentrain/config.json +15 -0
  25. package/testing/conformance/01-new-collection-entry/setup/.contentrain/models/blog.json +16 -0
  26. package/testing/conformance/02-new-singleton/expected/.contentrain/config.json +15 -0
  27. package/testing/conformance/02-new-singleton/expected/.contentrain/content/marketing/hero/en.json +4 -0
  28. package/testing/conformance/02-new-singleton/expected/.contentrain/context.json +22 -0
  29. package/testing/conformance/02-new-singleton/expected/.contentrain/meta/hero/en.json +5 -0
  30. package/testing/conformance/02-new-singleton/expected/.contentrain/models/hero.json +16 -0
  31. package/testing/conformance/02-new-singleton/scenario.json +18 -0
  32. package/testing/conformance/02-new-singleton/setup/.contentrain/config.json +15 -0
  33. package/testing/conformance/02-new-singleton/setup/.contentrain/models/hero.json +16 -0
  34. package/testing/conformance/03-new-dictionary/expected/.contentrain/config.json +15 -0
  35. package/testing/conformance/03-new-dictionary/expected/.contentrain/content/marketing/ui-strings/en.json +5 -0
  36. package/testing/conformance/03-new-dictionary/expected/.contentrain/context.json +22 -0
  37. package/testing/conformance/03-new-dictionary/expected/.contentrain/meta/ui-strings/en.json +5 -0
  38. package/testing/conformance/03-new-dictionary/expected/.contentrain/models/ui-strings.json +7 -0
  39. package/testing/conformance/03-new-dictionary/scenario.json +19 -0
  40. package/testing/conformance/03-new-dictionary/setup/.contentrain/config.json +15 -0
  41. package/testing/conformance/03-new-dictionary/setup/.contentrain/models/ui-strings.json +7 -0
  42. package/testing/conformance/README.md +77 -0
  43. package/testing/conformance/scenarios.json +36 -0
@@ -12,7 +12,7 @@ import "../../local-DvP4Y4T-.mjs";
12
12
  import "../../detect-DL9mFmaS.mjs";
13
13
  import "../../branch-lifecycle-CFVpTpNT.mjs";
14
14
  import "../../annotations-Clhbw2lh.mjs";
15
- import { t as createServer } from "../../server-B1OJrUQ9.mjs";
15
+ import { t as createServer } from "../../server-BkhhtEE2.mjs";
16
16
  import "../../validator-B0nNRFeU.mjs";
17
17
  import "../../scan-config-jC6XmiqF.mjs";
18
18
  import "../../graph-builder-DXv2JEp-.mjs";
@@ -24,26 +24,29 @@ import { randomUUID } from "node:crypto";
24
24
  import http from "node:http";
25
25
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
26
26
  //#region src/server/http/server.ts
27
+ const MCP_SESSION_HEADER = "mcp-session-id";
27
28
  /**
28
29
  * Start an HTTP transport for the MCP server on top of the local project.
29
30
  *
30
- * Phase 5.2 scope: stateless, single-endpoint `POST /mcp`. The underlying
31
- * McpServer is the same one the stdio entry point uses, so every tool
32
- * registered for stdio is available over HTTP with no duplication. Only
33
- * the transport swaps out.
31
+ * Stateless, single-endpoint `POST /mcp`. The underlying `McpServer` is
32
+ * the same one the stdio entry point uses, so every tool registered for
33
+ * stdio is available over HTTP with no duplication. Only the transport
34
+ * swaps out.
34
35
  *
35
- * Auth is a simple opaque Bearer token. When `authToken` is supplied, any
36
- * request without the exact `Authorization: Bearer <token>` header gets
37
- * a 401 before the MCP transport sees it. Multi-tenant auth (per-project
38
- * API keys, GitHub App scopes) is Studio's concern — MCP stays minimal.
39
- *
40
- * GitHubProvider HTTP routing will land in phase 5.3 together with the
41
- * tool-handler provider abstraction; today the HTTP server still drives
42
- * a LocalProvider under the hood.
36
+ * Auth is a simple opaque Bearer token. When `authToken` is supplied,
37
+ * any request without the exact `Authorization: Bearer <token>` header
38
+ * gets a 401 before the MCP transport sees it. Non-local bind addresses
39
+ * should always set this — the `contentrain serve --mcpHttp` CLI hard-
40
+ * errors when it detects a non-localhost bind without a token.
43
41
  */
44
42
  async function startHttpMcpServer(opts) {
43
+ const mcp = createServer(opts.projectRoot);
44
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
45
+ await mcp.connect(transport);
45
46
  return startHttpMcpServerInternal({
46
- mcp: createServer(opts.projectRoot),
47
+ mode: "single",
48
+ mcp,
49
+ transport,
47
50
  port: opts.port,
48
51
  host: opts.host,
49
52
  authToken: opts.authToken,
@@ -51,82 +54,190 @@ async function startHttpMcpServer(opts) {
51
54
  });
52
55
  }
53
56
  /**
54
- * Variant of {@link startHttpMcpServer} that accepts a pre-built provider
55
- * (and an optional `projectRoot`). This is the phase-5.3 entry point that
56
- * lets HTTP-hosted MCP route tool calls to a non-Local provider — e.g. a
57
- * `GitHubProvider` shared across many projects. Read-only tools work out
58
- * of the box; write-path tools return `capability_required: localWorktree`
59
- * when no `projectRoot` is supplied.
57
+ * Variant accepting a pre-built provider or a per-request resolver.
58
+ *
59
+ * **Single-provider mode** (`{ provider }`):
60
+ * All sessions share one `McpServer` backed by the given provider.
61
+ * Good for CI runners, single-tenant deployments, or any case where
62
+ * the backing repository is fixed at boot time.
63
+ *
64
+ * **Multi-tenant mode** (`{ resolveProvider }`):
65
+ * Each session resolves its own provider from the incoming request.
66
+ * The resolver is invoked once per session; subsequent requests with
67
+ * the `Mcp-Session-Id` header reuse the same server + transport pair.
68
+ * Idle sessions are closed after `sessionTtlMs` (default 15m).
60
69
  */
61
70
  async function startHttpMcpServerWith(opts) {
71
+ if ("resolveProvider" in opts) return startMultiTenantHttpMcpServer(opts);
72
+ const mcp = createServer({
73
+ provider: opts.provider,
74
+ projectRoot: opts.projectRoot
75
+ });
76
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
77
+ await mcp.connect(transport);
62
78
  return startHttpMcpServerInternal({
63
- mcp: createServer({
64
- provider: opts.provider,
65
- projectRoot: opts.projectRoot
66
- }),
79
+ mode: "single",
80
+ mcp,
81
+ transport,
67
82
  port: opts.port,
68
83
  host: opts.host,
69
84
  authToken: opts.authToken,
70
85
  path: opts.path
71
86
  });
72
87
  }
73
- async function startHttpMcpServerInternal(input) {
74
- const { mcp } = input;
75
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
76
- await mcp.connect(transport);
77
- const mountPath = input.path ?? "/mcp";
78
- const host = input.host ?? "127.0.0.1";
88
+ async function startMultiTenantHttpMcpServer(opts) {
89
+ const sessions = /* @__PURE__ */ new Map();
90
+ const ttl = opts.sessionTtlMs ?? 900 * 1e3;
91
+ const mountPath = opts.path ?? "/mcp";
92
+ const host = opts.host ?? "127.0.0.1";
93
+ async function disposeSession(sessionId) {
94
+ const session = sessions.get(sessionId);
95
+ if (!session) return;
96
+ sessions.delete(sessionId);
97
+ await session.transport.close().catch(() => {});
98
+ await session.mcp.close().catch(() => {});
99
+ }
100
+ const reaper = setInterval(() => {
101
+ const now = Date.now();
102
+ for (const [id, session] of sessions) if (now - session.lastUsed > ttl) disposeSession(id);
103
+ }, Math.min(ttl, 6e4)).unref();
79
104
  const server = http.createServer((req, res) => {
80
- handleRequest(req, res, {
81
- transport,
105
+ handleMultiTenantRequest(req, res, {
82
106
  mountPath,
83
- authToken: input.authToken
107
+ authToken: opts.authToken,
108
+ resolveProvider: opts.resolveProvider,
109
+ projectRoot: opts.projectRoot,
110
+ sessions
84
111
  });
85
112
  });
86
113
  await new Promise((resolve, reject) => {
87
114
  server.once("error", reject);
88
- server.listen(input.port, host, () => {
115
+ server.listen(opts.port, host, () => {
89
116
  server.off("error", reject);
90
117
  resolve();
91
118
  });
92
119
  });
93
120
  return {
94
121
  server,
95
- mcp,
122
+ mcp: null,
96
123
  url: `http://${host}:${server.address().port}${mountPath}`,
97
124
  close: async () => {
125
+ clearInterval(reaper);
98
126
  await new Promise((resolve, reject) => {
99
127
  server.close((err) => err ? reject(err) : resolve());
100
128
  });
101
- await transport.close().catch(() => {});
102
- await mcp.close().catch(() => {});
129
+ const ids = [...sessions.keys()];
130
+ await Promise.all(ids.map((id) => disposeSession(id)));
103
131
  }
104
132
  };
105
133
  }
106
- async function handleRequest(req, res, ctx) {
134
+ async function handleMultiTenantRequest(req, res, ctx) {
135
+ if (!applyRouteChecks(req, res, {
136
+ mountPath: ctx.mountPath,
137
+ authToken: ctx.authToken
138
+ })) return;
139
+ const existingSessionId = pickSessionId(req.headers[MCP_SESSION_HEADER]);
140
+ if (existingSessionId && ctx.sessions.has(existingSessionId)) {
141
+ const session = ctx.sessions.get(existingSessionId);
142
+ session.lastUsed = Date.now();
143
+ try {
144
+ await session.transport.handleRequest(req, res);
145
+ } catch (error) {
146
+ writeInternalError(res, error);
147
+ }
148
+ return;
149
+ }
150
+ try {
151
+ const mcp = createServer({
152
+ provider: await ctx.resolveProvider(req),
153
+ projectRoot: ctx.projectRoot
154
+ });
155
+ const transport = new StreamableHTTPServerTransport({
156
+ sessionIdGenerator: () => randomUUID(),
157
+ onsessioninitialized: (sessionId) => {
158
+ ctx.sessions.set(sessionId, {
159
+ mcp,
160
+ transport,
161
+ lastUsed: Date.now()
162
+ });
163
+ }
164
+ });
165
+ await mcp.connect(transport);
166
+ await transport.handleRequest(req, res);
167
+ } catch (error) {
168
+ writeInternalError(res, error);
169
+ }
170
+ }
171
+ function pickSessionId(header) {
172
+ if (!header) return void 0;
173
+ return Array.isArray(header) ? header[0] : header;
174
+ }
175
+ function applyRouteChecks(req, res, ctx) {
107
176
  if (req.method !== "POST" && req.method !== "GET" && req.method !== "DELETE") {
108
177
  writeJson(res, 405, { error: "Method Not Allowed" });
109
- return;
178
+ return false;
110
179
  }
111
180
  if (!req.url || !req.url.startsWith(ctx.mountPath)) {
112
181
  writeJson(res, 404, { error: "Not Found" });
113
- return;
182
+ return false;
114
183
  }
115
184
  if (ctx.authToken) {
116
185
  if (req.headers.authorization !== `Bearer ${ctx.authToken}`) {
117
186
  writeJson(res, 401, { error: "Unauthorized" });
118
- return;
187
+ return false;
119
188
  }
120
189
  }
190
+ return true;
191
+ }
192
+ async function startHttpMcpServerInternal(input) {
193
+ const { mcp, transport } = input;
194
+ const mountPath = input.path ?? "/mcp";
195
+ const host = input.host ?? "127.0.0.1";
196
+ const server = http.createServer((req, res) => {
197
+ handleRequest(req, res, {
198
+ transport,
199
+ mountPath,
200
+ authToken: input.authToken
201
+ });
202
+ });
203
+ await new Promise((resolve, reject) => {
204
+ server.once("error", reject);
205
+ server.listen(input.port, host, () => {
206
+ server.off("error", reject);
207
+ resolve();
208
+ });
209
+ });
210
+ return {
211
+ server,
212
+ mcp,
213
+ url: `http://${host}:${server.address().port}${mountPath}`,
214
+ close: async () => {
215
+ await new Promise((resolve, reject) => {
216
+ server.close((err) => err ? reject(err) : resolve());
217
+ });
218
+ await transport.close().catch(() => {});
219
+ await mcp.close().catch(() => {});
220
+ }
221
+ };
222
+ }
223
+ async function handleRequest(req, res, ctx) {
224
+ if (!applyRouteChecks(req, res, {
225
+ mountPath: ctx.mountPath,
226
+ authToken: ctx.authToken
227
+ })) return;
121
228
  try {
122
229
  await ctx.transport.handleRequest(req, res);
123
230
  } catch (error) {
124
- if (!res.headersSent) writeJson(res, 500, {
125
- error: "Internal Server Error",
126
- message: error instanceof Error ? error.message : String(error)
127
- });
231
+ writeInternalError(res, error);
128
232
  }
129
233
  }
234
+ function writeInternalError(res, error) {
235
+ if (res.headersSent) return;
236
+ writeJson(res, 500, {
237
+ error: "Internal Server Error",
238
+ message: error instanceof Error ? error.message : String(error)
239
+ });
240
+ }
130
241
  function writeJson(res, status, body) {
131
242
  res.writeHead(status, { "Content-Type": "application/json" });
132
243
  res.end(JSON.stringify(body));
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["createMcpServer"],"sources":["../../../src/server/http/server.ts"],"sourcesContent":["import http from 'node:http'\nimport { randomUUID } from 'node:crypto'\nimport type { AddressInfo } from 'node:net'\nimport type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'\nimport { createServer as createMcpServer, type ToolProvider } from '../../server.js'\n\nexport interface HttpMcpServerOptions {\n /** Project root the MCP server operates against (LocalProvider path). */\n projectRoot: string\n /** Optional Bearer token — when set, requests must send `Authorization: Bearer <token>`. */\n authToken?: string\n /** Mount path for the MCP JSON-RPC endpoint. Defaults to `/mcp`. */\n path?: string\n}\n\nexport interface HttpMcpServerProviderOptions {\n /** Pre-built content provider (GitHubProvider, a mock, etc.). */\n provider: ToolProvider\n /** Local project root when the provider is a LocalProvider; optional otherwise. */\n projectRoot?: string\n /** Optional Bearer token — when set, requests must send `Authorization: Bearer <token>`. */\n authToken?: string\n /** Mount path for the MCP JSON-RPC endpoint. Defaults to `/mcp`. */\n path?: string\n}\n\nexport interface HttpMcpServerHandle {\n server: http.Server\n mcp: McpServer\n url: string\n close: () => Promise<void>\n}\n\n/**\n * Start an HTTP transport for the MCP server on top of the local project.\n *\n * Phase 5.2 scope: stateless, single-endpoint `POST /mcp`. The underlying\n * McpServer is the same one the stdio entry point uses, so every tool\n * registered for stdio is available over HTTP with no duplication. Only\n * the transport swaps out.\n *\n * Auth is a simple opaque Bearer token. When `authToken` is supplied, any\n * request without the exact `Authorization: Bearer <token>` header gets\n * a 401 before the MCP transport sees it. Multi-tenant auth (per-project\n * API keys, GitHub App scopes) is Studio's concern — MCP stays minimal.\n *\n * GitHubProvider HTTP routing will land in phase 5.3 together with the\n * tool-handler provider abstraction; today the HTTP server still drives\n * a LocalProvider under the hood.\n */\nexport async function startHttpMcpServer(\n opts: HttpMcpServerOptions & { port: number, host?: string },\n): Promise<HttpMcpServerHandle> {\n return startHttpMcpServerInternal({\n mcp: createMcpServer(opts.projectRoot),\n port: opts.port,\n host: opts.host,\n authToken: opts.authToken,\n path: opts.path,\n })\n}\n\n/**\n * Variant of {@link startHttpMcpServer} that accepts a pre-built provider\n * (and an optional `projectRoot`). This is the phase-5.3 entry point that\n * lets HTTP-hosted MCP route tool calls to a non-Local provider — e.g. a\n * `GitHubProvider` shared across many projects. Read-only tools work out\n * of the box; write-path tools return `capability_required: localWorktree`\n * when no `projectRoot` is supplied.\n */\nexport async function startHttpMcpServerWith(\n opts: HttpMcpServerProviderOptions & { port: number, host?: string },\n): Promise<HttpMcpServerHandle> {\n return startHttpMcpServerInternal({\n mcp: createMcpServer({ provider: opts.provider, projectRoot: opts.projectRoot }),\n port: opts.port,\n host: opts.host,\n authToken: opts.authToken,\n path: opts.path,\n })\n}\n\nasync function startHttpMcpServerInternal(input: {\n mcp: McpServer\n port: number\n host?: string\n authToken?: string\n path?: string\n}): Promise<HttpMcpServerHandle> {\n const { mcp } = input\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: () => randomUUID(),\n })\n await mcp.connect(transport)\n\n const mountPath = input.path ?? '/mcp'\n const host = input.host ?? '127.0.0.1'\n\n const server = http.createServer((req, res) => {\n void handleRequest(req, res, { transport, mountPath, authToken: input.authToken })\n })\n\n await new Promise<void>((resolve, reject) => {\n server.once('error', reject)\n server.listen(input.port, host, () => {\n server.off('error', reject)\n resolve()\n })\n })\n\n const address = server.address() as AddressInfo\n const url = `http://${host}:${address.port}${mountPath}`\n\n return {\n server,\n mcp,\n url,\n close: async () => {\n await new Promise<void>((resolve, reject) => {\n server.close(err => (err ? reject(err) : resolve()))\n })\n await transport.close().catch(() => { /* best-effort */ })\n await mcp.close().catch(() => { /* best-effort */ })\n },\n }\n}\n\nasync function handleRequest(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n ctx: { transport: StreamableHTTPServerTransport, mountPath: string, authToken?: string },\n): Promise<void> {\n if (req.method !== 'POST' && req.method !== 'GET' && req.method !== 'DELETE') {\n writeJson(res, 405, { error: 'Method Not Allowed' })\n return\n }\n\n if (!req.url || !req.url.startsWith(ctx.mountPath)) {\n writeJson(res, 404, { error: 'Not Found' })\n return\n }\n\n if (ctx.authToken) {\n const header = req.headers.authorization\n if (header !== `Bearer ${ctx.authToken}`) {\n writeJson(res, 401, { error: 'Unauthorized' })\n return\n }\n }\n\n try {\n await ctx.transport.handleRequest(req, res)\n } catch (error) {\n if (!res.headersSent) {\n writeJson(res, 500, {\n error: 'Internal Server Error',\n message: error instanceof Error ? error.message : String(error),\n })\n }\n }\n}\n\nfunction writeJson(res: http.ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, { 'Content-Type': 'application/json' })\n res.end(JSON.stringify(body))\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmDA,eAAsB,mBACpB,MAC8B;AAC9B,QAAO,2BAA2B;EAChC,KAAKA,aAAgB,KAAK,YAAY;EACtC,MAAM,KAAK;EACX,MAAM,KAAK;EACX,WAAW,KAAK;EAChB,MAAM,KAAK;EACZ,CAAC;;;;;;;;;;AAWJ,eAAsB,uBACpB,MAC8B;AAC9B,QAAO,2BAA2B;EAChC,KAAKA,aAAgB;GAAE,UAAU,KAAK;GAAU,aAAa,KAAK;GAAa,CAAC;EAChF,MAAM,KAAK;EACX,MAAM,KAAK;EACX,WAAW,KAAK;EAChB,MAAM,KAAK;EACZ,CAAC;;AAGJ,eAAe,2BAA2B,OAMT;CAC/B,MAAM,EAAE,QAAQ;CAChB,MAAM,YAAY,IAAI,8BAA8B,EAClD,0BAA0B,YAAY,EACvC,CAAC;AACF,OAAM,IAAI,QAAQ,UAAU;CAE5B,MAAM,YAAY,MAAM,QAAQ;CAChC,MAAM,OAAO,MAAM,QAAQ;CAE3B,MAAM,SAAS,KAAK,cAAc,KAAK,QAAQ;AACxC,gBAAc,KAAK,KAAK;GAAE;GAAW;GAAW,WAAW,MAAM;GAAW,CAAC;GAClF;AAEF,OAAM,IAAI,SAAe,SAAS,WAAW;AAC3C,SAAO,KAAK,SAAS,OAAO;AAC5B,SAAO,OAAO,MAAM,MAAM,YAAY;AACpC,UAAO,IAAI,SAAS,OAAO;AAC3B,YAAS;IACT;GACF;AAKF,QAAO;EACL;EACA;EACA,KALU,UAAU,KAAK,GADX,OAAO,SAAS,CACM,OAAO;EAM3C,OAAO,YAAY;AACjB,SAAM,IAAI,SAAe,SAAS,WAAW;AAC3C,WAAO,OAAM,QAAQ,MAAM,OAAO,IAAI,GAAG,SAAS,CAAE;KACpD;AACF,SAAM,UAAU,OAAO,CAAC,YAAY,GAAsB;AAC1D,SAAM,IAAI,OAAO,CAAC,YAAY,GAAsB;;EAEvD;;AAGH,eAAe,cACb,KACA,KACA,KACe;AACf,KAAI,IAAI,WAAW,UAAU,IAAI,WAAW,SAAS,IAAI,WAAW,UAAU;AAC5E,YAAU,KAAK,KAAK,EAAE,OAAO,sBAAsB,CAAC;AACpD;;AAGF,KAAI,CAAC,IAAI,OAAO,CAAC,IAAI,IAAI,WAAW,IAAI,UAAU,EAAE;AAClD,YAAU,KAAK,KAAK,EAAE,OAAO,aAAa,CAAC;AAC3C;;AAGF,KAAI,IAAI;MACS,IAAI,QAAQ,kBACZ,UAAU,IAAI,aAAa;AACxC,aAAU,KAAK,KAAK,EAAE,OAAO,gBAAgB,CAAC;AAC9C;;;AAIJ,KAAI;AACF,QAAM,IAAI,UAAU,cAAc,KAAK,IAAI;UACpC,OAAO;AACd,MAAI,CAAC,IAAI,YACP,WAAU,KAAK,KAAK;GAClB,OAAO;GACP,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM;GAChE,CAAC;;;AAKR,SAAS,UAAU,KAA0B,QAAgB,MAAqB;AAChF,KAAI,UAAU,QAAQ,EAAE,gBAAgB,oBAAoB,CAAC;AAC7D,KAAI,IAAI,KAAK,UAAU,KAAK,CAAC"}
1
+ {"version":3,"file":"index.mjs","names":["createMcpServer"],"sources":["../../../src/server/http/server.ts"],"sourcesContent":["import http from 'node:http'\nimport { randomUUID } from 'node:crypto'\nimport type { AddressInfo } from 'node:net'\nimport type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'\nimport { createServer as createMcpServer, type ToolProvider } from '../../server.js'\n\nexport interface HttpMcpServerOptions {\n /** Project root the MCP server operates against (LocalProvider path). */\n projectRoot: string\n /** Optional Bearer token — when set, requests must send `Authorization: Bearer <token>`. */\n authToken?: string\n /** Mount path for the MCP JSON-RPC endpoint. Defaults to `/mcp`. */\n path?: string\n}\n\nexport interface HttpMcpServerProviderOptions {\n /** Pre-built content provider (GitHubProvider, a mock, etc.). */\n provider: ToolProvider\n /** Local project root when the provider is a LocalProvider; optional otherwise. */\n projectRoot?: string\n /** Optional Bearer token — when set, requests must send `Authorization: Bearer <token>`. */\n authToken?: string\n /** Mount path for the MCP JSON-RPC endpoint. Defaults to `/mcp`. */\n path?: string\n}\n\n/**\n * Multi-tenant entry point — one resolver, many providers.\n *\n * Every new MCP session resolves its own `RepoProvider` from the incoming\n * HTTP request (typically via a workspace / project identifier in a\n * header or path segment). The resolver is invoked exactly once per\n * session; follow-up requests carrying the `Mcp-Session-Id` header are\n * routed to the existing `McpServer` + transport pair.\n *\n * Use this for Studio's hosted MCP Cloud or any agent that drives\n * multiple Contentrain projects through a single HTTP endpoint.\n */\nexport interface HttpMcpServerResolverOptions {\n /**\n * Per-session provider resolver. Invoked on the first request of a\n * new session (typically the MCP `initialize` call). Whatever\n * provider the resolver returns is bound to that session for its\n * lifetime.\n */\n resolveProvider: (req: http.IncomingMessage) => ToolProvider | Promise<ToolProvider>\n /** Optional fallback project root injected into each resolved session. */\n projectRoot?: string\n /** Optional Bearer token — when set, requests must send `Authorization: Bearer <token>`. */\n authToken?: string\n /** Mount path for the MCP JSON-RPC endpoint. Defaults to `/mcp`. */\n path?: string\n /**\n * Idle-session TTL in ms. Sessions that haven't received a request\n * within this window are closed and their providers discarded.\n * Defaults to 15 minutes.\n */\n sessionTtlMs?: number\n}\n\nexport interface HttpMcpServerHandle {\n server: http.Server\n /**\n * The single shared `McpServer` when the server was started with\n * `{ projectRoot }` or `{ provider }`. **Not set** in multi-tenant\n * resolver mode — in that mode each session owns its own server and\n * you should address them through the HTTP endpoint, not directly.\n */\n mcp: McpServer | null\n url: string\n close: () => Promise<void>\n}\n\nconst MCP_SESSION_HEADER = 'mcp-session-id'\n\n/**\n * Start an HTTP transport for the MCP server on top of the local project.\n *\n * Stateless, single-endpoint `POST /mcp`. The underlying `McpServer` is\n * the same one the stdio entry point uses, so every tool registered for\n * stdio is available over HTTP with no duplication. Only the transport\n * swaps out.\n *\n * Auth is a simple opaque Bearer token. When `authToken` is supplied,\n * any request without the exact `Authorization: Bearer <token>` header\n * gets a 401 before the MCP transport sees it. Non-local bind addresses\n * should always set this — the `contentrain serve --mcpHttp` CLI hard-\n * errors when it detects a non-localhost bind without a token.\n */\nexport async function startHttpMcpServer(\n opts: HttpMcpServerOptions & { port: number, host?: string },\n): Promise<HttpMcpServerHandle> {\n const mcp = createMcpServer(opts.projectRoot)\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: () => randomUUID(),\n })\n await mcp.connect(transport)\n\n return startHttpMcpServerInternal({\n mode: 'single',\n mcp,\n transport,\n port: opts.port,\n host: opts.host,\n authToken: opts.authToken,\n path: opts.path,\n })\n}\n\n/**\n * Variant accepting a pre-built provider or a per-request resolver.\n *\n * **Single-provider mode** (`{ provider }`):\n * All sessions share one `McpServer` backed by the given provider.\n * Good for CI runners, single-tenant deployments, or any case where\n * the backing repository is fixed at boot time.\n *\n * **Multi-tenant mode** (`{ resolveProvider }`):\n * Each session resolves its own provider from the incoming request.\n * The resolver is invoked once per session; subsequent requests with\n * the `Mcp-Session-Id` header reuse the same server + transport pair.\n * Idle sessions are closed after `sessionTtlMs` (default 15m).\n */\nexport async function startHttpMcpServerWith(\n opts: (HttpMcpServerProviderOptions | HttpMcpServerResolverOptions) & { port: number, host?: string },\n): Promise<HttpMcpServerHandle> {\n if ('resolveProvider' in opts) {\n return startMultiTenantHttpMcpServer(opts)\n }\n const mcp = createMcpServer({ provider: opts.provider, projectRoot: opts.projectRoot })\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: () => randomUUID(),\n })\n await mcp.connect(transport)\n return startHttpMcpServerInternal({\n mode: 'single',\n mcp,\n transport,\n port: opts.port,\n host: opts.host,\n authToken: opts.authToken,\n path: opts.path,\n })\n}\n\ninterface MultiTenantSession {\n mcp: McpServer\n transport: StreamableHTTPServerTransport\n lastUsed: number\n}\n\nasync function startMultiTenantHttpMcpServer(\n opts: HttpMcpServerResolverOptions & { port: number, host?: string },\n): Promise<HttpMcpServerHandle> {\n const sessions = new Map<string, MultiTenantSession>()\n const ttl = opts.sessionTtlMs ?? 15 * 60 * 1000\n const mountPath = opts.path ?? '/mcp'\n const host = opts.host ?? '127.0.0.1'\n\n async function disposeSession(sessionId: string): Promise<void> {\n const session = sessions.get(sessionId)\n if (!session) return\n sessions.delete(sessionId)\n await session.transport.close().catch(() => { /* best-effort */ })\n await session.mcp.close().catch(() => { /* best-effort */ })\n }\n\n const reaper = setInterval(() => {\n const now = Date.now()\n for (const [id, session] of sessions) {\n if (now - session.lastUsed > ttl) void disposeSession(id)\n }\n }, Math.min(ttl, 60_000)).unref()\n\n const server = http.createServer((req, res) => {\n void handleMultiTenantRequest(req, res, {\n mountPath,\n authToken: opts.authToken,\n resolveProvider: opts.resolveProvider,\n projectRoot: opts.projectRoot,\n sessions,\n })\n })\n\n await new Promise<void>((resolve, reject) => {\n server.once('error', reject)\n server.listen(opts.port, host, () => {\n server.off('error', reject)\n resolve()\n })\n })\n\n const address = server.address() as AddressInfo\n const url = `http://${host}:${address.port}${mountPath}`\n\n return {\n server,\n mcp: null,\n url,\n close: async () => {\n clearInterval(reaper)\n await new Promise<void>((resolve, reject) => {\n server.close(err => (err ? reject(err) : resolve()))\n })\n const ids = [...sessions.keys()]\n await Promise.all(ids.map(id => disposeSession(id)))\n },\n }\n}\n\nasync function handleMultiTenantRequest(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n ctx: {\n mountPath: string\n authToken?: string\n resolveProvider: (req: http.IncomingMessage) => ToolProvider | Promise<ToolProvider>\n projectRoot?: string\n sessions: Map<string, MultiTenantSession>\n },\n): Promise<void> {\n if (!applyRouteChecks(req, res, { mountPath: ctx.mountPath, authToken: ctx.authToken })) return\n\n const existingSessionId = pickSessionId(req.headers[MCP_SESSION_HEADER])\n if (existingSessionId && ctx.sessions.has(existingSessionId)) {\n const session = ctx.sessions.get(existingSessionId)!\n session.lastUsed = Date.now()\n try {\n await session.transport.handleRequest(req, res)\n } catch (error) {\n writeInternalError(res, error)\n }\n return\n }\n\n try {\n const provider = await ctx.resolveProvider(req)\n const mcp = createMcpServer({ provider, projectRoot: ctx.projectRoot })\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: () => randomUUID(),\n onsessioninitialized: (sessionId: string) => {\n ctx.sessions.set(sessionId, { mcp, transport, lastUsed: Date.now() })\n },\n })\n await mcp.connect(transport)\n await transport.handleRequest(req, res)\n } catch (error) {\n writeInternalError(res, error)\n }\n}\n\nfunction pickSessionId(header: string | string[] | undefined): string | undefined {\n if (!header) return undefined\n return Array.isArray(header) ? header[0] : header\n}\n\nfunction applyRouteChecks(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n ctx: { mountPath: string, authToken?: string },\n): boolean {\n if (req.method !== 'POST' && req.method !== 'GET' && req.method !== 'DELETE') {\n writeJson(res, 405, { error: 'Method Not Allowed' })\n return false\n }\n if (!req.url || !req.url.startsWith(ctx.mountPath)) {\n writeJson(res, 404, { error: 'Not Found' })\n return false\n }\n if (ctx.authToken) {\n const header = req.headers.authorization\n if (header !== `Bearer ${ctx.authToken}`) {\n writeJson(res, 401, { error: 'Unauthorized' })\n return false\n }\n }\n return true\n}\n\nasync function startHttpMcpServerInternal(input: {\n mode: 'single'\n mcp: McpServer\n transport: StreamableHTTPServerTransport\n port: number\n host?: string\n authToken?: string\n path?: string\n}): Promise<HttpMcpServerHandle> {\n const { mcp, transport } = input\n const mountPath = input.path ?? '/mcp'\n const host = input.host ?? '127.0.0.1'\n\n const server = http.createServer((req, res) => {\n void handleRequest(req, res, { transport, mountPath, authToken: input.authToken })\n })\n\n await new Promise<void>((resolve, reject) => {\n server.once('error', reject)\n server.listen(input.port, host, () => {\n server.off('error', reject)\n resolve()\n })\n })\n\n const address = server.address() as AddressInfo\n const url = `http://${host}:${address.port}${mountPath}`\n\n return {\n server,\n mcp,\n url,\n close: async () => {\n await new Promise<void>((resolve, reject) => {\n server.close(err => (err ? reject(err) : resolve()))\n })\n await transport.close().catch(() => { /* best-effort */ })\n await mcp.close().catch(() => { /* best-effort */ })\n },\n }\n}\n\nasync function handleRequest(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n ctx: { transport: StreamableHTTPServerTransport, mountPath: string, authToken?: string },\n): Promise<void> {\n if (!applyRouteChecks(req, res, { mountPath: ctx.mountPath, authToken: ctx.authToken })) return\n\n try {\n await ctx.transport.handleRequest(req, res)\n } catch (error) {\n writeInternalError(res, error)\n }\n}\n\nfunction writeInternalError(res: http.ServerResponse, error: unknown): void {\n if (res.headersSent) return\n writeJson(res, 500, {\n error: 'Internal Server Error',\n message: error instanceof Error ? error.message : String(error),\n })\n}\n\nfunction writeJson(res: http.ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, { 'Content-Type': 'application/json' })\n res.end(JSON.stringify(body))\n}\n"],"mappings":"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"}
@@ -2122,7 +2122,7 @@ function registerDoctorTools(server, _provider, projectRoot) {
2122
2122
  }
2123
2123
  //#endregion
2124
2124
  //#region package.json
2125
- var version = "1.3.0";
2125
+ var version = "1.4.0";
2126
2126
  //#endregion
2127
2127
  //#region src/server.ts
2128
2128
  function createServer(input) {
@@ -2163,4 +2163,4 @@ function resolveServerContext(input) {
2163
2163
  //#endregion
2164
2164
  export { createServer as t };
2165
2165
 
2166
- //# sourceMappingURL=server-B1OJrUQ9.mjs.map
2166
+ //# sourceMappingURL=server-BkhhtEE2.mjs.map