@plitzi/sdk-server 0.32.10 → 0.32.12

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 (51) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/core/http/dispatcher.js +48 -10
  3. package/dist/core/requestParser.js +18 -1
  4. package/dist/core/services/mcp.js +4 -4
  5. package/dist/core/services/oauth.js +71 -0
  6. package/dist/core/services/registry.js +2 -0
  7. package/dist/core/services/rsc.js +1 -0
  8. package/dist/helpers/serverLog.js +34 -0
  9. package/dist/index.js +2 -1
  10. package/dist/mcp.js +2 -1
  11. package/dist/modules/mcp/handler.js +10 -0
  12. package/dist/modules/mcp/helpers/log.js +20 -10
  13. package/dist/modules/mcp/resources/register.js +10 -4
  14. package/dist/modules/mcp/resources/renderGuide.js +17 -7
  15. package/dist/modules/mcp/server.js +1 -1
  16. package/dist/modules/oauth/authorize.js +160 -0
  17. package/dist/modules/oauth/consentPage.js +111 -0
  18. package/dist/modules/oauth/metadata.js +43 -0
  19. package/dist/modules/oauth/params.js +5 -0
  20. package/dist/modules/oauth/pkce.js +15 -0
  21. package/dist/modules/oauth/records.js +35 -0
  22. package/dist/modules/oauth/register.js +48 -0
  23. package/dist/modules/oauth/respond.js +44 -0
  24. package/dist/modules/oauth/token.js +92 -0
  25. package/dist/plugins/manager.js +2 -0
  26. package/dist/plugins/validate.js +23 -0
  27. package/dist/src/core/http/dispatcher.test.d.ts +1 -0
  28. package/dist/src/core/http/types.d.ts +3 -0
  29. package/dist/src/core/requestParser.d.ts +5 -0
  30. package/dist/src/core/services/oauth.d.ts +8 -0
  31. package/dist/src/helpers/buildResponseHelpers.d.ts +2 -0
  32. package/dist/src/helpers/serverLog.d.ts +10 -0
  33. package/dist/src/index.d.ts +1 -0
  34. package/dist/src/mcp.d.ts +1 -0
  35. package/dist/src/modules/mcp/handler.d.ts +4 -4
  36. package/dist/src/modules/mcp/helpers/log.d.ts +3 -3
  37. package/dist/src/modules/mcp/resources/renderGuide.d.ts +2 -1
  38. package/dist/src/modules/mcp/server.d.ts +2 -2
  39. package/dist/src/modules/oauth/authorize.d.ts +7 -0
  40. package/dist/src/modules/oauth/consentPage.d.ts +7 -0
  41. package/dist/src/modules/oauth/metadata.d.ts +17 -0
  42. package/dist/src/modules/oauth/oauth.test.d.ts +1 -0
  43. package/dist/src/modules/oauth/params.d.ts +5 -0
  44. package/dist/src/modules/oauth/pkce.d.ts +6 -0
  45. package/dist/src/modules/oauth/records.d.ts +49 -0
  46. package/dist/src/modules/oauth/register.d.ts +5 -0
  47. package/dist/src/modules/oauth/respond.d.ts +13 -0
  48. package/dist/src/modules/oauth/token.d.ts +5 -0
  49. package/dist/src/plugins/validate.d.ts +9 -0
  50. package/dist/src/plugins/validate.test.d.ts +1 -0
  51. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.32.12
4
+
5
+ ### Patch Changes
6
+
7
+ - v0.32.12
8
+ - Updated dependencies
9
+ - @plitzi/plitzi-sdk@0.32.12
10
+ - @plitzi/sdk-schema@0.32.12
11
+ - @plitzi/sdk-shared@0.32.12
12
+
13
+ ## 0.32.11
14
+
15
+ ### Patch Changes
16
+
17
+ - v0.32.11
18
+ - Updated dependencies
19
+ - @plitzi/plitzi-sdk@0.32.11
20
+ - @plitzi/sdk-schema@0.32.11
21
+ - @plitzi/sdk-shared@0.32.11
22
+
3
23
  ## 0.32.10
4
24
 
5
25
  ### Patch Changes
@@ -1,22 +1,60 @@
1
1
  import { applySecurityHeaders } from "./securityHeaders.js";
2
2
  import { buildResponseHelpers } from "../../helpers/buildResponseHelpers.js";
3
- import { parseRequest } from "../requestParser.js";
3
+ import { clientIp, parseRequest } from "../requestParser.js";
4
4
  //#region src/core/http/dispatcher.ts
