holon-sh 0.0.1-dev.14 → 0.0.1-dev.16

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/README.md CHANGED
@@ -122,6 +122,50 @@ A refused subscription is answered Forbidden, and access is checked again at eac
122
122
 
123
123
  Poll and push delivery are here, with targeted emits, subscription hooks, authorization, ending subscriptions, limits, paged listing and removing event types. Webhook delivery and check functions, which read a source that keeps its own history, are not, and neither are typed event names. Clients are not told when the event types change: they list them again.
124
124
 
125
+ ## Run an app without Holon
126
+
127
+ `holon-sh/serve` serves an app's module at the paths Holon gives it, so the app also runs wherever Node does:
128
+
129
+ ```ts
130
+ import { serve } from "holon-sh/serve";
131
+
132
+ await serve(await import("./server.ts"));
133
+ ```
134
+
135
+ The MCP endpoint is at `/mcp` and `/mcp/<app>`, and the files of `frontend` are at every other path. `PORT` and `HOST` change where it listens, 127.0.0.1:3000 unless they are set. Holon answers clients of the 2025 MCP versions in front of an app, and here the app's handler answers them, so create it without `legacy: "reject"`.
136
+
137
+ ## The caller and the origin
138
+
139
+ `getContext` reads who called a tool, and where the app was reached, so a link the app makes works wherever it runs:
140
+
141
+ ```ts
142
+ import { getContext } from "holon-sh/context";
143
+
144
+ server.registerTool("share", { description: "Links to the page" }, ctx => {
145
+ const { principal, origin } = getContext(ctx.http);
146
+ return { content: [{ type: "text", text: `${origin}/?by=${principal?.subject}` }] };
147
+ });
148
+ ```
149
+
150
+ `principal` is the user Holon admitted, or null. `origin` is the one Holon or a proxy forwards, or else the one the request reached.
151
+
152
+ ## A page in MCP Apps hosts
153
+
154
+ `holon-sh/page` offers an app's page, built as one HTML file, as the view an MCP Apps host shows with a tool's result:
155
+
156
+ ```ts
157
+ import { createPage } from "holon-sh/page";
158
+
159
+ const page = createPage(new URL("./dist/mcp/index.html", import.meta.url));
160
+
161
+ function createServer() {
162
+ const server = new McpServer({ name: "foo", version: "0.1.0" });
163
+ server.registerTool("open", { description: "Opens the page", _meta: page.meta }, open);
164
+ page.serve(server);
165
+ return server;
166
+ }
167
+ ```
168
+
125
169
  ## Community
126
170
 
127
171
  Ask questions and share what you're building on the [Holon Discord](https://discord.gg/jXExyBdy).
package/dist/context.d.ts CHANGED
@@ -40,16 +40,19 @@ export interface Principal {
40
40
  claims: IdentityClaims;
41
41
  actor?: Record<string, unknown>;
42
42
  }
43
- /** The caller of the current MCP request. principal is null for anonymous requests. */
43
+ /** The caller of the current MCP request, and the origin it reached the app at. principal is null for anonymous requests, and origin without HTTP. */
44
44
  export interface RequestContext {
45
45
  principal: Principal | null;
46
+ origin: string | null;
46
47
  }
47
- /** Read the caller from the context an MCP server receives: a tool callback's ctx.http, or the McpRequestContext of a createMcpHandler factory. */
48
+ /** Read the caller and the app's origin from the context an MCP server receives: a tool callback's ctx.http, or the McpRequestContext of a createMcpHandler factory. */
48
49
  export declare function getContext(context?: {
49
50
  authInfo?: {
50
51
  clientId: string;
51
52
  scopes: string[];
52
53
  extra?: Record<string, unknown>;
53
54
  };
55
+ req?: Request;
56
+ requestInfo?: Request;
54
57
  }): RequestContext;
