@feasibleone/blong-gogo 1.31.3 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.32.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.31.3...blong-gogo-v1.32.0) (2026-09-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * blong-kukum scaffolding as api ([abb1e0b](https://github.com/feasibleone/blong/commit/abb1e0b4836f25e4a04b035cbc3f2b4bbab94a9b))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * small test fixes ([4adb7eb](https://github.com/feasibleone/blong/commit/4adb7eb1b899099fe71b05b96cd210af7d7ce51a))
14
+ * small test/lint fixes ([0f52e7a](https://github.com/feasibleone/blong/commit/0f52e7a30fa479c1a91f95ba5d8bb1ae4706856c))
15
+
3
16
  ## [1.31.3](https://github.com/feasibleone/blong/compare/blong-gogo-v1.31.2...blong-gogo-v1.31.3) (2026-09-10)
4
17
 
5
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.31.3",
3
+ "version": "1.32.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -10,7 +10,8 @@
10
10
  "browser": "./src/loadBrowser.ts",
11
11
  "import": "./src/loadServer.ts"
12
12
  },
13
- "./ConfigRuntime.js": "./src/ConfigRuntime.ts"
13
+ "./ConfigRuntime.js": "./src/ConfigRuntime.ts",
14
+ "./cli.ts": "./src/cli.ts"
14
15
  },
15
16
  "bin": {
16
17
  "blong": "./bin/blong.ts",
@@ -19,6 +20,7 @@
19
20
  },
20
21
  "dependencies": {
21
22
  "@apidevtools/swagger-parser": "^12.1.0",
23
+ "@modelcontextprotocol/sdk": "^1.0.0",
22
24
  "@aws-sdk/client-s3": "^3.1037.0",
23
25
  "@fastify/basic-auth": "^6.2.0",
24
26
  "@fastify/bearer-auth": "^10.1.2",
@@ -88,7 +90,7 @@
88
90
  "tap": "^21.6.3",
89
91
  "typescript": "^6.0.3",
90
92
  "vite": "^8.2.1",
91
- "@feasibleone/blong-dev": "1.2.2"
93
+ "@feasibleone/blong-dev": "1.3.0"
92
94
  },
93
95
  "scripts": {
94
96
  "browser-check": "node scripts/browser-compat-check.mjs",
@@ -87,6 +87,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
87
87
  log: unknown = null;
88
88
  importedMap?: Map<string, Record<string, (...args: unknown[]) => unknown>>;
89
89
  platform: IApi['platform'];
90
+ registry: IApi['registry'];
90
91
 
91
92
  // These are prefixed with _ rather than using # private class fields.
92
93
  // The adapter uses Object.setPrototypeOf(current, base) to set the base as
@@ -125,6 +126,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
125
126
  | 'attachCheckpoint'
126
127
  | 'render'
127
128
  | 'platform'
129
+ | 'registry'
128
130
  | 'attach'
129
131
  >,
130
132
  configBase: string,
@@ -137,6 +139,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
137
139
  this._methodId = api.methodId;
138
140
  this._getPath = api.getPath;
139
141
  this.platform = api.platform;
142
+ this.registry = api.registry;
140
143
  this._api = api;
141
144
  this._createLog = api.createLog;
142
145
  this._attachCheckpoint = api.attachCheckpoint;
package/src/Gateway.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  IErrorFactory,
6
6
  IErrorMap,
7
7
  IGateway,
8
+ IGatewayRoute,
8
9
  ILocal,
9
10
  ILog,
10
11
  IManifest,
@@ -194,6 +195,25 @@ export default class Gateway extends Internal implements IGateway {
194
195
  #rpcClient: IRpcClient;
195
196
  #routes: RouteOptions[] = [];
196
197
  #local: ILocal;
198
+
199
+ /**
200
+ * The registered routes as plain data.
201
+ *
202
+ * Derived from the same list that is handed to fastify, so callers (for
203
+ * example the MCP server) see exactly the surface that is served — including
204
+ * the per-route auth requirement, which is not inferable from the method name.
205
+ */
206
+ public describe(): IGatewayRoute[] {
207
+ return this.#routes.map(route => {
208
+ const config = route.config as {methodName?: string; auth?: unknown} | undefined;
209
+ return {
210
+ method: config?.methodName ?? '',
211
+ url: route.url,
212
+ httpMethod: String(route.method ?? 'POST'),
213
+ auth: config?.auth ?? 'jwt',
214
+ };
215
+ });
216
+ }
197
217
  #errorFields: [string, unknown][] = [];
198
218
  #plugins: {plugin: unknown; options: unknown}[] = [];
199
219
  #platform: IPlatformApi;
package/src/Mcp.ts ADDED
@@ -0,0 +1,227 @@
1
+ import type {IGateway, IGatewayRoute, ILog, IRegistry, IRemote} from '@feasibleone/blong/types';
2
+ import {Internal} from '@feasibleone/blong/types';
3
+ import type {FastifyInstance} from 'fastify';
4
+ import fp from 'fastify-plugin';
5
+
6
+ interface IConfig {
7
+ enabled: boolean;
8
+ routePrefix: string;
9
+ auth: false | 'jwt';
10
+ /** Server identity reported during the MCP initialise handshake. */
11
+ name: string;
12
+ version: string;
13
+ /**
14
+ * Method-name patterns to expose as tools. Defaults to the kukum API —
15
+ * widen this to expose other namespaces, or set `['.']` to expose every
16
+ * registered route.
17
+ */
18
+ tools: string[];
19
+ /** Free-text guidance sent to the client during initialise. */
20
+ instructions?: string;
21
+ logLevel?: string;
22
+ }
23
+
24
+ interface IApiRef {
25
+ log?: ILog;
26
+ gateway?: IGateway;
27
+ registry?: IRegistry;
28
+ remote?: IRemote;
29
+ }
30
+
31
+ /**
32
+ * MCP (Model Context Protocol) server for a Blong gateway, served over
33
+ * Streamable HTTP at `routePrefix` (default `/mcp`).
34
+ *
35
+ * Tools are derived from the gateway's own route table, so the MCP surface can
36
+ * never drift from the API: whatever `kukum.*` (or another configured namespace)
37
+ * route is registered becomes a callable tool, and invoking it dispatches
38
+ * through the same `remote` path an HTTP call would use.
39
+ *
40
+ * Off by default — this exposes an API to any MCP client that can reach the
41
+ * gateway, so it must be enabled deliberately (a suite can enable it in its own
42
+ * `dev` config without affecting anyone else). The SDK is imported lazily so the
43
+ * dependency is not loaded on the default path.
44
+ */
45
+ export default class Mcp extends Internal {
46
+ #config: IConfig = {
47
+ enabled: false,
48
+ routePrefix: '/mcp',
49
+ auth: false,
50
+ name: 'blong',
51
+ version: '0.0.0',
52
+ tools: ['^kukum\\.'],
53
+ };
54
+
55
+ #apiRef: IApiRef;
56
+ /** Sanitised tool name → gateway method name. */
57
+ #tools: Map<string, string> = new Map();
58
+
59
+ public constructor(config: IConfig, apiRef: IApiRef) {
60
+ super({log: apiRef.log});
61
+ this.merge(this.#config, config);
62
+ this.#apiRef = apiRef;
63
+ }
64
+
65
+ /** MCP tool names must be `[A-Za-z0-9_-]`; the dotted method maps 1:1. */
66
+ static toolName(method: string): string {
67
+ return method.replaceAll('.', '_').replaceAll('/', '_');
68
+ }
69
+
70
+ /**
71
+ * Routes to expose as tools.
72
+ *
73
+ * `tools` entries are regular expressions matched against the wired method
74
+ * name, so the default `^kukum\\.` selects the whole kukum API. They are used
75
+ * verbatim — anchoring is the caller's choice.
76
+ */
77
+ public routes(): IGatewayRoute[] {
78
+ const patterns = this.#config.tools.map(pattern => new RegExp(pattern));
79
+ return (this.#apiRef.gateway?.describe?.() ?? []).filter(
80
+ route => route.method && patterns.some(pattern => pattern.test(route.method)),
81
+ );
82
+ }
83
+
84
+ public async init(): Promise<void> {
85
+ if (!this.#config.enabled || !this.#apiRef.gateway) return;
86
+
87
+ const remote = this.#apiRef.remote;
88
+ if (!remote) {
89
+ this.log?.warn?.('mcp is enabled but no remote dispatcher is available; skipping');
90
+ return;
91
+ }
92
+
93
+ const prefix = this.#config.routePrefix;
94
+ const authConfig = this.#config.auth;
95
+ const logger = this.log;
96
+ const {name, version, instructions, tools: toolPatterns} = this.#config;
97
+
98
+ // Deferred into the plugin factory on purpose: fastify runs plugin
99
+ // factories during `gateway.start()`, which is AFTER `registry.start()`
100
+ // has published the validations. Reading `gateway.describe()` in
101
+ // `init()` would see an empty route table.
102
+ const plugin = fp(
103
+ async (instance: FastifyInstance) => {
104
+ const routes = this.routes();
105
+ if (!routes.length) {
106
+ logger?.warn?.(
107
+ `mcp is enabled but no routes match ${JSON.stringify(toolPatterns)}`,
108
+ );
109
+ }
110
+
111
+ // Lazy: nothing on the default path should pay for the SDK.
112
+ const {McpServer} = await import('@modelcontextprotocol/sdk/server/mcp.js');
113
+ const {StreamableHTTPServerTransport} =
114
+ await import('@modelcontextprotocol/sdk/server/streamableHttp.js');
115
+ const {randomUUID} = await import('node:crypto');
116
+
117
+ const server = new McpServer({name, version}, {instructions});
118
+ for (const route of routes) {
119
+ const tool = Mcp.toolName(route.method);
120
+ this.#tools.set(tool, route.method);
121
+ server.registerTool(
122
+ tool,
123
+ {
124
+ title: route.method,
125
+ description:
126
+ `${route.method} — POST ${route.url}` +
127
+ (route.auth === false ? ' (no auth required)' : ''),
128
+ },
129
+ async (args: unknown) => {
130
+ const params = (args ?? {}) as Record<string, unknown>;
131
+ const result = await remote.remote(route.method)(params, {
132
+ method: route.method,
133
+ });
134
+ return {
135
+ content: [
136
+ {type: 'text' as const, text: JSON.stringify(result, null, 2)},
137
+ ],
138
+ };
139
+ },
140
+ );
141
+ }
142
+
143
+ /**
144
+ * One transport per MCP session, keyed by `Mcp-Session-Id`.
145
+ *
146
+ * Sessions are stateful because the protocol requires
147
+ * `initialize` to precede other methods on the same session: a
148
+ * stateless per-request server answers every follow-up with
149
+ * "Method not found".
150
+ */
151
+ const sessions = new Map<
152
+ string,
153
+ InstanceType<typeof StreamableHTTPServerTransport>
154
+ >();
155
+
156
+ const sessionFor = async (sessionId?: string) => {
157
+ const existing = sessionId ? sessions.get(sessionId) : undefined;
158
+ if (existing) return existing;
159
+ const transport = new StreamableHTTPServerTransport({
160
+ sessionIdGenerator: () => randomUUID(),
161
+ onsessioninitialized: id => {
162
+ sessions.set(id, transport);
163
+ },
164
+ // Plain JSON responses rather than SSE streams: kukum
165
+ // calls are request/response with no server-initiated
166
+ // messages, which keeps the endpoint usable from curl and
167
+ // simple CLI clients.
168
+ enableJsonResponse: true,
169
+ });
170
+ transport.onclose = () => {
171
+ if (transport.sessionId) sessions.delete(transport.sessionId);
172
+ };
173
+ await server.connect(transport);
174
+ return transport;
175
+ };
176
+
177
+ const handle = async (
178
+ request: Parameters<Parameters<FastifyInstance['route']>[0]['handler']>[0],
179
+ reply: Parameters<Parameters<FastifyInstance['route']>[0]['handler']>[1],
180
+ ) => {
181
+ // The transport writes the response itself (including SSE
182
+ // streams), so hand it the raw Node objects and tell fastify
183
+ // not to touch the reply.
184
+ reply.hijack();
185
+ const sessionId = request.headers['mcp-session-id'] as string | undefined;
186
+ try {
187
+ const transport = await sessionFor(sessionId);
188
+ await transport.handleRequest(request.raw, reply.raw, request.body);
189
+ } catch (error) {
190
+ // `reply.hijack()` means fastify will not produce an error
191
+ // response — without this the client just sees an empty 500.
192
+ logger?.error?.({error}, 'mcp request failed');
193
+ if (!reply.raw.headersSent) {
194
+ reply.raw.writeHead(500, {'content-type': 'application/json'});
195
+ }
196
+ reply.raw.end(
197
+ JSON.stringify({
198
+ jsonrpc: '2.0',
199
+ error: {code: -32603, message: (error as Error).message},
200
+ id: null,
201
+ }),
202
+ );
203
+ }
204
+ };
205
+
206
+ instance.route({
207
+ method: ['GET', 'POST', 'DELETE'],
208
+ url: prefix,
209
+ config: {auth: authConfig},
210
+ handler: handle,
211
+ });
212
+ instance.addHook('onClose', async () => {
213
+ for (const transport of sessions.values()) await transport.close();
214
+ sessions.clear();
215
+ });
216
+
217
+ logger?.warn?.(
218
+ `mcp server enabled at ${prefix} — exposing ${routes.length} tool(s); ` +
219
+ 'do not enable in production',
220
+ );
221
+ },
222
+ {name: 'mcp'},
223
+ );
224
+
225
+ this.#apiRef.gateway.registerPlugin(plugin);
226
+ }
227
+ }
package/src/Registry.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  IObjectSchema,
15
15
  IPlatformApi,
16
16
  IRegistry,
17
+ IRegistryDescription,
17
18
  IRemote,
18
19
  IRpcServer,
19
20
  } from '@feasibleone/blong/types';
@@ -375,6 +376,19 @@ export default class Registry extends Internal implements IRegistry {
375
376
  renderAll(Array.isArray(what) ? merge(...what) : what, this.#platform.context),
376
377
  timing: this.#platform.timing,
377
378
  ...blongLib,
379
+ // The runtime services a `library()` function needs, mirroring the
380
+ // port API (`registry: this`, `platform: …`).
381
+ //
382
+ // A `library()` *factory* is not given the platform: `layerApi`
383
+ // carries only config/handler/errors/schema/gateway/apiSchema. And
384
+ // the lib proxy returns an attached function **raw**, so `this`
385
+ // inside it is this object — not the port, which is only bound in the
386
+ // proxy's not-yet-attached fallback path. Without these keys a
387
+ // library function therefore cannot reach the platform or registry at
388
+ // all, which is what a library needs to be reusable outside a
389
+ // request.
390
+ platform: this.#platform,
391
+ registry: this,
378
392
  };
379
393
  const local = {};
380
394
  const literals: object[] = [];
@@ -469,6 +483,31 @@ export default class Registry extends Internal implements IRegistry {
469
483
  return await this.#watch.test(framework);
470
484
  }
471
485
 
486
+ /**
487
+ * Structural snapshot of the loaded realm graph.
488
+ *
489
+ * Combines the live registry (realms, ports, registered method groups) with
490
+ * the on-disk view owned by `Watch` (handler folders and files). This is the
491
+ * runtime source of truth that tooling and agents should query instead of
492
+ * re-deriving layout from conventions.
493
+ */
494
+ public describe(): IRegistryDescription {
495
+ const disk = this.#watch?.describe?.();
496
+ return {
497
+ realms: Array.from(this.modules.keys()).filter(
498
+ (key): key is string => typeof key === 'string',
499
+ ),
500
+ ports: Array.from(this.ports.keys()),
501
+ groups: Array.from(this.methods.entries()).map(([name, handlers]) => ({
502
+ name,
503
+ handlerCount: handlers.length,
504
+ })),
505
+ folders: disk?.folders ?? [],
506
+ files: disk?.files ?? [],
507
+ layerFiles: disk?.layerFiles ?? [],
508
+ };
509
+ }
510
+
472
511
  public async stop(): Promise<IRegistry> {
473
512
  if (this.#stopped) return this;
474
513
  await this.#watch?.stop();
package/src/Watch.ts CHANGED
@@ -36,6 +36,18 @@ export interface IWatch {
36
36
  setConfigRuntime?(configRuntime: IConfigRuntime): void;
37
37
  /** Attach the registry objectSchema so hot-reload paths can pass it to layerProxy */
38
38
  setObjectSchema?(schema: IObjectSchema): void;
39
+ /**
40
+ * Structural snapshot of the handler folders/files discovered on disk.
41
+ * Data-only so it can be returned straight from an API endpoint.
42
+ */
43
+ describe(): IWatchDescription;
44
+ }
45
+
46
+ /** Handler folders/files discovered from disk, keyed by their owning realm. */
47
+ export interface IWatchDescription {
48
+ folders: Array<{group: string; realm: string; dir: string}>;
49
+ files: Array<{file: string; realm: string}>;
50
+ layerFiles: Array<{file: string; realm: string}>;
39
51
  }
40
52
 
41
53
  interface ITestContext {
@@ -97,6 +109,13 @@ export default class Watch extends Internal implements IWatch {
97
109
  new Map();
98
110
  #handlerFiles: Map<string, {name: string; pkg: IModuleConfig['pkg']; base: string}> = new Map();
99
111
  #layerFiles: Map<string, {name: string; pkg: IModuleConfig['pkg']; base: string}> = new Map();
112
+ /**
113
+ * Every code file discovered inside a handler folder, mapped to its realm.
114
+ * Kept separate from `#handlerFiles` (which drives hot-reload decisions and
115
+ * is deliberately populated only for single-file loads) so introspection can
116
+ * report the full file set without changing reload behaviour.
117
+ */
118
+ #discoveredFiles: Map<string, string> = new Map();
100
119
  #watchers: IWatcher[] = [];
101
120
  #port: () => unknown;
102
121
  #error: IErrorFactory;
@@ -138,6 +157,35 @@ export default class Watch extends Internal implements IWatch {
138
157
  this.#objectSchema = schema;
139
158
  }
140
159
 
160
+ /**
161
+ * Structural snapshot of what is loaded from disk.
162
+ *
163
+ * Group ids follow the same convention the loader registers with
164
+ * (`<realm>.<folder>`, see `_loadHandlers`). Paths are made relative to the
165
+ * process cwd so the result is stable across machines.
166
+ */
167
+ public describe(): IWatchDescription {
168
+ const relative = (file: string): string => this.#platform.relative('.', file);
169
+ const folderEntries = Array.from(this.#handlerFolders.entries()).map(([dir, config]) => ({
170
+ group: `${config.name}.${this.#platform.basename(dir)}`,
171
+ realm: config.name,
172
+ dir: relative(dir),
173
+ }));
174
+ const toFiles = (map: Map<string, {name: string}>): Array<{file: string; realm: string}> =>
175
+ Array.from(map.entries()).map(([file, config]) => ({
176
+ file: relative(file),
177
+ realm: config.name,
178
+ }));
179
+ return {
180
+ folders: folderEntries,
181
+ files: Array.from(this.#discoveredFiles.entries()).map(([file, realm]) => ({
182
+ file: relative(file),
183
+ realm,
184
+ })),
185
+ layerFiles: toFiles(this.#layerFiles),
186
+ };
187
+ }
188
+
141
189
  /** Attach a ConfigRuntime so config-file changes trigger in-process reloads */
142
190
  public setConfigRuntime(configRuntime: IConfigRuntime): void {
143
191
  this.#configRuntime = configRuntime;
@@ -327,6 +375,7 @@ export default class Watch extends Internal implements IWatch {
327
375
  filename,
328
376
  this.#platform.extname(filename),
329
377
  );
378
+ this.#discoveredFiles.set(filename, config.name);
330
379
  const name = expectedName;
331
380
  (kind(item) === 'validation'
332
381
  ? validations
@@ -659,13 +708,14 @@ export default class Watch extends Internal implements IWatch {
659
708
  remote: IRemote,
660
709
  configOverride: object,
661
710
  ): Promise<void> {
662
- this.log?.debug?.({
663
- $meta: {mtid: 'event', method: 'watch.start'},
664
- dir: Array.from(this.#handlerFolders.keys())
665
- .concat(Array.from(this.#handlerFiles.keys()))
666
- .concat(Array.from(this.#layerFiles.keys()))
667
- .map(folder => this.#platform.relative('.', folder)),
668
- });
711
+ if (this.#config.enabled)
712
+ this.log?.debug?.({
713
+ $meta: {mtid: 'event', method: 'watch.start'},
714
+ dir: Array.from(this.#handlerFolders.keys())
715
+ .concat(Array.from(this.#handlerFiles.keys()))
716
+ .concat(Array.from(this.#layerFiles.keys()))
717
+ .map(folder => this.#platform.relative('.', folder)),
718
+ });
669
719
  if (this.#config.test) {
670
720
  this.#emit.addEventListener('test', async (event: Event) => {
671
721
  const {done, test} = ((event as CustomEvent).detail ?? {}) as {