5
- var runPipeline = async (raw, rawRes, buildContext, stages) => {
5
+ var safePath = (url) => {
6
+ const [path = "/", query] = url.split("?");
7
+ if (!query) return path;
8
+ const keys = [...new Set(new URLSearchParams(query).keys())];
9
+ return keys.length > 0 ? `${path}?${keys.join("&")}` : path;
10
+ };
11
+ var errorText = (error) => error instanceof Error ? error.message : String(error);
12
+ var statusOf = (rawRes, res) => rawRes.headersSent ? rawRes.statusCode : res.status;
13
+ var runPipeline = async (raw, rawRes, buildContext, stages, server) => {
14
+ const startedAt = Date.now();
6
15
  const req = parseRequest(raw);
7
16
  const res = buildResponseHelpers(rawRes, req.headers["accept-encoding"]);
8
- if (req.path === "\0") {
9
- res.setStatus(400);
10
- res.end();
11
- return;
12
- }
13
17
  const ctx = buildContext(raw, rawRes, req, res);
14
- applySecurityHeaders(res, ctx.config, ctx.port);
15
- for (const stage of stages) if (await stage(ctx)) return;
18
+ const logger = ctx.config.logger;
19
+ const ip = logger ? clientIp(raw, req) : "";
20
+ const logRequest = (error) => {
21
+ if (!logger) return;
22
+ const status = statusOf(rawRes, res);
23
+ logger({
24
+ kind: "request",
25
+ server,
26
+ method: req.method,
27
+ path: safePath(req.url),
28
+ ...ip ? { clientIp: ip } : {},
29
+ ...ctx.operation === void 0 ? {} : { operation: ctx.operation },
30
+ status,
31
+ durationMs: Date.now() - startedAt,
32
+ ok: !error && status < 400,
33
+ ...error ? { error: errorText(error) } : {},
34
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
35
+ });
36
+ };
37
+ try {
38
+ if (req.path === "\0") {
39
+ res.setStatus(400);
40
+ res.end();
41
+ logRequest();
42
+ return;
43
+ }
44
+ applySecurityHeaders(res, ctx.config, ctx.port);
45
+ for (const stage of stages) if (await stage(ctx)) {
46
+ logRequest();
47
+ return;
48
+ }
49
+ logRequest();
50
+ } catch (error) {
51
+ logRequest(error);
52
+ throw error;
53
+ }
16
54
  };
17
55
  var makeHandler = (label, buildContext, stages) => {
18
56
  return (raw, rawRes) => {
19
- runPipeline(raw, rawRes, buildContext, stages).catch((err) => {
57
+ runPipeline(raw, rawRes, buildContext, stages, label).catch((err) => {
20
58
  console.error(`[${label}] Unhandled error:`, err);
21
59
  try {
22
60
  if (!rawRes.headersSent) rawRes.writeHead(500, { "Content-Type": "text/plain" });
@@ -30,6 +30,23 @@ var parseRequest = (raw) => {
30
30
  ctx: {}
31
31
  };
32
32
  };
33
+ var AUTHORITY_RE = /^[a-zA-Z0-9.-]{1,253}(?::\d{1,5})?$/u;
34
+ /** The public origin the request was addressed to (proxy-aware via x-forwarded-proto, port included), or an empty
35
+ * string when the request carries no usable authority. */
36
+ var requestOrigin = (req) => {
37
+ const authority = req.headers[":authority"] ?? req.headers["host"] ?? "";
38
+ return AUTHORITY_RE.test(authority) ? `${req.protocol}://${authority}` : "";
39
+ };
40
+ var headerValue = (value) => (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
41
+ var unmapIpv4 = (address) => address.startsWith("::ffff:") && address.includes(".") ? address.slice(7) : address;
42
+ /** The address the request came from, seen through the proxies a deployment sits behind: Cloudflare states the
43
+ * peer it accepted in `cf-connecting-ip`, the ingress in `x-real-ip`, and `x-forwarded-for` lists the chain with
44
+ * the original client first. All three are plain headers a direct client can forge, so this is log/diagnostic
45
+ * material — never an authorisation input. Falls back to the socket peer, which no client controls. */
46
+ var clientIp = (raw, req) => {
47
+ const forwardedFor = headerValue(req.headers["x-forwarded-for"]).split(",")[0] ?? "";
48
+ return unmapIpv4(headerValue(req.headers["cf-connecting-ip"]) || headerValue(req.headers["x-real-ip"]) || forwardedFor.trim() || raw.socket.remoteAddress || "");
49
+ };
33
50
  var MAX_BODY_BYTES = 1024 * 1024;
34
51
  var readRawBody = (raw) => new Promise((resolve, reject) => {
35
52
  const chunks = [];
@@ -46,4 +63,4 @@ var readRawBody = (raw) => new Promise((resolve, reject) => {
46
63
  raw.on("error", reject);
47
64
  });
48
65
  //#endregion
49
- export { parseRequest, readRawBody };
66
+ export { clientIp, parseRequest, readRawBody, requestOrigin };
@@ -3,12 +3,12 @@ import { createHttpPreviewClient } from "../../modules/mcp/previewClient.js";
3
3
  import { createHttpScreenshotClient } from "../../modules/mcp/screenshotClient.js";
4
4
  //#region src/core/services/mcp.ts
5
5
  var mcpPathOf = (config) => config.mcpAi?.path ?? config.mcp?.path ?? "/mcp";
6
- var serveMcp = (ctx) => {
7
- const { previewClient, screenshot, adapters, mcpLogger } = ctx.config;
8
- return handleMcp(ctx.raw, ctx.rawRes, ctx.req, adapters, {
6
+ var serveMcp = async (ctx) => {
7
+ const { previewClient, screenshot, adapters, logger } = ctx.config;
8
+ ctx.operation = await handleMcp(ctx.raw, ctx.rawRes, ctx.req, adapters, {
9
9
  preview: previewClient ? createHttpPreviewClient(previewClient) : void 0,
10
10
  screenshot: screenshot ? createHttpScreenshotClient(screenshot) : void 0,
11
- logger: mcpLogger
11
+ logger
12
12
  });
13
13
  };
14
14
  var mcpStage = async (ctx) => {
@@ -0,0 +1,71 @@
1
+ import { readRawBody } from "../requestParser.js";
2
+ import { authorizationServerMetadata, protectedResourceMetadata } from "../../modules/oauth/metadata.js";
3
+ import { sendErrorJson, sendJson } from "../../modules/oauth/respond.js";
4
+ import { handleAuthorizeStart, handleAuthorizeSubmit } from "../../modules/oauth/authorize.js";
5
+ import { handleRegister } from "../../modules/oauth/register.js";
6
+ import { handleToken } from "../../modules/oauth/token.js";
7
+ //#region src/core/services/oauth.ts
8
+ var matchesWellKnown = (path, wellKnown) => path === wellKnown || path.startsWith(`${wellKnown}/`);
9
+ var isOAuthPath = (path) => matchesWellKnown(path, "/.well-known/oauth-protected-resource") || matchesWellKnown(path, "/.well-known/oauth-authorization-server") || path === "/register" || path === "/authorize" || path === "/token";
10
+ var formParams = (req, body) => {
11
+ const params = { ...req.query };
12
+ for (const [key, value] of new URLSearchParams(body).entries()) params[key] = value;
13
+ return params;
14
+ };
15
+ var parseJson = (body) => {
16
+ try {
17
+ return JSON.parse(body);
18
+ } catch {
19
+ return;
20
+ }
21
+ };
22
+ /** OAuth 2.1 authorization for the MCP server, mounted ONLY when a deployment configures `oauth`. Without it the
23
+ * stage falls straight through and the server keeps its anonymous contract: discovery 404s, the public surface
24
+ * (handshake, listings, the guide, plitzi_render) answers without a token, and nothing below changes.
25
+ *
26
+ * It sits before the MCP stage because that one answers every path on a dedicated MCP server — these endpoints
27
+ * would otherwise be swallowed by the JSON-RPC transport, which is exactly the 406 a host hits on /register. */
28
+ var oauthStage = async (ctx) => {
29
+ const { oauth } = ctx.config;
30
+ const { path, method } = ctx.req;
31
+ if (!oauth || !isOAuthPath(path)) return false;
32
+ const { res } = ctx;
33
+ if (path !== "/authorize") {
34
+ res.setHeader("Access-Control-Allow-Origin", "*");
35
+ res.setHeader("Access-Control-Allow-Headers", "*");
36
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
37
+ }
38
+ if (method === "OPTIONS") {
39
+ res.setStatus(204);
40
+ res.end();
41
+ return true;
42
+ }
43
+ if (method === "GET" && matchesWellKnown(path, "/.well-known/oauth-protected-resource")) {
44
+ sendJson(res, 200, protectedResourceMetadata(oauth, ctx.req));
45
+ return true;
46
+ }
47
+ if (method === "GET" && matchesWellKnown(path, "/.well-known/oauth-authorization-server")) {
48
+ sendJson(res, 200, authorizationServerMetadata(oauth, ctx.req));
49
+ return true;
50
+ }
51
+ if (path === "/authorize" && method === "GET") {
52
+ await handleAuthorizeStart(oauth, res, ctx.req.query);
53
+ return true;
54
+ }
55
+ if (path === "/authorize" && method === "POST") {
56
+ await handleAuthorizeSubmit(oauth, res, formParams(ctx.req, await readRawBody(ctx.raw)));
57
+ return true;
58
+ }
59
+ if (path === "/token" && method === "POST") {
60
+ await handleToken(oauth, res, formParams(ctx.req, await readRawBody(ctx.raw)));
61
+ return true;
62
+ }
63
+ if (path === "/register" && method === "POST") {
64
+ await handleRegister(oauth, res, parseJson(await readRawBody(ctx.raw)));
65
+ return true;
66
+ }
67
+ sendErrorJson(res, 405, "invalid_request", `${method} is not allowed on ${path}.`);
68
+ return true;
69
+ };
70
+ //#endregion
71
+ export { oauthStage };
@@ -1,4 +1,5 @@
1
1
  import { mcpOnlyStage, mcpStage } from "./mcp.js";
2
+ import { oauthStage } from "./oauth.js";
2
3
  import { previewStage } from "./preview.js";
3
4
  import { rscStage } from "./rsc.js";
4
5
  import { notFoundStage, ssrStage } from "./ssr.js";
@@ -28,6 +29,7 @@ var buildSSRPipeline = (services) => {
28
29
  var buildMCPPipeline = () => [
29
30
  healthStage,
30
31
  configStaticStage,
32
+ oauthStage,
31
33
  mcpOnlyStage
32
34
  ];
33
35
  //#endregion
@@ -4,6 +4,7 @@ var rscStage = async (ctx) => {
4
4
  const { config, req } = ctx;
5
5
  const rscPath = config.rsc?.path ?? "/_rsc";
6
6
  if (!(config.rsc?.enabled ?? true) || req.method !== "GET" || req.path !== rscPath) return false;
7
+ ctx.operation = "rsc";
7
8
  await handleRsc(req, ctx.res, config, ctx.pluginManager, ctx.caches.rsc);
8
9
  return true;
9
10
  };
@@ -0,0 +1,34 @@
1
+ //#region src/helpers/serverLog.ts
2
+ var outcomeOf = (event) => event.ok ? "ok" : `ERROR ${event.error ?? ""}`.trim();
3
+ var renderRequest = (event) => {
4
+ const client = event.clientIp ? `${event.clientIp} ` : "";
5
+ const operation = event.operation ? ` ${event.operation}` : "";
6
+ const timing = `${Math.round(event.durationMs)}ms`;
7
+ return `[${event.server}] ${client}${event.method} ${event.path}${operation} ${event.status} ${timing} ${outcomeOf(event)}`;
8
+ };
9
+ var renderTool = (event) => {
10
+ const args = event.argsSummary ? ` ${event.argsSummary}` : "";
11
+ return `[mcp] tools/call ${event.name}${args} ${Math.round(event.durationMs)}ms ${outcomeOf(event)}`;
12
+ };
13
+ var renderResource = (event) => `[mcp] resources/read ${event.name} ${Math.round(event.durationMs)}ms ${outcomeOf(event)}`;
14
+ /** One line for any {@link ServerLogEvent}: an HTTP request reads as an access-log line
15
+ * (`[SSR] 203.0.113.7 GET /pricing 200 12ms ok`), the MCP events as what happened inside one
16
+ * (`[mcp] tools/call plitzi_apply {operations:[3]} 41ms ok`). Rendering is a pure format — the dispatcher
17
+ * already stripped query values, collected no headers, cookies or tokens and summarised tool args by shape;
18
+ * the client IP it does carry is personal data, so a sink that persists these lines must say so. */
19
+ var renderLogEvent = (event) => {
20
+ switch (event.kind) {
21
+ case "request": return renderRequest(event);
22
+ case "tool": return renderTool(event);
23
+ case "resource": return renderResource(event);
24
+ }
25
+ };
26
+ /** A drop-in `SSRServerConfig.logger` for consumers that just want the log on the console. Consumers with their
27
+ * own logging stack should pass their own sink instead and read the structured event. */
28
+ var consoleLogger = (event) => {
29
+ const line = renderLogEvent(event);
30
+ if (event.ok) console.log(line);
31
+ else console.error(line);
32
+ };
33
+ //#endregion
34
+ export { consoleLogger, renderLogEvent };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { buildAgentGuide } from "./modules/mcp/helpers/agentPrompt.js";
2
+ import { consoleLogger, renderLogEvent } from "./helpers/serverLog.js";
2
3
  import { operation } from "./modules/mcp/tools/operations/index.js";
3
4
  import { apply, applyShape } from "./modules/mcp/tools/apply/index.js";
4
5
  import { read, readShape } from "./modules/mcp/tools/read.js";
@@ -19,4 +20,4 @@ import { createSSRServer } from "./core/server/ssrServer.js";
19
20
  import { createServer } from "./core/createServer.js";
20
21
  import { createJsonAdapters } from "./adapters/jsonAdapters.js";
21
22
  import AIEngine from "./modules/ai/AIEngine.js";
22
- export { AIEngine, PREVIEW_TOKEN_PARAM, apply, applyShape, bindTools, buildAgentGuide, buildHealthPayload, createHttpPreviewClient, createHttpScreenshotClient, createJsonAdapters, createMCPServer, createMcpServer, createMemoryDraftStore, createPreview, createSSRServer, createServer, getAllowedModes, handleMcp, isCallToolResult, isToolActive, operation, read, readMcpBody, readShape, registerHealthCheck, resolveServices, resolveToolHandler, search, searchShape, serveMcp, toolResponseErr, toolResponseFromResult, toolResponseOk, tools, validate, validateShape, zodToJsonSchema };
23
+ export { AIEngine, PREVIEW_TOKEN_PARAM, apply, applyShape, bindTools, buildAgentGuide, buildHealthPayload, consoleLogger, createHttpPreviewClient, createHttpScreenshotClient, createJsonAdapters, createMCPServer, createMcpServer, createMemoryDraftStore, createPreview, createSSRServer, createServer, getAllowedModes, handleMcp, isCallToolResult, isToolActive, operation, read, readMcpBody, readShape, registerHealthCheck, renderLogEvent, resolveServices, resolveToolHandler, search, searchShape, serveMcp, toolResponseErr, toolResponseFromResult, toolResponseOk, tools, validate, validateShape, zodToJsonSchema };
package/dist/mcp.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { consoleLogger, renderLogEvent } from "./helpers/serverLog.js";
1
2
  import { createMcpServer } from "./modules/mcp/server.js";
2
3
  import { handleMcp, readMcpBody, serveMcp } from "./modules/mcp/handler.js";
3
4
  import { createHttpPreviewClient } from "./modules/mcp/previewClient.js";
4
5
  import { createHttpScreenshotClient } from "./modules/mcp/screenshotClient.js";
5
6
  import { createMCPServer } from "./core/server/mcpServer.js";
6
- export { createHttpPreviewClient, createHttpScreenshotClient, createMCPServer, createMcpServer, handleMcp, readMcpBody, serveMcp };
7
+ export { consoleLogger, createHttpPreviewClient, createHttpScreenshotClient, createMCPServer, createMcpServer, handleMcp, readMcpBody, renderLogEvent, serveMcp };
@@ -15,6 +15,10 @@ var readMcpBody = (req) => new Promise((resolve, reject) => {
15
15
  });
16
16
  req.on("error", reject);
17
17
  });
18
+ var operationOf = (body) => {
19
+ if (Array.isArray(body)) return `batch(${body.length})`;
20
+ if (typeof body === "object" && body !== null && "method" in body && typeof body.method === "string") return body.method;
21
+ };
18
22
  var serveMcp = async (raw, res, server) => {
19
23
  res.setHeader("Access-Control-Allow-Origin", "*");
20
24
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
@@ -30,6 +34,11 @@ var serveMcp = async (raw, res, server) => {
30
34
  res.end();
31
35
  return;
32
36
  }
37
+ if (raw.url === "/favicon.ico") {
38
+ res.writeHead(204);
39
+ res.end();
40
+ return;
41
+ }
33
42
  if (raw.method !== "POST") {
34
43
  res.writeHead(405, {
35
44
  "Content-Type": "application/json",
@@ -60,6 +69,7 @@ var serveMcp = async (raw, res, server) => {
60
69
  return;
61
70
  }
62
71
  await transport.handleRequest(raw, res, body);
72
+ return operationOf(body);
63
73
  } finally {
64
74
  await transport.close();
65
75
  }
@@ -1,7 +1,19 @@
1
+ import { renderLogEvent } from "../../../helpers/serverLog.js";
1
2
  //#region src/modules/mcp/helpers/log.ts
2
3
  var MCP_DEBUG = process.env.MCP_DEBUG === "1";
3
- var summarize = (value, max = 300) => {
4
- if (value === void 0) return;
4
+ var MCP_LOG_ARGS = process.env.MCP_LOG_ARGS === "1";
5
+ var shapeOf = (value, depth = 0) => {
6
+ if (value === null) return "null";
7
+ if (Array.isArray(value)) return `[${value.length}]`;
8
+ if (typeof value === "object") {
9
+ if (depth >= 2) return "{…}";
10
+ const entries = Object.entries(value);
11
+ const shown = entries.slice(0, 8).map(([key, item]) => `${key}:${shapeOf(item, depth + 1)}`);
12
+ return `{${[...shown, ...entries.length > shown.length ? ["…"] : []].join(",")}}`;
13
+ }
14
+ return typeof value;
15
+ };
16
+ var rawSummary = (value, max = 300) => {
5
17
  let json;
6
18
  try {
7
19
  json = JSON.stringify(value);
@@ -10,25 +22,23 @@ var summarize = (value, max = 300) => {
10
22
  }
11
23
  return json.length > max ? `${json.slice(0, max)}…` : json;
12
24
  };
13
- var errorText = (error) => error instanceof Error ? error.message : String(error);
14
- var consoleRender = (event) => {
15
- const kind = event.kind === "tool" ? "tools/call" : "resources/read";
16
- const detail = event.argsSummary ? ` ${event.argsSummary}` : "";
17
- const status = event.ok ? "ok" : `ERROR ${event.error ?? ""}`;
18
- console.log(`[mcp] ${kind} ${event.name}${detail} ${Math.round(event.durationMs)}ms ${status}`);
25
+ var summarize = (value) => {
26
+ if (value === void 0) return;
27
+ return MCP_LOG_ARGS ? rawSummary(value) : shapeOf(value);
19
28
  };
29
+ var errorText = (error) => error instanceof Error ? error.message : String(error);
20
30
  var noop = () => void 0;
21
31
  var inertLog = {
22
32
  toolCall: noop,
23
33
  resourceRead: noop
24
34
  };
25
- /** Build the request-log sink for one MCP server. Dispatches structured events to the consumer's `logger` when
35
+ /** Build the protocol-log sink for one MCP server. Dispatches structured events to the consumer's `logger` when
26
36
  * provided; otherwise renders to the console when MCP_DEBUG=1; otherwise a no-op. */
27
37
  var createMcpLog = (logger) => {
28
38
  if (!logger && !MCP_DEBUG) return inertLog;
29
39
  const emit = (event) => {
30
40
  if (logger) logger(event);
31
- else consoleRender(event);
41
+ else console.log(renderLogEvent(event));
32
42
  };
33
43
  return {
34
44
  toolCall: (name, args, ms, error) => emit({
@@ -21,19 +21,25 @@ var registerResources = (server, getSpace, env, log) => {
21
21
  throw error;
22
22
  }
23
23
  };
24
+ const emitStatic = (uri, read) => {
25
+ const start = performance.now();
26
+ const result = read();
27
+ log.resourceRead(uri, performance.now() - start);
28
+ return result;
29
+ };
24
30
  server.registerResource("Guide", "plitzi://guide", {
25
31
  description: "How to read and write this space with mcp-ai",
26
32
  mimeType: "text/markdown"
27
- }, () => ({ contents: [{
33
+ }, () => emitStatic("plitzi://guide", () => ({ contents: [{
28
34
  uri: "plitzi://guide",
29
35
  mimeType: "text/markdown",
30
36
  text: guideText
31
- }] }));
37
+ }] })));
32
38
  server.registerResource("CSS properties", "plitzi://css-properties", {
33
39
  description: "Valid kebab-case CSS property keys",
34
40
  mimeType: "application/json"
35
- }, () => jsonContents("plitzi://css-properties", envelope(cssProperties)));
36
- registerRenderResources(server);
41
+ }, () => emitStatic("plitzi://css-properties", () => jsonContents("plitzi://css-properties", envelope(cssProperties))));
42
+ registerRenderResources(server, log);
37
43
  const fixed = [
38
44
  [
39
45
  "Primer",
@@ -168,19 +168,29 @@ connects that source to a descendant's field.
168
168
  The tool returns \`rendered: false\` with \`errors: [{ path, message, hint }]\`. Read the hint, fix that one op, and
169
169
  retry — you never lose the rest of the batch.
170
170
  `;
171
- var registerRenderResources = (server) => {
171
+ var registerRenderResources = (server, log) => {
172
172
  server.registerResource("Render guide", RENDER_GUIDE_URI, {
173
173
  description: "How to author a plitzi_render widget: operations, element types, styling, examples.",
174
174
  mimeType: "text/markdown"
175
- }, () => ({ contents: [{
176
- uri: RENDER_GUIDE_URI,
177
- mimeType: "text/markdown",
178
- text: renderGuideText
179
- }] }));
175
+ }, () => {
176
+ const start = performance.now();
177
+ const contents = { contents: [{
178
+ uri: RENDER_GUIDE_URI,
179
+ mimeType: "text/markdown",
180
+ text: renderGuideText
181
+ }] };
182
+ log.resourceRead(RENDER_GUIDE_URI, performance.now() - start);
183
+ return contents;
184
+ });
180
185
  server.registerResource("Render element types", RENDER_TYPES_URI, {
181
186
  description: "Built-in element types a plitzi_render widget can use, with descriptions.",
182
187
  mimeType: "application/json"
183
- }, () => jsonContents(RENDER_TYPES_URI, envelope(renderTypes())));
188
+ }, () => {
189
+ const start = performance.now();
190
+ const contents = jsonContents(RENDER_TYPES_URI, envelope(renderTypes()));
191
+ log.resourceRead(RENDER_TYPES_URI, performance.now() - start);
192
+ return contents;
193
+ });
184
194
  };
185
195
  //#endregion
186
196
  export { RENDER_GUIDE_URI, RENDER_TYPES_URI, registerRenderResources };
@@ -43,7 +43,7 @@ var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger }) =>
43
43
  const getSpace = () => spacePromise ??= loadSpace();
44
44
  const server = new McpServer({
45
45
  name: "plitzi-mcp",
46
- version: "0.32.10"
46
+ version: "0.32.12"
47
47
  }, { instructions: serverInstructions });
48
48
  registerResources(server, getSpace, MCP_ENV, log);
49
49
  registerApps(server);
@@ -0,0 +1,160 @@
1
+ import { renderConsentPage } from "./consentPage.js";
2
+ import { AUTHORIZE_PATH } from "./metadata.js";
3
+ import { field, optionalField } from "./params.js";
4
+ import { randomId } from "./pkce.js";
5
+ import { dropPending, getClient, getPending, putCode, putPending } from "./records.js";
6
+ import { redirectWithCode, redirectWithError, sendErrorPage, sendHtml } from "./respond.js";
7
+ //#region src/modules/oauth/authorize.ts
8
+ var DEFAULT_CODE_TTL_SECONDS = 60;
9
+ var hiddenFieldsFor = (request, pendingId) => {
10
+ const hidden = {
11
+ client_id: request.clientId,
12
+ redirect_uri: request.redirectUri,
13
+ code_challenge: request.challenge,
14
+ code_challenge_method: "S256"
15
+ };
16
+ if (request.state !== void 0) hidden["state"] = request.state;
17
+ if (request.scope !== void 0) hidden["scope"] = request.scope;
18
+ if (pendingId !== void 0) hidden["pending"] = pendingId;
19
+ return hidden;
20
+ };
21
+ var renderConsent = async (config, res, view) => {
22
+ sendHtml(res, 200, await (config.renderConsent ?? renderConsentPage)(view));
23
+ };
24
+ /** Validates the parts of an authorization request that decide WHERE a failure may be reported. Until the client
25
+ * and its redirect target check out, nothing may be sent back to the client. */
26
+ var resolveRequest = async (config, res, params) => {
27
+ const clientId = field(params, "client_id");
28
+ const redirectUri = field(params, "redirect_uri");
29
+ const client = clientId ? await getClient(config.adapters.store, clientId) : void 0;
30
+ if (!client) {
31
+ sendErrorPage(res, "Unknown client", "This application is not registered with the server, or its registration expired.");
32
+ return;
33
+ }
34
+ if (!redirectUri || !client.redirectUris.includes(redirectUri)) {
35
+ sendErrorPage(res, "Invalid redirect", "The application asked to be sent back to an address it did not register.");
36
+ return;
37
+ }
38
+ const state = optionalField(params, "state");
39
+ const responseType = field(params, "response_type");
40
+ if (responseType && responseType !== "code") {
41
+ redirectWithError(res, redirectUri, "unsupported_response_type", "Only the authorization code flow is supported.", state);
42
+ return;
43
+ }
44
+ const challenge = field(params, "code_challenge");
45
+ const method = field(params, "code_challenge_method");
46
+ if (!challenge || method !== "S256") {
47
+ redirectWithError(res, redirectUri, "invalid_request", "PKCE with code_challenge_method=S256 is required.", state);
48
+ return;
49
+ }
50
+ return {
51
+ clientId,
52
+ redirectUri,
53
+ challenge,
54
+ state,
55
+ scope: optionalField(params, "scope")
56
+ };
57
+ };
58
+ /** Consent granted: mint the bearer now, park it behind a one-shot code and send the browser back. Minting here
59
+ * rather than at redemption keeps a failure the user can act on — no space, revoked access — on this screen. */
60
+ var completeGrant = async (config, res, request, pending, target) => {
61
+ const issued = await config.adapters.issueToken(pending.user, target);
62
+ if (!issued) {
63
+ redirectWithError(res, request.redirectUri, "access_denied", "The account may not grant access to this resource.", request.state);
64
+ return;
65
+ }
66
+ const code = randomId();
67
+ await putCode(config.adapters.store, code, {
68
+ clientId: request.clientId,
69
+ redirectUri: request.redirectUri,
70
+ challenge: request.challenge,
71
+ token: issued.token,
72
+ expiresInSeconds: issued.expiresInSeconds,
73
+ scope: request.scope,
74
+ user: pending.user,
75
+ target
76
+ }, config.codeTtlSeconds ?? DEFAULT_CODE_TTL_SECONDS);
77
+ redirectWithCode(res, request.redirectUri, code, request.state);
78
+ };
79
+ /** GET /authorize — the entry point a host opens in the user's browser. */
80
+ var handleAuthorizeStart = async (config, res, params) => {
81
+ const request = await resolveRequest(config, res, params);
82
+ if (!request) return;
83
+ await renderConsent(config, res, {
84
+ step: "credentials",
85
+ action: AUTHORIZE_PATH,
86
+ hidden: hiddenFieldsFor(request),
87
+ targets: [],
88
+ branding: config.branding ?? {}
89
+ });
90
+ };
91
+ /** POST /authorize — both steps of the form land here: the credentials submit, and the grant submit that carries
92
+ * the `pending` id proving the credentials step already passed. */
93
+ var handleAuthorizeSubmit = async (config, res, params) => {
94
+ const request = await resolveRequest(config, res, params);
95
+ if (!request) return;
96
+ const pendingId = optionalField(params, "pending");
97
+ if (pendingId) {
98
+ const pending = await getPending(config.adapters.store, pendingId);
99
+ if (!pending || pending.clientId !== request.clientId) {
100
+ redirectWithError(res, request.redirectUri, "access_denied", "The sign-in expired. Try connecting again.", request.state);
101
+ return;
102
+ }
103
+ const targets = await config.adapters.grantTargets(pending.user);
104
+ const chosen = targets.find((target) => target.value === field(params, "target"));
105
+ if (!chosen) {
106
+ await renderConsent(config, res, {
107
+ step: "target",
108
+ action: AUTHORIZE_PATH,
109
+ hidden: hiddenFieldsFor(request, pendingId),
110
+ targets,
111
+ user: pending.user,
112
+ error: "Choose what to grant access to.",
113
+ branding: config.branding ?? {}
114
+ });
115
+ return;
116
+ }
117
+ await dropPending(config.adapters.store, pendingId);
118
+ await completeGrant(config, res, request, pending, chosen);
119
+ return;
120
+ }
121
+ const user = await config.adapters.authenticate({
122
+ username: field(params, "username"),
123
+ password: field(params, "password")
124
+ });
125
+ if (!user) {
126
+ await renderConsent(config, res, {
127
+ step: "credentials",
128
+ action: AUTHORIZE_PATH,
129
+ hidden: hiddenFieldsFor(request),
130
+ targets: [],
131
+ error: "Those credentials did not match an account.",
132
+ branding: config.branding ?? {}
133
+ });
134
+ return;
135
+ }
136
+ const targets = await config.adapters.grantTargets(user);
137
+ if (targets.length === 0) {
138
+ redirectWithError(res, request.redirectUri, "access_denied", "This account has nothing to grant access to.", request.state);
139
+ return;
140
+ }
141
+ const nextPendingId = randomId();
142
+ await putPending(config.adapters.store, nextPendingId, {
143
+ clientId: request.clientId,
144
+ redirectUri: request.redirectUri,
145
+ challenge: request.challenge,
146
+ state: request.state,
147
+ scope: request.scope,
148
+ user
149
+ });
150
+ await renderConsent(config, res, {
151
+ step: "target",
152
+ action: AUTHORIZE_PATH,
153
+ hidden: hiddenFieldsFor(request, nextPendingId),
154
+ targets,
155
+ user,
156
+ branding: config.branding ?? {}
157
+ });
158
+ };
159
+ //#endregion
160
+ export { handleAuthorizeStart, handleAuthorizeSubmit };