55
58
  //# sourceMappingURL=context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,MAAM,WAAW,aAAa;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IACxH,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AACD,yHAAyH;AACzH,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClG,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAClG,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAClG,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IACrG,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AACD,yDAAyD;AACzD,MAAM,WAAW,SAAS;IAAG,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,cAAc,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE;AAC3J,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAAG,SAAS,EAAE,SAAS,GAAG,IAAI,CAAA;CAAE;AAM/D,mJAAmJ;AACnJ,wBAAgB,UAAU,CAAC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAA;CAAO,GAAG,cAAc,CAK/I"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,MAAM,WAAW,aAAa;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IACxH,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AACD,yHAAyH;AACzH,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClG,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAClG,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAClG,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IACrG,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AACD,yDAAyD;AACzD,MAAM,WAAW,SAAS;IAAG,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,cAAc,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE;AAC3J,sJAAsJ;AACtJ,MAAM,WAAW,cAAc;IAAG,SAAS,EAAE,SAAS,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;AAMtF,wKAAwK;AACxK,wBAAgB,UAAU,CAAC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,cAAc,CAMrL"}
package/dist/context.js CHANGED
@@ -1,9 +1,15 @@
1
- /** Read the caller from the context an MCP server receives: a tool callback's ctx.http, or the McpRequestContext of a createMcpHandler factory. */
1
+ /** Read the caller and the app's origin from the context an MCP server receives: a tool callback's ctx.http, or the McpRequestContext of a createMcpHandler factory. */
2
2
  export function getContext(context = {}) {
3
- const { authInfo } = context;
3
+ const { authInfo, req = context.requestInfo } = context;
4
4
  const { iss, sub, claims, act } = (authInfo?.extra ?? {});
5
- if (!authInfo || typeof iss !== "string" || typeof sub !== "string")
6
- return { principal: null };
7
- return { principal: { issuer: iss, subject: sub, clientId: authInfo.clientId, scopes: authInfo.scopes, claims: claims ?? {}, ...(act && { actor: act }) } };
5
+ const principal = !authInfo || typeof iss !== "string" || typeof sub !== "string" ? null
6
+ : { issuer: iss, subject: sub, clientId: authInfo.clientId, scopes: authInfo.scopes, claims: claims ?? {}, ...(act && { actor: act }) };
7
+ return { principal, origin: req ? origin(req) : null };
8
+ }
9
+ // Holon forwards the origin it serves the app at, as a proxy in front may. Otherwise the request reached the app where it
10
+ // listens.
11
+ function origin({ url, headers }) {
12
+ const { protocol, host } = new URL(url);
13
+ return `${headers.get("x-forwarded-proto") ?? protocol.slice(0, -1)}://${headers.get("x-forwarded-host") ?? host}`;
8
14
  }
