@gmickel/gno 1.17.0 → 1.18.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 (51) hide show
  1. package/README.md +14 -2
  2. package/assets/skill/SKILL.md +17 -1
  3. package/assets/skill/mcp-reference.md +21 -0
  4. package/package.json +2 -2
  5. package/src/cli/commands/daemon.ts +69 -2
  6. package/src/cli/commands/models/pull.ts +13 -3
  7. package/src/cli/commands/status.ts +2 -0
  8. package/src/cli/detach.ts +37 -20
  9. package/src/cli/program.ts +74 -27
  10. package/src/config/index.ts +3 -0
  11. package/src/config/types.ts +37 -0
  12. package/src/core/job-manager.ts +19 -0
  13. package/src/core/mutation-generations.ts +33 -0
  14. package/src/llm/cache.ts +13 -3
  15. package/src/llm/nodeLlamaCpp/adapter.ts +10 -1
  16. package/src/llm/nodeLlamaCpp/lifecycle.ts +71 -0
  17. package/src/mcp/context.ts +161 -0
  18. package/src/mcp/http-security.ts +477 -0
  19. package/src/mcp/http-session.ts +272 -0
  20. package/src/mcp/http-transport.ts +370 -0
  21. package/src/mcp/resources/index.ts +141 -134
  22. package/src/mcp/server.ts +19 -79
  23. package/src/mcp/tools/add-collection.ts +3 -1
  24. package/src/mcp/tools/capture.ts +3 -0
  25. package/src/mcp/tools/clear-collection-embeddings.ts +2 -0
  26. package/src/mcp/tools/context.ts +9 -8
  27. package/src/mcp/tools/embed.ts +62 -52
  28. package/src/mcp/tools/index-cmd.ts +88 -74
  29. package/src/mcp/tools/index.ts +22 -2
  30. package/src/mcp/tools/remove-collection.ts +2 -0
  31. package/src/mcp/tools/status.ts +11 -0
  32. package/src/mcp/tools/sync.ts +16 -14
  33. package/src/mcp/tools/workspace-write.ts +7 -3
  34. package/src/serve/background-runtime.ts +12 -212
  35. package/src/serve/embed-scheduler.ts +74 -43
  36. package/src/serve/index.ts +9 -0
  37. package/src/serve/jobs.ts +78 -80
  38. package/src/serve/public/components/HealthCenter.tsx +74 -1
  39. package/src/serve/public/globals.built.css +1 -1
  40. package/src/serve/public/pages/Dashboard.tsx +1 -0
  41. package/src/serve/resident-admission.ts +159 -0
  42. package/src/serve/resident-background-work.ts +39 -0
  43. package/src/serve/resident-request.ts +55 -0
  44. package/src/serve/resident-runtime.ts +490 -0
  45. package/src/serve/resident-status.ts +96 -0
  46. package/src/serve/routes/api.ts +263 -167
  47. package/src/serve/routes/mcp.ts +69 -0
  48. package/src/serve/server.ts +191 -37
  49. package/src/serve/status-model.ts +51 -0
  50. package/src/serve/status.ts +5 -0
  51. package/src/store/sqlite/adapter.ts +26 -9
package/README.md CHANGED
@@ -94,7 +94,7 @@ gno daemon --detach # headless continuous indexing (background; --status / --st
94
94
 
95
95
  <!-- public-truth:current-version -->
96
96
 
97
- > Current release: **v1.16.0** — see [CHANGELOG.md](./CHANGELOG.md)
97
+ > Current release: **v1.17.0** — see [CHANGELOG.md](./CHANGELOG.md)
98
98
 
99
99
  <!-- /public-truth -->
100
100
 
@@ -104,6 +104,11 @@ gno daemon --detach # headless continuous indexing (background; --status / --st
104
104
  Web/Desktop dashboard now share a per-folder lexical retrieval proof. Local
105
105
  semantic readiness remains independent, and installed MCP targets can run an
106
106
  explicit read-only retrieval smoke from Connectors.
107
+ - **One resident gateway**: `gno serve` and `gno daemon` now host stateful
108
+ Streamable HTTP MCP at `/mcp` from the same long-lived runtime as their
109
+ watcher, jobs, stores, and models. The packed npm smoke proves two-client
110
+ parity, warm reuse, redacted lifecycle status, fail-closed security, restart,
111
+ and shutdown.
107
112
  - **Second-brain capture**: `gno capture`, REST `/api/capture`, SDK
108
113
  `client.capture()`, MCP `gno_capture`, and Web UI Quick Capture write