9
15
  //# sourceMappingURL=context.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAsBA,mJAAmJ;AACnJ,MAAM,UAAU,UAAU,CAAC,OAAO,GAA2F,EAAE;IAC7H,MAAM,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;IAC7B,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,QAAQ,EAAE,KAAK,IAAI,EAAE,CAAa,CAAC;IACtE,IAAI,CAAC,QAAQ,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IAChG,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,IAAI,EAAE,EAAE,GAAG,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;AAC9J,CAAC"}
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAsBA,wKAAwK;AACxK,MAAM,UAAU,UAAU,CAAC,OAAO,GAAiI,EAAE;IACnK,MAAM,EAAE,QAAQ,EAAE,GAAG,GAAG,OAAO,CAAC,WAAW,EAAE,GAAG,OAAO,CAAC;IACxD,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,QAAQ,EAAE,KAAK,IAAI,EAAE,CAAa,CAAC;IACtE,MAAM,SAAS,GAAG,CAAC,QAAQ,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI;QACtF,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,IAAI,EAAE,EAAE,GAAG,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC;IAC1I,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AACzD,CAAC;AAED,0HAA0H;AAC1H,WAAW;AACX,SAAS,MAAM,CAAC,EAAE,GAAG,EAAE,OAAO,EAAW;IACvC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,mBAAmB,CAAC,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,IAAI,IAAI,EAAE,CAAC;AACrH,CAAC"}
package/dist/page.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ /** An app's page, as the view an MCP Apps host shows with a tool's result. */
3
+ export interface Page {
4
+ /** A tool's `_meta`, so that hosts show the page with what the tool returns. */
5
+ meta: {
6
+ ui: {
7
+ resourceUri: string;
8
+ };
9
+ };
10
+ /** Offers the page to a server's clients as its view. */
11
+ serve(server: Pick<McpServer, "registerResource">): void;
12
+ }
13
+ /**
14
+ * Makes an app's page a view: `file` is the page built as one HTML file, and `ui` the view's `_meta.ui`, such as its
15
+ * `csp`.
16
+ *
17
+ * ```ts
18
+ * const page = createPage(new URL("../dist/mcp/index.html", import.meta.url));
19
+ * server.registerTool("open", { description: "Opens it", _meta: page.meta }, open);
20
+ * page.serve(server);
21
+ * ```
22
+ */
23
+ export declare function createPage(file: URL, ui?: Record<string, unknown>): Page;
24
+ //# sourceMappingURL=page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAQ9D,8EAA8E;AAC9E,MAAM,WAAW,IAAI;IACnB,gFAAgF;IAChF,IAAI,EAAE;QAAE,EAAE,EAAE;YAAE,WAAW,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;IACtC,yDAAyD;IACzD,KAAK,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,EAAE,kBAAkB,CAAC,GAAG,IAAI,CAAC;CAC1D;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,IAAI,CAQ5E"}
package/dist/page.js ADDED
@@ -0,0 +1,25 @@
1
+ import { readFile } from "node:fs/promises";
2
+ // MCP Apps names a view with a ui:// URI and serves it as this type. An app has one page, so the URI names no app and
3
+ // survives renaming one.
4
+ // https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx
5
+ const uri = "ui://page";
6
+ const mimeType = "text/html;profile=mcp-app";
7
+ /**
8
+ * Makes an app's page a view: `file` is the page built as one HTML file, and `ui` the view's `_meta.ui`, such as its
9
+ * `csp`.
10
+ *
11
+ * ```ts
12
+ * const page = createPage(new URL("../dist/mcp/index.html", import.meta.url));
13
+ * server.registerTool("open", { description: "Opens it", _meta: page.meta }, open);
14
+ * page.serve(server);
15
+ * ```
16
+ */
17
+ export function createPage(file, ui = {}) {
18
+ return {
19
+ meta: { ui: { resourceUri: uri } },
20
+ serve: server => void server.registerResource("Page", uri, { mimeType }, async () => ({
21
+ contents: [{ uri, mimeType, text: await readFile(file, "utf8"), _meta: { ui } }],
22
+ })),
23
+ };
24
+ }
25
+ //# sourceMappingURL=page.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.js","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAG5C,sHAAsH;AACtH,yBAAyB;AACzB,+FAA+F;AAC/F,MAAM,GAAG,GAAG,WAAW,CAAC;AACxB,MAAM,QAAQ,GAAG,2BAA2B,CAAC;AAU7C;;;;;;;;;GASG;AACH,MAAM,UAAU,UAAU,CAAC,IAAS,EAAE,EAAE,GAA4B,EAAE;IACpE,OAAO;QACL,IAAI,EAAE,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,EAAE;QAClC,KAAK,EAAE,MAAM,CAAC,EAAE,CACd,KAAK,MAAM,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE,EAAE,QAAQ,EAAE,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC;YACnE,QAAQ,EAAE,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC;SACjF,CAAC,CAAC;KACN,CAAC;AACJ,CAAC"}
@@ -0,0 +1,22 @@
1
+ import { type Server } from "node:http";
2
+ /** An app's module: the MCP handler it exports by default, and the directory of its page. */
3
+ export interface App {
4
+ default: {
5
+ fetch(request: Request): Promise<Response>;
6
+ };
7
+ /** The directory of the page's files, as a file URL. */
8
+ frontend?: URL;
9
+ }
10
+ /**
11
+ * Runs an app without Holon, at the paths Holon gives it: the MCP endpoint at `/mcp` and `/mcp/<app>`, and the page's
12
+ * files everywhere else. Listens on `PORT` and `HOST`, or else on 127.0.0.1:3000.
13
+ *
14
+ * ```ts
15
+ * await serve(await import("./server.ts"));
16
+ * ```
17
+ *
18
+ * Holon answers clients of the 2025 MCP versions in front of an app. Here the handler answers them, so create it
19
+ * without `legacy: "reject"`.
20
+ */
21
+ export declare function serve({ default: handler, frontend }: App): Promise<Server>;
22
+ //# sourceMappingURL=serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAC;AAMjG,6FAA6F;AAC7F,MAAM,WAAW,GAAG;IAClB,OAAO,EAAE;QAAE,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;KAAE,CAAC;IACxD,wDAAwD;IACxD,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,KAAK,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,CAiBhF"}
package/dist/serve.js ADDED
@@ -0,0 +1,69 @@
1
+ import { once } from "node:events";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+ import { Readable } from "node:stream";
5
+ import { pipeline } from "node:stream/promises";
6
+ import mime from "mime";
7
+ /**
8
+ * Runs an app without Holon, at the paths Holon gives it: the MCP endpoint at `/mcp` and `/mcp/<app>`, and the page's
9
+ * files everywhere else. Listens on `PORT` and `HOST`, or else on 127.0.0.1:3000.
10
+ *
11
+ * ```ts
12
+ * await serve(await import("./server.ts"));
13
+ * ```
14
+ *
15
+ * Holon answers clients of the 2025 MCP versions in front of an app. Here the handler answers them, so create it
16
+ * without `legacy: "reject"`.
17
+ */
18
+ export async function serve({ default: handler, frontend }) {
19
+ const { PORT = "3000", HOST = "127.0.0.1" } = process.env;
20
+ // A directory's URL ends in `/`. Without it, a name would resolve beside the directory instead of in it.
21
+ const directory = frontend && new URL(frontend.href.replace(/\/?$/, "/"));
22
+ const server = createServer((req, res) => respond(handler, directory, req, res).catch((error) => {
23
+ console.error(error);
24
+ if (!res.headersSent)
25
+ res.writeHead(500);
26
+ res.end();
27
+ }));
28
+ server.listen(Number(PORT), HOST);
29
+ await once(server, "listening");
30
+ const { port } = server.address();
31
+ const origin = `http://${HOST.includes(":") ? `[${HOST}]` : HOST}:${port}`;
32
+ console.log(`Serving ${origin}/ with the MCP endpoint at ${origin}/mcp`);
33
+ return server;
34
+ }
35
+ async function respond(handler, directory, req, res) {
36
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
37
+ if (/^\/mcp(\/[^/]+)?$/.test(url.pathname)) {
38
+ const gone = new AbortController();
39
+ res.on("close", () => gone.abort());
40
+ const headers = new Headers();
41
+ for (const [name, values = []] of Object.entries(req.headersDistinct))
42
+ for (const value of values)
43
+ headers.append(name, value);
44
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : req;
45
+ const response = await handler.fetch(new Request(url, { method: req.method, headers, body, duplex: "half", signal: gone.signal }));
46
+ res.setHeaders(response.headers).writeHead(response.status);
47
+ // When the client leaves, pipeline cancels the body, so a stream between events ends then and not at its next
48
+ // write. Either side failing ends the response, which is all there is to do.
49
+ if (response.body)
50
+ await pipeline(Readable.fromWeb(response.body), res).catch(() => { });
51
+ else
52
+ res.end();
53
+ return;
54
+ }
55
+ if (!directory || (req.method !== "GET" && req.method !== "HEAD"))
56
+ return void res.writeHead(404).end();
57
+ const name = url.pathname.endsWith("/") ? `${url.pathname}index.html` : url.pathname;
58
+ // The URL parser has resolved every `..`, and a file URL may not encode `/`, so no name leads out of the directory.
59
+ // Like Holon, serve nothing hidden, however its dot is spelled, and treat what cannot be read, such as a directory, as
60
+ // not found.
61
+ const contents = /\/(\.|%2e)/i.test(name) ? undefined : await readFile(new URL(`.${name}`, directory)).catch(() => undefined);
62
+ if (!contents)
63
+ return void res.writeHead(404).end();
64
+ // Like Holon, take the type from the MIME database, and call text UTF-8, which a built page is.
65
+ const type = mime.getType(name) ?? "application/octet-stream";
66
+ const contentType = type.startsWith("text/") ? `${type}; charset=utf-8` : type;
67
+ res.writeHead(200, { "content-type": contentType, "x-content-type-options": "nosniff" }).end(contents);
68
+ }
69
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AACnC,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,YAAY,EAA0D,MAAM,WAAW,CAAC;AAEjG,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAChD,OAAO,IAAI,MAAM,MAAM,CAAC;AASxB;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAO;IAC7D,MAAM,EAAE,IAAI,GAAG,MAAM,EAAE,IAAI,GAAG,WAAW,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC;IAC1D,yGAAyG;IACzG,MAAM,SAAS,GAAG,QAAQ,IAAI,IAAI,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;IAC1E,MAAM,MAAM,GAAG,YAAY,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CACvC,OAAO,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;QAC7D,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACrB,IAAI,CAAC,GAAG,CAAC,WAAW;YAAE,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QACzC,GAAG,CAAC,GAAG,EAAE,CAAC;IACZ,CAAC,CAAC,CACH,CAAC;IACF,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC;IAClC,MAAM,IAAI,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAChC,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC,OAAO,EAAiB,CAAC;IACjD,MAAM,MAAM,GAAG,UAAU,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,IAAI,IAAI,EAAE,CAAC;IAC3E,OAAO,CAAC,GAAG,CAAC,WAAW,MAAM,8BAA8B,MAAM,MAAM,CAAC,CAAC;IACzE,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,KAAK,UAAU,OAAO,CAAC,OAAuB,EAAE,SAA0B,EAAE,GAAoB,EAAE,GAAmB;IACnH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE,UAAU,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,WAAW,EAAE,CAAC,CAAC;IACjF,IAAI,mBAAmB,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC3C,MAAM,IAAI,GAAG,IAAI,eAAe,EAAE,CAAC;QACnC,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;QACpC,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;QAC9B,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,EAAE,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC;YAAE,KAAK,MAAM,KAAK,IAAI,MAAM;gBAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAC/H,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,KAAK,KAAK,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC;QAC7E,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACnI,GAAG,CAAC,UAAU,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC5D,8GAA8G;QAC9G,6EAA6E;QAC7E,IAAI,QAAQ,CAAC,IAAI;YAAE,MAAM,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;;YACnF,GAAG,CAAC,GAAG,EAAE,CAAC;QACf,OAAO;IACT,CAAC;IACD,IAAI,CAAC,SAAS,IAAI,CAAC,GAAG,CAAC,MAAM,KAAK,KAAK,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,CAAC;QAAE,OAAO,KAAK,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;IACxG,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,QAAQ,YAAY,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC;IACrF,oHAAoH;IACpH,uHAAuH;IACvH,aAAa;IACb,MAAM,QAAQ,GAAG,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,QAAQ,CAAC,IAAI,GAAG,CAAC,IAAI,IAAI,EAAE,EAAE,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;IAC9H,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;IACpD,gGAAgG;IAChG,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,0BAA0B,CAAC;IAC9D,MAAM,WAAW,GAAG,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,iBAAiB,CAAC,CAAC,CAAC,IAAI,CAAC;IAC/E,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,WAAW,EAAE,wBAAwB,EAAE,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;AACzG,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "holon-sh",
3
- "version": "0.0.1-dev.14",
3
+ "version": "0.0.1-dev.16",
4
4
  "description": "Typed MCP clients, MCP Events, and the caller of an MCP request, for Node.js.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,6 +35,14 @@
35
35
  "./ext/files": {
36
36
  "types": "./dist/ext/files.d.ts",
37
37
  "import": "./dist/ext/files.js"
38
+ },
39
+ "./serve": {
40
+ "types": "./dist/serve.d.ts",
41
+ "import": "./dist/serve.js"
42
+ },
43
+ "./page": {
44
+ "types": "./dist/page.d.ts",
45
+ "import": "./dist/page.js"
38
46
  }
39
47
  },
40
48
  "typesVersions": {
@@ -53,6 +61,12 @@
53
61
  ],
54
62
  "ext/files": [
55
63
  "./dist/ext/files.d.ts"
64
+ ],
65
+ "serve": [
66
+ "./dist/serve.d.ts"
67
+ ],
68
+ "page": [
69
+ "./dist/page.d.ts"
56
70
  ]
57
71
  }
58
72
  },
@@ -73,6 +87,7 @@
73
87
  "dependencies": {
74
88
  "@modelcontextprotocol/client": "^2.3.1",
75
89
  "ipaddr.js": "^2.5.0",
90
+ "mime": "^4.1.2",
76
91
  "undici": "^7.16.0"
77
92
  },
78
93
  "peerDependencies": {
package/src/context.ts CHANGED
@@ -13,17 +13,25 @@ export interface IdentityClaims {
13
13
  }
14
14
  /** The user a request acts for, as Holon admitted it. */
15
15
  export interface Principal { issuer: string; subject: string; clientId: string; scopes: string[]; claims: IdentityClaims; actor?: Record<string, unknown> }
16
- /** The caller of the current MCP request. principal is null for anonymous requests. */
17
- export interface RequestContext { principal: Principal | null }
16
+ /** The caller of the current MCP request, and the origin it reached the app at. principal is null for anonymous requests, and origin without HTTP. */
17
+ export interface RequestContext { principal: Principal | null; origin: string | null }
18
18
 
19
19
  // Holon admits the caller before the request reaches the server and hands it over as MCP's authInfo, with claims and
20
20
  // act in these shapes.
21
21
  interface Admitted { iss?: unknown; sub?: unknown; claims?: IdentityClaims; act?: Record<string, unknown> }
22
22
 
23
- /** Read the caller from the context an MCP server receives: a tool callback's ctx.http, or the McpRequestContext of a createMcpHandler factory. */
24
- export function getContext(context: { authInfo?: { clientId: string; scopes: string[]; extra?: Record<string, unknown> } } = {}): RequestContext {
25
- const { authInfo } = context;
23
+ /** Read the caller and the app's origin from the context an MCP server receives: a tool callback's ctx.http, or the McpRequestContext of a createMcpHandler factory. */
24
+ export function getContext(context: { authInfo?: { clientId: string; scopes: string[]; extra?: Record<string, unknown> }; req?: Request; requestInfo?: Request } = {}): RequestContext {
25
+ const { authInfo, req = context.requestInfo } = context;
26
26
  const { iss, sub, claims, act } = (authInfo?.extra ?? {}) as Admitted;
27
- if (!authInfo || typeof iss !== "string" || typeof sub !== "string") return { principal: null };
28
- return { principal: { issuer: iss, subject: sub, clientId: authInfo.clientId, scopes: authInfo.scopes, claims: claims ?? {}, ...(act && { actor: act }) } };
27
+ const principal = !authInfo || typeof iss !== "string" || typeof sub !== "string" ? null
28
+ : { issuer: iss, subject: sub, clientId: authInfo.clientId, scopes: authInfo.scopes, claims: claims ?? {}, ...(act && { actor: act }) };
29
+ return { principal, origin: req ? origin(req) : null };
30
+ }
31
+
32
+ // Holon forwards the origin it serves the app at, as a proxy in front may. Otherwise the request reached the app where it
33
+ // listens.
34
+ function origin({ url, headers }: Request) {
35
+ const { protocol, host } = new URL(url);
36
+ return `${headers.get("x-forwarded-proto") ?? protocol.slice(0, -1)}://${headers.get("x-forwarded-host") ?? host}`;
29
37
  }
package/src/page.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { McpServer } from "@modelcontextprotocol/server";
3
+
4
+ // MCP Apps names a view with a ui:// URI and serves it as this type. An app has one page, so the URI names no app and
5
+ // survives renaming one.
6
+ // https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx
7
+ const uri = "ui://page";
8
+ const mimeType = "text/html;profile=mcp-app";
9
+
10
+ /** An app's page, as the view an MCP Apps host shows with a tool's result. */
11
+ export interface Page {
12
+ /** A tool's `_meta`, so that hosts show the page with what the tool returns. */
13
+ meta: { ui: { resourceUri: string } };
14
+ /** Offers the page to a server's clients as its view. */
15
+ serve(server: Pick<McpServer, "registerResource">): void;
16
+ }
17
+
18
+ /**
19
+ * Makes an app's page a view: `file` is the page built as one HTML file, and `ui` the view's `_meta.ui`, such as its
20
+ * `csp`.
21
+ *
22
+ * ```ts
23
+ * const page = createPage(new URL("../dist/mcp/index.html", import.meta.url));
24
+ * server.registerTool("open", { description: "Opens it", _meta: page.meta }, open);
25
+ * page.serve(server);
26
+ * ```
27
+ */
28
+ export function createPage(file: URL, ui: Record<string, unknown> = {}): Page {
29
+ return {
30
+ meta: { ui: { resourceUri: uri } },
31
+ serve: server =>
32
+ void server.registerResource("Page", uri, { mimeType }, async () => ({
33
+ contents: [{ uri, mimeType, text: await readFile(file, "utf8"), _meta: { ui } }],
34
+ })),
35
+ };
36
+ }
package/src/serve.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { once } from "node:events";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
4
+ import type { AddressInfo } from "node:net";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import mime from "mime";
8
+
9
+ /** An app's module: the MCP handler it exports by default, and the directory of its page. */
10
+ export interface App {
11
+ default: { fetch(request: Request): Promise<Response> };
12
+ /** The directory of the page's files, as a file URL. */
13
+ frontend?: URL;
14
+ }
15
+
16
+ /**
17
+ * Runs an app without Holon, at the paths Holon gives it: the MCP endpoint at `/mcp` and `/mcp/<app>`, and the page's
18
+ * files everywhere else. Listens on `PORT` and `HOST`, or else on 127.0.0.1:3000.
19
+ *
20
+ * ```ts
21
+ * await serve(await import("./server.ts"));
22
+ * ```
23
+ *
24
+ * Holon answers clients of the 2025 MCP versions in front of an app. Here the handler answers them, so create it
25
+ * without `legacy: "reject"`.
26
+ */
27
+ export async function serve({ default: handler, frontend }: App): Promise<Server> {
28
+ const { PORT = "3000", HOST = "127.0.0.1" } = process.env;
29
+ // A directory's URL ends in `/`. Without it, a name would resolve beside the directory instead of in it.
30
+ const directory = frontend && new URL(frontend.href.replace(/\/?$/, "/"));
31
+ const server = createServer((req, res) =>
32
+ respond(handler, directory, req, res).catch((error: unknown) => {
33
+ console.error(error);
34
+ if (!res.headersSent) res.writeHead(500);
35
+ res.end();
36
+ }),
37
+ );
38
+ server.listen(Number(PORT), HOST);
39
+ await once(server, "listening");
40
+ const { port } = server.address() as AddressInfo;
41
+ const origin = `http://${HOST.includes(":") ? `[${HOST}]` : HOST}:${port}`;
42
+ console.log(`Serving ${origin}/ with the MCP endpoint at ${origin}/mcp`);
43
+ return server;
44
+ }
45
+
46
+ async function respond(handler: App["default"], directory: URL | undefined, req: IncomingMessage, res: ServerResponse) {
47
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
48
+ if (/^\/mcp(\/[^/]+)?$/.test(url.pathname)) {
49
+ const gone = new AbortController();
50
+ res.on("close", () => gone.abort());
51
+ const headers = new Headers();
52
+ for (const [name, values = []] of Object.entries(req.headersDistinct)) for (const value of values) headers.append(name, value);
53
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : req;
54
+ const response = await handler.fetch(new Request(url, { method: req.method, headers, body, duplex: "half", signal: gone.signal }));
55
+ res.setHeaders(response.headers).writeHead(response.status);
56
+ // When the client leaves, pipeline cancels the body, so a stream between events ends then and not at its next
57
+ // write. Either side failing ends the response, which is all there is to do.
58
+ if (response.body) await pipeline(Readable.fromWeb(response.body), res).catch(() => {});
59
+ else res.end();
60
+ return;
61
+ }
62
+ if (!directory || (req.method !== "GET" && req.method !== "HEAD")) return void res.writeHead(404).end();
63
+ const name = url.pathname.endsWith("/") ? `${url.pathname}index.html` : url.pathname;
64
+ // The URL parser has resolved every `..`, and a file URL may not encode `/`, so no name leads out of the directory.
65
+ // Like Holon, serve nothing hidden, however its dot is spelled, and treat what cannot be read, such as a directory, as
66
+ // not found.
67
+ const contents = /\/(\.|%2e)/i.test(name) ? undefined : await readFile(new URL(`.${name}`, directory)).catch(() => undefined);
68
+ if (!contents) return void res.writeHead(404).end();
69
+ // Like Holon, take the type from the MIME database, and call text UTF-8, which a built page is.
70
+ const type = mime.getType(name) ?? "application/octet-stream";
71
+ const contentType = type.startsWith("text/") ? `${type}; charset=utf-8` : type;
72
+ res.writeHead(200, { "content-type": contentType, "x-content-type-options": "nosniff" }).end(contents);
73
+ }