109
114
  provenance-rich notes from text, stdin, or files, including typed presets for
@@ -308,7 +313,7 @@ Use `gno daemon` when you want continuous indexing without the browser or
308
313
  desktop shell open.
309
314
 
310
315
  ```bash
311
- gno daemon # foreground (Ctrl+C to stop)
316
+ gno daemon # foreground + /mcp on 127.0.0.1:3000
312
317
  gno daemon --no-sync-on-start
313
318
  gno daemon --detach # background (macOS/Linux); auto-writes pid + log files
314
319
  gno daemon --status # check the detached process
@@ -571,6 +576,13 @@ adds 11 opt-in mutation tools, for 30 total.
571
576
  inspection. Your AI assistant synthesizes answers from retrieved context. Write
572
577
  tools are available only through the explicit `--enable-write` opt-in.
573
578
 
579
+ `gno serve` and `gno daemon` also expose this surface as stateful Streamable
580
+ HTTP at `http://127.0.0.1:3000/mcp`. HTTP stays read-only by default.
581
+ Authenticated non-loopback access is available through the headless daemon and
582
+ requires an explicit restrictive bearer-token file plus exact Host and Origin
583
+ allowlists; `gno serve` remains loopback-only. Authentication alone never
584
+ enables mutation tools.
585
+
574
586
  [MCP setup guide →](https://gno.sh/docs/MCP/)
575
587
 
576
588
  ---
@@ -72,7 +72,7 @@ Recipe rules:
72
72
  | **Tags** | `tags`, `tags add`, `tags rm` | Organize and filter documents |
73
73
  | **Context** | `context add/list/rm/check/build/verify` | Configure guidance or compile/verify evidence Capsules |
74
74
  | **Models** | `models list/use/pull/clear/path` | Manage local AI models |
75
- | **Serve** | `serve` | Web UI for browsing and searching |
75
+ | **Serve** | `serve`, `daemon` | One resident Web/headless gateway and watcher |
76
76
  | **Publish** | `publish export` | Export gno.sh publish artifacts |
77
77
  | **MCP** | `mcp`, `mcp install/uninstall/status` | AI assistant integration |
78
78
  | **Skill** | `skill install/uninstall/show/paths` | Install skill for AI agents |
@@ -155,12 +155,28 @@ gno multi-get gno://work/doc1.md gno://work/doc2.md
155
155
  # Search, get full content of top result
156
156
  gno query "auth" --json | jq -r '.results[0].uri' | xargs gno get
157
157
 
158
+ # Exclude documents containing a term
159
+ gno search "deployment" --exclude staging
160
+
158
161
  # Get all results
159
162
  gno search "error handling" --json | jq -r '.results[].uri' | xargs gno multi-get
160
163
  ```
161
164
 
165
+ When the user wants a synthesized answer instead of ranked evidence:
166
+
167
+ ```bash
168
+ gno ask "What changed in the deployment process?" --answer
169
+ ```
170
+
162
171
  ## MCP Retrieval Strategy
163
172
 
173
+ For a long-lived client that supports Streamable HTTP, start one resident owner
174
+ with `gno serve` or `gno daemon` and connect to
175
+ `http://127.0.0.1:3000/mcp`. Existing installed stdio entries remain valid.
176
+ Serve is always loopback-only. Only daemon accepts an explicit non-loopback bind,
177
+ and only with a restrictive bearer-token file plus exact Host/Origin allowlists.
178
+ Authentication never enables writes by itself.
179
+
164
180
  When using GNO through MCP, prefer this retrieval order:
165
181
 
166
182
  1. Check `gno_status` first when freshness, missing vectors, or stale results are plausible.
@@ -72,6 +72,27 @@ Do not shorten a generated entry to `gno mcp`.
72
72
  gno mcp status
73
73
  ```
74
74
 
75
+ ## Resident Streamable HTTP
76
+
77
+ `gno serve` and `gno daemon` expose the same read-only-by-default MCP surface at
78
+ `http://127.0.0.1:3000/mcp`. Use this URL for clients that support Streamable
79
+ HTTP and benefit from shared warm stores, jobs, watchers, and model leases.
80
+ Existing `gno mcp install` stdio entries remain valid.
81
+
82
+ Only `gno daemon` supports an explicit non-loopback bind. It requires a
83
+ restrictive bearer-token file plus exact Host and Origin allowlists.
84
+ Authentication does not grant writes; `gateway.enableWrite` or
85
+ `--mcp-enable-write` is a separate opt-in. `gno serve` always remains
86
+ loopback-only.
87
+
88
+ Inspect the safe lifecycle without exposing paths or secrets:
89
+
90
+ ```bash
91
+ curl http://127.0.0.1:3000/api/resident/status
92
+ gno serve --status --json
93
+ gno daemon --status --json
94
+ ```
95
+
75
96
  ## Retrieval Order
76
97
 
77
98
  For normal questions, start with `gno_query`, then read targeted snippets with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -143,7 +143,7 @@
143
143
  "dependencies": {
144
144
  "@codemirror/lang-markdown": "6.5.0",
145
145
  "@codemirror/theme-one-dark": "6.1.3",
146
- "@modelcontextprotocol/sdk": "1.27.1",
146
+ "@modelcontextprotocol/sdk": "1.29.0",
147
147
  "@radix-ui/react-collapsible": "1.1.12",
148
148
  "@radix-ui/react-dialog": "1.1.15",
149
149
  "@radix-ui/react-dropdown-menu": "2.1.16",
@@ -1,9 +1,18 @@
1
1
  import type { CollectionSyncResult } from "../../ingestion";
2
+ import type { HttpGatewayOverrides } from "../../mcp/http-security";
2
3
  import type { BackgroundRuntimeResult } from "../../serve/background-runtime";
4
+ import type { ResidentRuntime } from "../../serve/resident-runtime";
3
5
 
6
+ import {
7
+ DEFAULT_HTTP_GATEWAY_PORT,
8
+ isHttpGatewayLoopbackBind,
9
+ resolveHttpGatewayConfig,
10
+ } from "../../mcp/http-security";
4
11
  import { startBackgroundRuntime } from "../../serve/background-runtime";
12
+ import { handleResidentStatus, handleStatus } from "../../serve/routes/api";
13
+ import { createMcpHttpGateway } from "../../serve/routes/mcp";
5
14
 
6
- export interface DaemonOptions {
15
+ export interface DaemonOptions extends HttpGatewayOverrides {
7
16
  configPath?: string;
8
17
  index?: string;
9
18
  offline?: boolean;
@@ -24,6 +33,8 @@ type DaemonLogger = {
24
33
 
25
34
  type DaemonDeps = {
26
35
  startBackgroundRuntime?: typeof startBackgroundRuntime;
36
+ createMcpHttpGateway?: typeof createMcpHttpGateway;
37
+ serve?: typeof Bun.serve;
27
38
  logger?: DaemonLogger;
28
39
  };
29
40
 
@@ -31,6 +42,18 @@ function formatCollectionSyncSummary(result: CollectionSyncResult): string {
31
42
  return `${result.collection}: ${result.filesAdded} added, ${result.filesUpdated} updated, ${result.filesUnchanged} unchanged, ${result.filesErrored} errors`;
32
43
  }
33
44
 
45
+ export function handleDaemonAppStatus(
46
+ runtime: ResidentRuntime,
47
+ host: string
48
+ ): Promise<Response> | Response {
49
+ if (!isHttpGatewayLoopbackBind(host)) {
50
+ return new Response(null, { status: 404 });
51
+ }
52
+ return handleStatus(runtime.ctxHolder.current, {
53
+ getResidentStatus: () => runtime.getStatus(),
54
+ });
55
+ }
56
+
34
57
  function createSignalPromise(
35
58
  signal: AbortSignal | undefined,
36
59
  logger: DaemonLogger,
@@ -79,6 +102,7 @@ export async function daemon(
79
102
  const runtimeResult: BackgroundRuntimeResult = await (
80
103
  deps.startBackgroundRuntime ?? startBackgroundRuntime
81
104
  )({
105
+ mode: "daemon",
82
106
  configPath: options.configPath,
83
107
  index: options.index,
84
108
  requireCollections: true,
@@ -108,11 +132,52 @@ export async function daemon(
108
132
  }
109
133
 
110
134
  const { runtime } = runtimeResult;
135
+ const gatewayConfig = resolveHttpGatewayConfig(runtime.config.gateway, {
136
+ host: options.host,
137
+ port: options.port ?? DEFAULT_HTTP_GATEWAY_PORT,
138
+ tokenFile: options.tokenFile,
139
+ allowedHosts: options.allowedHosts,
140
+ allowedOrigins: options.allowedOrigins,
141
+ enableWrite: options.enableWrite,
142
+ });
143
+ let gateway: Awaited<ReturnType<typeof createMcpHttpGateway>> | undefined;
144
+ let server: ReturnType<typeof Bun.serve> | undefined;
111
145
  try {
146
+ gateway = await (deps.createMcpHttpGateway ?? createMcpHttpGateway)(
147
+ runtime as ResidentRuntime,
148
+ gatewayConfig
149
+ );
150
+ server = (deps.serve ?? Bun.serve)({
151
+ port: gatewayConfig.port,
152
+ hostname: gatewayConfig.host,
153
+ development: false,
154
+ routes: {
155
+ "/mcp": gateway.route,
156
+ "/api/status": {
157
+ GET: () =>
158
+ handleDaemonAppStatus(
159
+ runtime as ResidentRuntime,
160
+ gatewayConfig.host
161
+ ),
162
+ },
163
+ "/api/resident/status": {
164
+ GET: () =>
165
+ handleResidentStatus(() =>
166
+ (runtime as ResidentRuntime).getStatus()
167
+ ),
168
+ },
169
+ },
170
+ });
171
+ (runtime as Partial<ResidentRuntime>).setListenerPort?.(
172
+ server.port ?? gatewayConfig.port
173
+ );
112
174
  if (!options.quiet) {
113
175
  logger.log(
114
176
  `GNO daemon started for index "${options.index ?? "default"}" using ${runtime.config.collections.length} collection${runtime.config.collections.length === 1 ? "" : "s"}.`
115
177
  );
178
+ logger.log(
179
+ `MCP gateway listening at http://${gatewayConfig.host}:${server.port}/mcp`
180
+ );
116
181
  const watchState = runtime.watchService.getState();
117
182
  if (watchState.activeCollections.length > 0) {
118
183
  logger.log(`watching: ${watchState.activeCollections.join(", ")}`);
@@ -154,6 +219,8 @@ export async function daemon(
154
219
  error: error instanceof Error ? error.message : String(error),
155
220
  };
156
221
  } finally {
157
- await runtime.dispose();
222
+ await Promise.allSettled([server?.stop(true)]);
223
+ await Promise.allSettled([gateway?.close()]);
224
+ await Promise.allSettled([runtime.dispose()]);
158
225
  }
159
226
  }
@@ -35,6 +35,8 @@ export interface ModelsPullOptions {
35
35
  force?: boolean;
36
36
  /** Progress callback for UI (omit to disable progress) */
37
37
  onProgress?: (type: ModelType, progress: DownloadProgress) => void;
38
+ /** Stop before the next model and suppress subsequent work. */
39
+ signal?: AbortSignal;
38
40
  }
39
41
 
40
42
  export interface ModelPullResult {
@@ -52,6 +54,10 @@ export interface ModelsPullResult {
52
54
  skipped: number;
53
55
  }
54
56
 
57
+ export interface ModelsPullDependencies {
58
+ cache?: Pick<ModelCache, "download" | "getCachedPath" | "isCached">;
59
+ }
60
+
55
61
  // ─────────────────────────────────────────────────────────────────────────────
56
62
  // Implementation
57
63
  // ─────────────────────────────────────────────────────────────────────────────
@@ -87,7 +93,8 @@ function getTypesToPull(options: ModelsPullOptions): ModelType[] {
87
93
  * Execute gno models pull command.
88
94
  */
89
95
  export async function modelsPull(
90
- options: ModelsPullOptions = {}
96
+ options: ModelsPullOptions = {},
97
+ deps: ModelsPullDependencies = {}
91
98
  ): Promise<ModelsPullResult> {
92
99
  // Use provided config, or load from disk (use defaults if not initialized)
93
100
  let config = options.config;
@@ -98,7 +105,7 @@ export async function modelsPull(
98
105
  }
99
106
 
100
107
  const preset = getActivePreset(config);
101
- const cache = new ModelCache(getModelsCachePath());
108
+ const cache = deps.cache ?? new ModelCache(getModelsCachePath());
102
109
  const types = getTypesToPull(options);
103
110
 
104
111
  const results: ModelPullResult[] = [];
@@ -106,6 +113,7 @@ export async function modelsPull(
106
113
  let skipped = 0;
107
114
 
108
115
  for (const type of types) {
116
+ if (options.signal?.aborted) break;
109
117
  const uri =
110
118
  type === "expand" ? (preset.expand ?? preset.gen) : preset[type];
111
119
 
@@ -133,8 +141,10 @@ export async function modelsPull(
133
141
  (progress) => {
134
142
  options.onProgress?.(type, progress);
135
143
  },
136
- options.force
144
+ options.force,
145
+ options.signal
137
146
  );
147
+ if (options.signal?.aborted) break;
138
148
 
139
149
  if (result.ok) {
140
150
  results.push({
@@ -15,6 +15,7 @@ import { buildActivationStatus } from "../../core/activation-status";
15
15
  import { ModelCache } from "../../llm/cache";
16
16
  import { getActivePreset, resolveModelUri } from "../../llm/registry";
17
17
  import { getConnectorVerificationTargets } from "../../serve/connectors";
18
+ import { createStandaloneResidentStatus } from "../../serve/resident-status";
18
19
  import { SqliteAdapter } from "../../store/sqlite/adapter";
19
20
 
20
21
  /**
@@ -277,6 +278,7 @@ export function formatStatus(
277
278
  const s = result.status;
278
279
  return JSON.stringify(
279
280
  {
281
+ resident: createStandaloneResidentStatus("direct-cli"),
280
282
  indexName: s.indexName,
281
283
  configPath: s.configPath,
282
284
  dbPath: s.dbPath,
package/src/cli/detach.ts CHANGED
@@ -20,9 +20,12 @@ import { mkdir, stat, unlink } from "node:fs/promises";
20
20
  // node:path — no Bun path utils.
21
21
  import { dirname, join } from "node:path";
22
22
 
23
+ import type { ResidentStatus } from "../serve/status-model";
24
+
23
25
  import { VERSION, resolveDirs } from "../app/constants";
24
26
  import { toAbsolutePath } from "../config/paths";
25
27
  import { atomicWrite } from "../core/file-ops";
28
+ import { isResidentStatus } from "../serve/resident-status";
26
29
  import { CliError } from "./errors";
27
30
 
28
31
  // ─────────────────────────────────────────────────────────────────────────────
@@ -63,6 +66,7 @@ export interface ProcessStatus {
63
66
  version: string | null;
64
67
  started_at: string | null;
65
68
  uptime_seconds: number | null;
69
+ resident: ResidentStatus | null;
66
70
  pid_file: string;
67
71
  log_file: string;
68
72
  log_size_bytes: number | null;
@@ -424,7 +428,7 @@ export interface SpawnDetachedOptions {
424
428
  * self-contained executable like a single `.mjs` file).
425
429
  */
426
430
  entryScript?: string | null;
427
- /** Optional port to embed in the pid-file payload (serve only). */
431
+ /** Optional resident HTTP port to embed in the pid-file payload. */
428
432
  port?: number | null;
429
433
  /** Working directory for the child. Defaults to `process.cwd()`. */
430
434
  cwd?: string;
@@ -736,6 +740,23 @@ export interface StatusOptions {
736
740
  logFile: string;
737
741
  /** Clock override for deterministic tests. Defaults to `Date.now`. */
738
742
  now?: () => number;
743
+ fetchResidentStatus?: (port: number) => Promise<ResidentStatus | null>;
744
+ }
745
+
746
+ async function fetchResidentStatus(
747
+ port: number
748
+ ): Promise<ResidentStatus | null> {
749
+ try {
750
+ const response = await fetch(
751
+ `http://127.0.0.1:${port}/api/resident/status`,
752
+ { signal: AbortSignal.timeout(500) }
753
+ );
754
+ if (!response.ok) return null;
755
+ const body: unknown = await response.json();
756
+ return isResidentStatus(body) ? body : null;
757
+ } catch {
758
+ return null;
759
+ }
739
760
  }
740
761
 
741
762
  /**
@@ -765,6 +786,7 @@ export async function statusProcess(
765
786
  version: null,
766
787
  started_at: null,
767
788
  uptime_seconds: null,
789
+ resident: null,
768
790
  pid_file: options.pidFile,
769
791
  log_file: options.logFile,
770
792
  log_size_bytes: logSize,
@@ -789,33 +811,28 @@ export async function statusProcess(
789
811
  ? Math.max(0, Math.floor((now() - Date.parse(payload.started_at)) / 1000))
790
812
  : null;
791
813
 
792
- // Schema invariant: a live serve must report a numeric port. If the pid-file
793
- // is missing one somehow, fall back to not-running rather than lying.
794
- const portForRunningServe =
795
- running && effectiveKind === "serve"
796
- ? typeof payload.port === "number"
797
- ? payload.port
798
- : null
799
- : running && effectiveKind === "daemon"
800
- ? null
801
- : (payload.port ?? null);
802
-
803
- const runningFinal =
804
- running && !(effectiveKind === "serve" && portForRunningServe === null);
814
+ // Both resident modes expose an HTTP listener. If a live pid-file is
815
+ // missing its port, fall back to not-running rather than claiming a gateway
816
+ // that clients cannot locate.
817
+ const portForRunningProcess =
818
+ running && typeof payload.port === "number" ? payload.port : null;
819
+ const runningFinal = running && portForRunningProcess !== null;
820
+ const resident =
821
+ runningFinal && portForRunningProcess !== null
822
+ ? await (options.fetchResidentStatus ?? fetchResidentStatus)(
823
+ portForRunningProcess
824
+ )
825
+ : null;
805
826
 
806
827
  return {
807
828
  running: runningFinal,
808
829
  pid: payload.pid,
809
- port:
810
- runningFinal && effectiveKind === "serve"
811
- ? portForRunningServe
812
- : effectiveKind === "daemon"
813
- ? null
814
- : (payload.port ?? null),
830
+ port: runningFinal ? portForRunningProcess : (payload.port ?? null),
815
831
  cmd: effectiveKind,
816
832
  version: payload.version,
817
833
  started_at: payload.started_at,
818
834
  uptime_seconds: runningFinal ? uptimeSeconds : null,
835
+ resident,
819
836
  pid_file: options.pidFile,
820
837
  log_file: options.logFile,
821
838
  log_size_bytes: logSize,
@@ -2806,20 +2806,46 @@ function wireGraphCommand(program: Command): void {
2806
2806
  // Serve Command (web UI)
2807
2807
  // ─────────────────────────────────────────────────────────────────────────────
2808
2808
 
2809
- function wireDaemonCommand(program: Command): void {
2810
- const daemonCmd = program
2811
- .command("daemon")
2812
- .description("Start headless continuous indexing")
2809
+ function addGatewayOptions(
2810
+ command: Command,
2811
+ hostDescription = "HTTP listen address (default: 127.0.0.1)"
2812
+ ): Command {
2813
+ return command
2814
+ .option("--host <address>", hostDescription)
2815
+ .option("--mcp-token-file <path>", "restrictive bearer token file")
2816
+ .option(
2817
+ "--mcp-allowed-host <host>",
2818
+ "exact allowed Host header (repeatable)",
2819
+ (value: string, previous: string[] = []) => [...previous, value]
2820
+ )
2813
2821
  .option(
2814
- "--no-sync-on-start",
2815
- "skip initial sync and only watch future file changes"
2822
+ "--mcp-allowed-origin <origin>",
2823
+ "exact allowed Origin (repeatable)",
2824
+ (value: string, previous: string[] = []) => [...previous, value]
2816
2825
  )
2817
- // Local `--json` so `gno daemon --status --json` parses cleanly in any
2818
- // argv position. Mirrors the serve wiring: gated to --status only.
2819
2826
  .option(
2820
- "--json",
2821
- "JSON output (applies to --status; see process-status schema)"
2827
+ "--mcp-enable-write",
2828
+ "authorize HTTP MCP mutation tools (independent of authentication)"
2822
2829
  );
2830
+ }
2831
+
2832
+ function wireDaemonCommand(program: Command): void {
2833
+ const daemonCmd = addGatewayOptions(
2834
+ program
2835
+ .command("daemon")
2836
+ .description("Start headless continuous indexing")
2837
+ .option("-p, --port <num>", "MCP gateway port", "3000")
2838
+ .option(
2839
+ "--no-sync-on-start",
2840
+ "skip initial sync and only watch future file changes"
2841
+ )
2842
+ // Local `--json` so `gno daemon --status --json` parses cleanly in any
2843
+ // argv position. Mirrors the serve wiring: gated to --status only.
2844
+ .option(
2845
+ "--json",
2846
+ "JSON output (applies to --status; see process-status schema)"
2847
+ )
2848
+ );
2823
2849
 
2824
2850
  // --detach / --status / --stop are mutually exclusive. Use Commander's
2825
2851
  // Option API so the conflict error is the native "option '--status'
@@ -2873,8 +2899,8 @@ function wireDaemonCommand(program: Command): void {
2873
2899
  * the foreground + detached-child paths boot the runtime.
2874
2900
  *
2875
2901
  * Mirrors `handleServeAction` exactly — same helper imports, same JSON
2876
- * gating, same detached-child verification + pid-file cleanup. Daemon has no
2877
- * `--port`, so the foreground path skips port validation entirely.
2902
+ * gating, same detached-child verification + pid-file cleanup. Daemon's port
2903
+ * identifies its headless MCP gateway.
2878
2904
  */
2879
2905
  async function handleDaemonAction(
2880
2906
  cmdOpts: Record<string, unknown>,
@@ -2927,7 +2953,9 @@ async function handleDaemonAction(
2927
2953
  }
2928
2954
 
2929
2955
  if (cmdOpts.detach) {
2956
+ const port = parsePositiveInt("port", cmdOpts.port);
2930
2957
  await runDaemonDetach({
2958
+ port,
2931
2959
  paths,
2932
2960
  spawnDetached,
2933
2961
  argv: resolveCliArgv(cmd),
@@ -2954,6 +2982,8 @@ async function handleDaemonAction(
2954
2982
  installPidFileCleanup(paths.pidFile);
2955
2983
  }
2956
2984
 
2985
+ const port = parsePositiveInt("port", cmdOpts.port);
2986
+
2957
2987
  const { daemon } = await import("./commands/daemon.js");
2958
2988
  const result = await daemon({
2959
2989
  configPath: globals.config,
@@ -2962,6 +2992,12 @@ async function handleDaemonAction(
2962
2992
  verbose: globals.verbose,
2963
2993
  quiet: globals.quiet,
2964
2994
  noSyncOnStart: cmdOpts.syncOnStart === false,
2995
+ port,
2996
+ host: cmdOpts.host as string | undefined,
2997
+ tokenFile: cmdOpts.mcpTokenFile as string | undefined,
2998
+ allowedHosts: cmdOpts.mcpAllowedHost as string[] | undefined,
2999
+ allowedOrigins: cmdOpts.mcpAllowedOrigin as string[] | undefined,
3000
+ enableWrite: cmdOpts.mcpEnableWrite === true ? true : undefined,
2965
3001
  });
2966
3002
  if (!result.success) {
2967
3003
  throw new CliError("RUNTIME", result.error);
@@ -2995,8 +3031,9 @@ async function runDaemonStatus(deps: DaemonStatusDeps): Promise<void> {
2995
3031
  process.stdout.write("gno daemon status\n");
2996
3032
  process.stdout.write(`${"─".repeat(50)}\n`);
2997
3033
  if (status.running) {
2998
- // Daemon is headless — no port to print.
2999
- process.stdout.write(` running yes (pid ${status.pid})\n`);
3034
+ process.stdout.write(
3035
+ ` running yes (pid ${status.pid}, port ${status.port})\n`
3036
+ );
3000
3037
  if (status.version) {
3001
3038
  process.stdout.write(` version ${status.version}\n`);
3002
3039
  }
@@ -3091,6 +3128,7 @@ async function runDaemonStop(deps: DaemonStopDeps): Promise<void> {
3091
3128
  }
3092
3129
 
3093
3130
  interface DaemonDetachDeps {
3131
+ port: number;
3094
3132
  paths: { pidFile: string; logFile: string };
3095
3133
  spawnDetached: typeof import("./detach.js").spawnDetached;
3096
3134
  /**
@@ -3111,24 +3149,28 @@ async function runDaemonDetach(deps: DaemonDetachDeps): Promise<void> {
3111
3149
  argv: childArgv,
3112
3150
  pidFile: deps.paths.pidFile,
3113
3151
  logFile: deps.paths.logFile,
3152
+ port: deps.port,
3114
3153
  });
3115
- // Daemon is headless print pid only (no URL).
3154
+ // Daemon has no Web UI URL; its listener exists solely for MCP.
3116
3155
  process.stdout.write(`PID ${result.pid}\n`);
3117
3156
  }
3118
3157
 
3119
3158
  function wireServeCommand(program: Command): void {
3120
- const serveCmd = program
3121
- .command("serve")
3122
- .description("Start web UI server")
3123
- .option("-p, --port <num>", "port to listen on", "3000")
3124
- // Local `--json` so `gno serve --status --json` parses cleanly in any
3125
- // argv position. Global `--json` already exists on the root program
3126
- // (see line 208); resolution goes through `getFormat()` /
3127
- // `getGlobals()` which precedence-merges local over global.
3128
- .option(
3129
- "--json",
3130
- "JSON output (applies to --status; see process-status schema)"
3131
- );
3159
+ const serveCmd = addGatewayOptions(
3160
+ program
3161
+ .command("serve")
3162
+ .description("Start web UI server")
3163
+ .option("-p, --port <num>", "port to listen on", "3000")
3164
+ // Local `--json` so `gno serve --status --json` parses cleanly in any
3165
+ // argv position. Global `--json` already exists on the root program
3166
+ // (see line 208); resolution goes through `getFormat()` /
3167
+ // `getGlobals()` which precedence-merges local over global.
3168
+ .option(
3169
+ "--json",
3170
+ "JSON output (applies to --status; see process-status schema)"
3171
+ ),
3172
+ "loopback HTTP listen address (default: 127.0.0.1)"
3173
+ );
3132
3174
 
3133
3175
  // --detach / --status / --stop are mutually exclusive. Use Commander's
3134
3176
  // Option API so the conflict error is the native "option '--status'
@@ -3275,6 +3317,11 @@ async function handleServeAction(
3275
3317
  port,
3276
3318
  configPath: globals.config,
3277
3319
  index: globals.index,
3320
+ host: cmdOpts.host as string | undefined,
3321
+ tokenFile: cmdOpts.mcpTokenFile as string | undefined,
3322
+ allowedHosts: cmdOpts.mcpAllowedHost as string[] | undefined,
3323
+ allowedOrigins: cmdOpts.mcpAllowedOrigin as string[] | undefined,
3324
+ enableWrite: cmdOpts.mcpEnableWrite === true ? true : undefined,
3278
3325
  });
3279
3326
 
3280
3327
  if (!result.success) {
@@ -50,6 +50,8 @@ export {
50
50
  CollectionSchema,
51
51
  type Config,
52
52
  ConfigSchema,
53
+ HttpGatewayConfigSchema,
54
+ HttpGatewayLimitsSchema,
53
55
  CONTENT_TYPE_GRAPH_HINTS,
54
56
  type ContentTypeConfig,
55
57
  type ContentTypeGraphHint,
@@ -67,3 +69,4 @@ export {
67
69
  type ScopeType,
68
70
  ScopeTypeSchema,
69
71
  } from "./types";
72
+ export type { HttpGatewayConfig } from "./types";
@@ -245,6 +245,40 @@ export const ModelConfigSchema = z.object({
245
245
 
246
246
  export type ModelConfig = z.infer<typeof ModelConfigSchema>;
247
247
 
248
+ // ────────────────────────────────────────────────────────────────────────────
249
+ // Resident HTTP gateway schema
250
+ // ────────────────────────────────────────────────────────────────────────────
251
+
252
+ export const HttpGatewayLimitsSchema = z.object({
253
+ maxBodyBytes: z
254
+ .number()
255
+ .int()
256
+ .min(1)
257
+ .max(16 * 1024 * 1024)
258
+ .optional(),
259
+ maxRequestsPerMinute: z.number().int().min(1).max(100_000).optional(),
260
+ maxConcurrentRequests: z.number().int().min(1).max(10_000).optional(),
261
+ maxQueuedRequests: z.number().int().min(0).max(10_000).optional(),
262
+ maxSessions: z.number().int().min(1).max(10_000).optional(),
263
+ sessionIdleTimeoutMs: z.number().int().min(1_000).optional(),
264
+ });
265
+
266
+ export const HttpGatewayConfigSchema = z.object({
267
+ /** Literal listen address. Defaults to IPv4 loopback. */
268
+ host: z.string().min(1).optional(),
269
+ /** Bearer token file. Required for wildcard/non-loopback binding. */
270
+ tokenFile: z.string().min(1).optional(),
271
+ /** Exact Host header allowlist. */
272
+ allowedHosts: z.array(z.string().min(1)).optional(),
273
+ /** Exact Origin allowlist. Origin-less non-browser clients remain supported. */
274
+ allowedOrigins: z.array(z.string().min(1)).optional(),
275
+ /** Separate mutation authorization; authentication alone never enables it. */
276
+ enableWrite: z.boolean().optional(),
277
+ limits: HttpGatewayLimitsSchema.optional(),
278
+ });
279
+
280
+ export type HttpGatewayConfig = z.infer<typeof HttpGatewayConfigSchema>;
281
+
248
282
  // ─────────────────────────────────────────────────────────────────────────────
249
283
  // Content Type Schema
250
284
  // ─────────────────────────────────────────────────────────────────────────────
@@ -301,6 +335,9 @@ export const ConfigSchema = z.object({
301
335
 
302
336
  /** Model configuration */
303
337
  models: ModelConfigSchema.optional(),
338
+
339
+ /** Resident Streamable HTTP MCP gateway configuration */
340
+ gateway: HttpGatewayConfigSchema.optional(),
304
341
  });
305
342
 
306
343
  export type Config = Omit<z.infer<typeof ConfigSchema>, "contentTypes"> & {