From cf932baa98ccbbce7fd9a3859b78d21a7906443a Mon Sep 17 00:00:00 2001 From: Zacaria Chtatar <5860751+Zacaria@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:12:33 +0000 Subject: [PATCH 1/3] refactor!: remove legacy setup and transport paths Remove owner-only compatibility code, credential-taking MCP setup, implicit catalog fallbacks, legacy HTTP sessions, and obsolete migration guards/docs. Split guided setup from connection-only bootstrap and replace manual CLI parsing with Commander options. BREAKING CHANGE: configure is removed. Startup requires a connection and an explicit catalog path or store. HTTP accepts only the current per-request protocol. setup no longer accepts bootstrap flags; use the bootstrap command. Closes #50 --- AGENTS.md | 4 + CONTRIBUTING.md | 5 +- INSTALL.md | 4 +- README.md | 2 + ...-install-and-durable-setup-requirements.md | 121 ---- ...-04-03-001-feat-kibana-features-v1-plan.md | 390 ------------ ...tor-kibana-mcp-professionalization-plan.md | 5 +- ...04-08-001-feat-github-landing-page-plan.md | 212 ------- ...6-04-08-002-feat-github-pages-site-plan.md | 300 ---------- ...9-001-feat-ergonomic-install-setup-plan.md | 407 ------------- ...4-09-002-refactor-release-pipeline-plan.md | 319 ---------- ...-11-001-feat-github-pages-homepage-plan.md | 7 +- docs/project/compatibility-matrix.md | 4 +- docs/project/distribution-strategy.md | 2 +- docs/project/release-checklist.md | 2 + docs/project/support-policy.md | 14 +- ...tained-codex-install-handoff-2026-04-08.md | 22 +- ...p-config-reset-after-restart-2026-04-03.md | 103 ---- ...endpoints-may-be-unavailable-2026-04-03.md | 10 +- docs/sources/README.md | 1 - docs/sources/feature-v1-review.md | 137 ----- docs/sources/features-v1.md | 20 - scripts/verify-release-contract.mjs | 26 +- skills/kibana-log-investigation/SKILL.md | 6 +- src/bin/run_cli.ts | 7 +- src/commands/bootstrap.ts | 131 ++++ src/commands/context.ts | 1 - src/commands/setup.ts | 182 +----- src/config/environment.ts | 95 +-- src/config/startup.ts | 16 +- src/profiles/runtime.ts | 23 +- src/server.ts | 66 +-- src/setup/bootstrap.ts | 3 +- src/tools/configure.ts | 69 --- src/tools/configure_index.ts | 3 +- src/transports/http.ts | 115 +--- src/transports/stdio.ts | 3 +- src/types.ts | 1 - test/bootstrap.test.ts | 10 +- test/cli.test.ts | 293 +++++++-- test/config.test.ts | 52 +- test/config/startup.test.ts | 8 +- test/configure_index.test.ts | 13 +- test/profiles/runtime.test.ts | 31 +- test/release_contract.test.ts | 7 +- test/server.test.ts | 104 +--- test/transports/helpers.ts | 64 +- test/transports/http.test.ts | 558 ++++++------------ test/transports/stdio.test.ts | 7 + website/.vitepress/config.ts | 1 - website/ai/agent-plugins.md | 4 +- website/contributing/architecture.md | 3 +- website/deployment/http.md | 6 +- website/deployment/security.md | 2 +- website/deployment/stdio.md | 4 +- website/guide/configuration-file.md | 2 +- website/guide/getting-started.md | 10 +- website/guide/profiles.md | 15 +- website/guide/what-is-it.md | 5 +- website/reference/cli.md | 4 +- website/reference/config-file.md | 1 + website/reference/environment.md | 7 +- website/reference/errors.md | 10 +- website/reference/tools/configure.md | 50 -- 64 files changed, 855 insertions(+), 3254 deletions(-) delete mode 100644 docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md delete mode 100644 docs/plans/2026-04-03-001-feat-kibana-features-v1-plan.md delete mode 100644 docs/plans/2026-04-08-001-feat-github-landing-page-plan.md delete mode 100644 docs/plans/2026-04-08-002-feat-github-pages-site-plan.md delete mode 100644 docs/plans/2026-04-09-001-feat-ergonomic-install-setup-plan.md delete mode 100644 docs/plans/2026-04-09-002-refactor-release-pipeline-plan.md delete mode 100644 docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md delete mode 100644 docs/sources/feature-v1-review.md create mode 100644 src/commands/bootstrap.ts delete mode 100644 src/tools/configure.ts delete mode 100644 website/reference/tools/configure.md diff --git a/AGENTS.md b/AGENTS.md index a2b391e..e97e67a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,3 +26,7 @@ For product details and MCP behavior, read `README.md`. ## CLI conventions Keep the human CLI decomposed by concern: the executable lives in `src/bin/`, Commander command registration lives in `src/commands/`, and terminal I/O plus Clack prompt adapters live outside command modules. Commands contain only command-specific validation and orchestration; do not add command logic back to `src/cli.ts`. + +## Lessons + +- When a project format was used only by the owner, remove its obsolete code, callers, tests and documentation instead of adding compatibility or migration paths. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a602079..a9eb052 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,9 +25,10 @@ Thanks for contributing to Kibana Log Investigation MCP. ## Source Catalogs -- Start from `config/sources.example.json`. +- Connection bootstrap saves an empty managed catalog with the machine profile; `configure_index` adds only indexes explicitly approved by the user. +- Guided `setup` imports a hand-authored catalog selected by the operator. Use `config/sources.example.json` only as an explicitly chosen template. +- Environment-based startup requires `KIBANA_SOURCE_CATALOG_PATH` unless a configuration-file store supplies the catalog. Saved profiles carry their own catalog path; no checkout file is loaded implicitly. - Do not commit real environment source catalogs or credentials. -- `config/sources.runtime.json` is generated by `configure` and is ignored by git. ## Schema Endpoints diff --git a/INSTALL.md b/INSTALL.md index 130c44d..7bc2766 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -104,6 +104,8 @@ The safe sequence is: `configure_index` accepts no Kibana credentials. A validation failure leaves the existing catalog unchanged. +The MCP server starts only with a configured connection. Bootstrap saves one for subsequent starts. Operators who supply the connection directly through environment variables must also set `KIBANA_SOURCE_CATALOG_PATH`, or declare a catalog store in a configuration file. No catalog is loaded implicitly from the checkout, and no MCP tool accepts connection credentials. + ## Secret Input Never put a password in a command-line argument. Supported sources, in precedence order: @@ -155,7 +157,7 @@ If a profile points to a hand-authored or operator-modified catalog, bootstrap f ## Example Catalog Policy -`config/sources.example.json` is documentation data. It never participates in bootstrap precedence. Guided `setup` remains available for operators who explicitly want a hand-authored catalog: +`config/sources.example.json` is documentation data. It never participates in bootstrap precedence. `bootstrap` is always connection-only, including without flags. Guided `setup` is a separate command with no bootstrap flags, for operators who explicitly want a hand-authored catalog: ```bash npx -y @havesomecode/kibana-mcp-server setup diff --git a/README.md b/README.md index 3b61240..d39176f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ npx -y @havesomecode/kibana-mcp-server bootstrap --client codex Bootstrap verifies only the Kibana connection, creates an empty managed catalog, stores the credentials securely and registers the server. It does not list, scan, infer, or configure indexes: the agent asks which exact index or pattern to use, then calls `configure_index`. +The server requires a connection at startup. Use bootstrap, a configuration file, or connection environment variables with an explicit `KIBANA_SOURCE_CATALOG_PATH`. It does not accept credentials through MCP tools or search the checkout for a catalog. `bootstrap` always saves only the connection; `setup` is a separate guided command for importing hand-authored catalogs. + Never pass passwords as command-line arguments. Use `KIBANA_PASSWORD`, `--password-env NAME`, or `--password-stdin`. Install the agent skill that teaches the investigation workflow: diff --git a/docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md b/docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md deleted file mode 100644 index c4e2eaa..0000000 --- a/docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -date: 2026-04-09 -topic: ergonomic-install-and-durable-setup ---- - -# Ergonomic Install and Durable Setup - -## Problem Frame - -The current setup story is workable for technical operators, but it is still too friction-heavy for non-technical users and for lightweight agent handoff. There is already a good long-form prompt in `INSTALL.md`, but there is not yet a true “copy this and go” machine-install surface for humans, and the normal setup story still leans too much on manual `KIBANA_*` configuration and explicit `configure` behavior. - -That friction shows up in two visible ways: - -- a user wants a simple install section with something they can copy directly into a shell or paste into an agent -- a newly opened thread may still require configuration work before the MCP is useful, which undermines the idea of “install once, use everywhere on this machine” - -The goal is to make the product feel like a normal tool for non-technical users: install it with one command or one agent prompt, answer a short guided setup once, store the right machine-level defaults securely, and have future threads work without repeating setup. - -## Approach Comparison - -| Approach | Description | Pros | Cons | Recommendation | -| --- | --- | --- | --- | --- | -| Docs-only polish | Add clearer commands and prompts, but keep env vars and repeated configure steps as the main contract | Fastest to ship | Does not solve the new-thread or machine-level durability problem | No | -| Guided install plus durable machine profile | Offer copyable install surfaces for humans and agents, then launch a first-run setup that stores secrets securely and remembers a default profile for later threads | Solves both install friction and reconfiguration friction | Requires new profile/persistence behavior | Yes | -| Session-only setup | Launch a guided setup, but do not persist enough state to reuse it later | Lower security/storage complexity | Still forces repeated setup in new threads and new sessions | No | - -## User Flow - -```mermaid -flowchart TB - A["User copies install command or agent prompt"] --> B["Tool installs"] - B --> C["Guided setup starts immediately"] - C --> D["User enters Kibana URL, username, password, default environment"] - D --> E["Secrets stored in OS credential store"] - E --> F["Machine default profile saved"] - F --> G["User can add another environment or finish"] - G --> H["Future threads use saved default without reconfigure"] -``` - -## Requirements - -**Install Surfaces** -- R1. The product must expose a copyable human install command that a non-technical user can run directly on their machine. -- R2. The product must expose a copyable agent prompt that a user can paste into Codex or another agent to complete installation and setup. -- R3. The public install story must support both a clone-free install path and a repo-based fallback path. -- R4. When clone-free install is not yet available in a given release posture, user-facing docs must say so clearly and present the repo-based fallback immediately below it rather than implying the simpler path already works. -- R5. The recommended install surface must be the simplest available path, but the fallback path must remain easy to find and copy. - -**Guided Setup** -- R6. A normal supported install must launch guided setup immediately after installation instead of requiring users to manually define `KIBANA_*` variables before first use. -- R7. Guided setup must ask only for the minimum information needed for a first working connection: - - Kibana base URL - - username - - password - - a default environment/profile name -- R8. Guided setup must validate common input mistakes before saving, including the current known pitfall where a user pastes a full Kibana API endpoint instead of the base URL prefix. -- R9. Guided setup must offer an “add another environment” continuation after the first successful profile so users can save additional environments without rerunning the full install flow from scratch. - -**Durable Machine-Level Use** -- R10. After first-run setup succeeds, the tool must persist a machine-level default profile so future threads on that machine can use the MCP without rerunning `configure`. -- R11. A newly opened thread should be able to use the saved default profile automatically unless the user explicitly chooses a different saved environment or the saved configuration is invalid. -- R12. The default user experience must be “install once, configure once, use across later threads” rather than “configure per thread.” -- R13. The setup model must support one default environment first, with optional additional named environments such as `prod` or `staging`. - -**Secrets and Local State** -- R14. Secrets must be stored in the platform credential store rather than in normal documentation steps or hand-edited environment variables. -- R15. The supported secure storage story must cover macOS, Windows, and Linux from the start. -- R16. Non-secret local state such as saved environment names, source-catalog paths, or profile metadata must be stored separately from secrets. -- R17. If the platform credential store is unavailable, locked, or unsupported in a specific runtime context, the product must explain that clearly and guide the user to a supported fallback path without telling a non-technical user to export raw env vars manually as the primary flow. - -**Cross-Surface Consistency** -- R18. The guided setup and durable-profile behavior must work for both human-driven installs and agent-driven installs. -- R19. The product’s public surfaces must consistently explain the install and setup story across `README.md`, `INSTALL.md`, the future Pages site, and plugin/package metadata. -- R20. The advanced env-var/bootstrap path may remain available for power users and compatibility, but it must no longer be the default onboarding story. - -## Success Criteria - -- A non-technical user can install the tool by copying either one shell command or one agent prompt. -- The normal install path reaches a guided setup flow without asking the user to define `KIBANA_*` variables by hand. -- A user enters connection details once and can open a new thread later without rerunning `configure`. -- A user can keep one default environment and optionally save additional named environments after the first successful setup. -- Public install docs clearly distinguish the preferred clone-free path from the repo-based fallback when package distribution status changes. - -## Scope Boundaries - -- No browser-based Kibana OAuth or SSO flow in this tranche. -- No requirement to sync saved environments across multiple machines. -- No replacement of the advanced env-var bootstrap path for power users or automation. -- No attempt to solve organization-wide fleet management or shared team credential provisioning. -- No requirement for a full graphical desktop UI; a guided CLI or agent-driven flow is sufficient if it is ergonomic for non-technical users. - -## Key Decisions - -- **Two first-class install surfaces:** The product should support both a human copy-paste install command and a copy-paste agent prompt. -- **Guided setup starts immediately after install:** The first-run flow should ask for only the minimum connection details and should not expect manual env-var editing. -- **Machine-level persistence is the default behavior:** New threads should inherit a saved default profile instead of requiring `configure` again. -- **One default profile first, more environments optionally:** The primary path stays simple for non-technical users, with multi-environment support exposed as a follow-up choice rather than as an upfront burden. -- **OS credential storage is the default secrets posture:** Secrets should live in the operating system credential store rather than in the normal install instructions or plain local config. -- **Env vars become an advanced compatibility path, not the main onboarding contract:** Keep them for power users, but do not force them into the normal first-run experience. - -## Dependencies / Assumptions - -- The tool will need a stable machine-local place to store non-secret profile metadata. -- The installation/runtime surfaces will need a way to resolve a saved default profile before requiring manual runtime `configure`. -- Public package distribution may still arrive later than the repo-based fallback, so install surfaces must handle staged availability cleanly. - -## Outstanding Questions - -### Resolve Before Planning -- None. - -### Deferred to Planning -- [Affects R3-R5][Technical] What staged rollout shape best supports both clone-free install and repo-based fallback while the public package path is still gated? -- [Affects R10-R17][Technical] What machine-local profile format and lookup order should the runtime use so saved defaults work across new threads without breaking existing repo-local flows? -- [Affects R14-R17][Needs research] What is the most reliable cross-platform credential-store integration strategy for macOS, Windows, and Linux, and what fallback behavior is acceptable when Linux secret-store support is absent or locked? -- [Affects R6-R13][Technical] Should guided setup live in the CLI/runtime surface, in the plugin install flow, or in a shared setup command used by both? -- [Affects R18-R20][Technical] How should existing env-var-based users migrate to durable machine profiles without breaking current workflows? - -## Next Steps - -→ /prompts:ce-plan for structured implementation planning diff --git a/docs/plans/2026-04-03-001-feat-kibana-features-v1-plan.md b/docs/plans/2026-04-03-001-feat-kibana-features-v1-plan.md deleted file mode 100644 index 748b8ec..0000000 --- a/docs/plans/2026-04-03-001-feat-kibana-features-v1-plan.md +++ /dev/null @@ -1,390 +0,0 @@ ---- -title: feat: Add schema-aware investigation features to Kibana MCP -type: feat -status: active -date: 2026-04-03 -origin: docs/sources/features-v1.md ---- - -# feat: Add schema-aware investigation features to Kibana MCP - -## Overview - -Evolve the existing Kibana MCP from a source-catalog search tool into a schema-aware investigation tool that can answer the remaining log-analysis needs in `docs/sources/features-v1.md` without forcing agents back to direct Elasticsearch calls or manual Kibana inspection. - -This is a second-phase plan. The repo already ships `configure`, `discover`, `filter`, and `query`, along with runtime source-catalog persistence and arbitrary hit sorting. The work here is to close the remaining capability gaps while keeping the MCP general and the public surface intentionally small. - -## Problem Frame - -The current MCP can already: - -- discover configured sources -- execute multi-source hits, count, histogram, and grouped-count queries -- run exact-field filtering through `filter` -- sort hits by arbitrary fields -- persist runtime source catalogs across MCP restarts - -It still cannot reliably answer investigation questions that depend on: - -- knowing whether `event` or `event.keyword` is the safe exact-match field -- inspecting nested array content such as `steps[]` -- paging through very large hit sets -- computing numeric summary stats and percentiles -- retrieving “top N per bucket” evidence instead of only global top hits - -Those gaps are exactly what drove the fallback described in `docs/sources/features-v1.md`. - -## Requirements Trace - -- F1. Add field and mapping introspection for configured sources. -- F2. Make exact-match filtering safe when the caller knows the logical field but not the exact keyword-safe variant. -- F3-F4. Support nested filtering and compact nested evidence extraction. -- F5. Add cursor-based pagination for large hit sets. -- F6. Add numeric stats aggregations, including percentiles. -- F7. Add grouped top-hit retrieval. -- F8. Support correlation-timeline workflows without forcing raw backend calls. -- F9. Avoid repeated re-bootstrap friction for environment-based configuration. - -## Scope Boundaries - -- No workflow-specific helper tool. -- No Redis inspection, trigger execution, or external API orchestration. -- No write access to Kibana, Elasticsearch, or data views. -- No silent rewriting of exact field names passed to `filter`; that tool remains the explicit “I know the backend field” path. -- No attempt to implement every possible Elasticsearch aggregation in this tranche. - -## Context & Research - -### Current Repo State - -- The public surface is implemented in `src/server.ts` and currently registers `configure`, `discover`, `filter`, and `query`. -- Query compilation is centralized in `src/query/compiler.ts`. -- Result shaping is centralized in `src/query/normalize.ts`. -- Backend execution is centralized in `src/kibana_client.ts`. -- Source metadata currently comes only from static configuration plus field hints in `src/source_catalog.ts`. -- `AGENTS.md` references `RTK.md`, but `RTK.md` is not present in this workspace, so no additional repo standards were available beyond the checked-in files and AGENTS instructions. - -### Institutional Learnings - -- `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md` established an important operating rule for this repo: persist non-secret runtime state, but keep credentials in environment variables. This matters because new schema-aware features must not reintroduce restart-sensitive configuration. - -### External References - -- Elastic field capabilities API documents that field metadata can expose `type`, `searchable`, and `aggregatable`, which makes it the right primitive for `describe_fields`. -- Elastic nested query and inner hits docs confirm that nested filtering and per-match extraction require actual `nested` mappings and that `inner_hits` is the supported way to return only matching nested objects. -- Elastic pagination docs recommend `search_after` over deep `from`/`size`, and they require stable sort values plus a tiebreaker for reliable continuation. -- Elastic aggregation docs confirm that `percentiles` is the correct primitive for p50/p95/p99 and that `top_hits` is intended as a sub-aggregation within a bucket aggregation. - -## Key Technical Decisions - -- Add one new public tool: `describe_fields`. This is the minimum surface increase that materially improves agent reliability. -- Keep `discover` lightweight. Full mapping output does not belong in discovery responses. -- Make `query` schema-aware. Logical exact filters and aggregations should resolve to the safest exact-match field variant when the caller uses logical field names. -- Keep `filter` literal. If the caller passes `event`, the server must use `event`; it may warn, but it must not silently rewrite. -- Treat nested support as a first-class query feature, but only for fields that are actually mapped as `nested`. -- Ship deep pagination only for `hits` and only for single-source requests in this tranche. Multi-source global pagination is materially more complex and not required to close the primary investigation gap. -- Add numeric stats and grouped top hits as additional `query` modes rather than additional public tools. -- Do not add a separate `timeline` tool in this tranche. A stable ascending `hits` query plus correlation filters, nested extraction, and cursor pagination is sufficient to support the timeline workflow without expanding the surface again. -- Do not add a `configure_from_env` tool. The existing startup env bootstrap plus persisted runtime source catalog already covers the operational need behind F9. - -## Open Questions - -### Resolved During Planning - -- Should schema introspection live in `discover` or a separate tool? Separate tool. `discover` must stay concise. -- Should exact-match safety rewrite `filter` requests automatically? No. `filter` is intentionally exact. -- Should grouped top hits be a new tool? No. It fits naturally as a `query` mode. -- Should timeline reconstruction become a dedicated tool now? No. The workflow can be covered by enhancing `query`. - -### Deferred to Implementation - -- Which backend path has the highest probability for schema introspection in the target environment: direct Elasticsearch-compatible field-cap calls, or a Kibana-mediated metadata path? -- Whether nested evidence extraction should default to source filtering or docvalue-style field selection for performance when `inner_hits` are large. -- Whether grouped top hits should initially support only one `group_by` field or allow a later nested-bucket extension. - -## High-Level Technical Design - -> This is directional guidance for review, not implementation code. - -```mermaid -flowchart TB - A[discover] --> B[SourceCatalog] - C[describe_fields] --> D[SchemaCatalog] - E[query/filter] --> F[QueryCompiler] - F --> D - F --> G[KibanaClient] - G --> H[ResponseNormalizer] - D --> C - H --> E -``` - -The architectural change is the introduction of schema awareness as a reusable layer. `describe_fields` exposes it directly, and `query` uses it to make safer choices for exact filtering, nested queries, pagination, and aggregation compilation. - -## Implementation Units - -- [ ] **Unit 1: Add schema metadata retrieval and the `describe_fields` tool** - -**Goal:** Expose per-source field capabilities so agents can stop guessing field variants, aggregatability, and nested paths. - -**Requirements:** F1, F2 - -**Dependencies:** None - -**Files:** -- Create: `src/schema_catalog.ts` -- Create: `src/tools/describe_fields.ts` -- Modify: `src/types.ts` -- Modify: `src/server.ts` -- Modify: `src/kibana_client.ts` -- Create: `test/schema_catalog.test.ts` -- Create: `test/describe_fields.test.ts` -- Modify: `test/server.test.ts` -- Modify: `README.md` - -**Approach:** -- Introduce a schema metadata layer that can fetch and cache field capabilities for a configured source. -- Use backend metadata that can expose at least: - - field name - - type family - - searchable - - aggregatable - - nested-path information when available - - subfield relationships such as `.keyword` -- Make `describe_fields(source_id)` return concise, agent-usable metadata rather than the raw backend payload. -- Include a `preferred_exact_field` or equivalent derived hint when a logical field has a safer exact-match variant. - -**Patterns to follow:** -- Keep backend I/O in `src/kibana_client.ts` or a sibling backend module, not inside tool handlers. -- Keep source-level schema caching in a dedicated module so query compilation can reuse it. - -**Test scenarios:** -- Happy path: `describe_fields` returns type, searchable, aggregatable, and subfield metadata for a configured source. -- Happy path: a text field with a `.keyword` multifield exposes the multifield and marks it as the preferred exact variant. -- Edge case: a field that exists with different type families across indices is surfaced as ambiguous instead of silently collapsed. -- Error path: schema metadata failure for one source produces a clear tool error. - -**Verification:** -- An agent can inspect a source and determine whether `event`, `event.keyword`, or another subfield is the safe choice for term filters and aggregations. - -- [ ] **Unit 2: Make `query` exact-match and aggregation resolution schema-aware** - -**Goal:** Let agents use logical field names safely in `query` without knowing backend mapping details. - -**Requirements:** F2, F6, F7, F8 - -**Dependencies:** Unit 1 - -**Files:** -- Modify: `src/query/compiler.ts` -- Modify: `src/query/normalize.ts` -- Modify: `src/tools/query.ts` -- Modify: `src/tools/filter.ts` -- Modify: `src/types.ts` -- Modify: `test/query_compiler.test.ts` -- Modify: `test/query_normalize.test.ts` -- Modify: `test/filter.test.ts` -- Modify: `README.md` - -**Approach:** -- Extend the query compiler so exact filter fields, `group_by`, and aggregation fields can resolve through schema metadata, not just static field hints. -- Prefer keyword-safe or otherwise exact-capable field variants for `query` when the logical field itself is not aggregatable or is unsafe for exact term semantics. -- Preserve `filter` as exact-field literal behavior. -- Add a structured `warnings` or `advisories` section to the response envelope so agents can explain when the effective field differed from the requested logical field. - -**Patterns to follow:** -- Continue treating `query` as the ergonomic path and `filter` as the explicit escape hatch. -- Keep all resolution decisions visible in `query_echo` or a sibling metadata section. - -**Test scenarios:** -- Happy path: `query` term filtering on a logical text field resolves to its keyword-safe exact field. -- Happy path: `terms` grouping on a logical field resolves to an aggregatable exact field. -- Happy path: `filter` still uses the literal field name passed by the caller. -- Edge case: no safe exact variant exists, and the response includes a clear warning or error instead of a misleading zero-hit result. - -**Verification:** -- An agent can ask for exact event matches without manually discovering or hardcoding `.keyword` first. - -- [ ] **Unit 3: Add nested filters and nested evidence extraction** - -**Goal:** Support object-level investigation inside nested log arrays such as `steps[]`. - -**Requirements:** F3, F4, F8 - -**Dependencies:** Units 1-2 - -**Files:** -- Modify: `src/types.ts` -- Modify: `src/query/compiler.ts` -- Modify: `src/query/normalize.ts` -- Modify: `src/tools/query.ts` -- Modify: `src/tools/filter.ts` -- Modify: `src/kibana_client.ts` -- Create: `test/nested_query_compiler.test.ts` -- Create: `test/nested_normalize.test.ts` -- Modify: `test/server.test.ts` -- Modify: `README.md` - -**Approach:** -- Add explicit nested-query input shape rather than overloading flat `filters`. -- Compile nested filters into Elasticsearch `nested` queries with `inner_hits` so the response can include only the matching nested objects. -- Add normalized nested match output that carries: - - root hit identifiers such as timestamp and document id - - matching nested object payloads - - nested path metadata -- Reject nested requests against paths that are not known to be mapped as `nested`. - -**Patterns to follow:** -- Keep nested semantics explicit in the public contract. Do not infer nesting from dotted field names alone. -- Keep raw parent documents available even when nested extraction is enabled. - -**Test scenarios:** -- Happy path: a nested filter returns parent hits plus only the matching nested object payload. -- Happy path: nested extraction for `steps.name` returns compact evidence fields for the matching object. -- Error path: requesting nested filtering on a non-nested path fails with a clear schema error. -- Edge case: multiple nested matches in one parent hit are all returned in stable order. - -**Verification:** -- An agent can inspect one problematic object inside a structured stats event without manually parsing the whole parent document. - -- [ ] **Unit 4: Add cursor pagination for large hit sets** - -**Goal:** Make large investigations workable when a correlation key yields tens of thousands of documents. - -**Requirements:** F5, F8 - -**Dependencies:** Units 1-2 - -**Files:** -- Modify: `src/types.ts` -- Modify: `src/query/compiler.ts` -- Modify: `src/query/normalize.ts` -- Modify: `src/tools/query.ts` -- Modify: `src/kibana_client.ts` -- Modify: `test/query_compiler.test.ts` -- Modify: `test/query_normalize.test.ts` -- Create: `test/pagination.test.ts` -- Modify: `README.md` - -**Approach:** -- Add cursor pagination for `hits` via `search_after`. -- Scope the first release to single-source `hits` queries only. -- Require a stable sort tuple: - - requested sort field or source time field - - deterministic tiebreaker field -- Return `next_cursor` only when another page exists. -- Encode the cursor as an opaque token that includes the source id, sort tuple, and continuation values so clients do not hand-craft backend details. - -**Patterns to follow:** -- Keep pagination mechanics in the compiler/client boundary, not in tool handlers. -- Make cursor invalidation explicit when the caller changes query shape or sort order. - -**Test scenarios:** -- Happy path: first page returns `next_cursor`, and the second page resumes from the last hit. -- Edge case: changing sort direction or source id with an old cursor is rejected. -- Edge case: queries without an explicit sort still paginate using a stable default tuple. -- Scope guard: multi-source hits with cursor pagination are rejected with a clear error in this tranche. - -**Verification:** -- An agent can walk a very large single-source result set without dropping to raw backend pagination. - -- [ ] **Unit 5: Add numeric stats and grouped top hits as new `query` modes** - -**Goal:** Support latency and cost analysis workflows that depend on summaries and “worst per key” evidence. - -**Requirements:** F6, F7, F8 - -**Dependencies:** Units 1-2 - -**Files:** -- Modify: `src/types.ts` -- Modify: `src/query/compiler.ts` -- Modify: `src/query/normalize.ts` -- Modify: `src/tools/query.ts` -- Modify: `test/query_compiler.test.ts` -- Modify: `test/query_normalize.test.ts` -- Create: `test/query_stats.test.ts` -- Create: `test/query_grouped_top_hits.test.ts` -- Modify: `README.md` - -**Approach:** -- Add `stats` mode for numeric summary queries on one field, with optional `group_by`. -- Implement the numeric summary using standard numeric aggs plus `percentiles` for p50, p95, and p99. -- Add `grouped_top_hits` mode that: - - buckets by one field - - sorts hits within each bucket by another field - - returns the top N hits per bucket -- Keep both modes per-source in the response envelope, matching the current multi-source aggregation pattern. - -**Patterns to follow:** -- Reuse the existing mode-driven compiler and normalizer instead of adding parallel handler code. -- Keep group semantics explicit in the response so agents can quote bucket keys and top evidence cleanly. - -**Test scenarios:** -- Happy path: `stats` returns count, min, max, avg, sum, p50, p95, and p99 for a numeric field. -- Happy path: grouped `stats` returns one stats block per bucket key. -- Happy path: `grouped_top_hits` returns the highest `total_duration_ms` hit per `request_id`. -- Edge case: stats on a non-numeric field fail clearly. -- Edge case: grouped top hits on a non-aggregatable bucket field fail clearly or emit a schema-backed warning before backend execution when possible. - -**Verification:** -- An agent can summarize duration distributions and fetch the worst reload event per correlation key through the MCP alone. - -- [ ] **Unit 6: Close the feature-document deltas in docs and compatibility tests** - -**Goal:** Finish the tranche by explicitly documenting what from `docs/sources/features-v1.md` is implemented, what is intentionally not a separate tool, and why. - -**Requirements:** F8, F9 - -**Dependencies:** Units 1-5 - -**Files:** -- Modify: `README.md` -- Modify: `docs/sources/features-v1.md` -- Modify: `test/server.test.ts` -- Optionally create: `docs/solutions/feature-shapes/schema-aware-querying-2026-04-03.md` - -**Approach:** -- Document that: - - `timeline` is covered through `query` plus sorting, filters, and cursor pagination - - `configure_from_env` is already operationally covered by startup env bootstrap and runtime source-catalog persistence -- Update feature examples so they show the intended investigation workflows directly. -- Add compatibility-style tests that prove the server advertises the expected tool surface after this tranche. - -**Patterns to follow:** -- Prefer documenting why a feature did not become a new tool instead of silently omitting it. - -**Test scenarios:** -- Happy path: tool registration includes `describe_fields` and does not introduce unnecessary extra helpers. -- Documentation parity: README examples match the final public request shapes. -- Regression: env-bootstrap startup plus persisted runtime sources still work after schema-aware additions. - -**Verification:** -- The feature list and the shipped public surface no longer diverge in ways that force agents to guess what exists. - -## System-Wide Impact - -- **Public surface:** Adds one new tool, `describe_fields`, and expands `query` rather than fragmenting the MCP into many specialized helpers. -- **Backend access shape:** Introduces read-only schema metadata calls in addition to search calls. -- **Response contract:** Adds advisories, nested evidence, cursor metadata, and new aggregate result shapes. -- **Performance profile:** Schema caching and pagination must prevent repeated heavy metadata fetches and deep-hit overfetching. -- **Failure modes:** The server must distinguish schema-unavailable, field-unsupported, and backend-search failures clearly. -- **Operational invariants:** Credentials remain environment-backed; runtime source persistence remains the only durable client-supplied state. - -## Sequencing - -1. Implement schema introspection first. Every other feature depends on accurate field knowledge. -2. Use that schema layer to harden exact-match resolution before adding more modes. -3. Add nested support next because it changes both compilation and normalization. -4. Add cursor pagination after sort semantics and nested response shapes are stable. -5. Add stats and grouped top hits last, reusing the now schema-aware compiler. -6. Finish with docs and compatibility cleanup so the final surface is coherent. - -## Recommended Execution Posture - -Implement this tranche characterization-first at the compiler and normalizer boundaries: - -- lock the current `query` and `filter` behavior with tests before changing schemas -- add schema-resolution tests before backend metadata wiring -- add integration-style tests for each new mode and response envelope change - -This repo is still small, but these features all change the MCP contract. The cheapest way to avoid drift is to keep the tests ahead of the handler and normalization changes. diff --git a/docs/plans/2026-04-06-001-refactor-kibana-mcp-professionalization-plan.md b/docs/plans/2026-04-06-001-refactor-kibana-mcp-professionalization-plan.md index de1ea48..f87aef3 100644 --- a/docs/plans/2026-04-06-001-refactor-kibana-mcp-professionalization-plan.md +++ b/docs/plans/2026-04-06-001-refactor-kibana-mcp-professionalization-plan.md @@ -63,7 +63,7 @@ The goal of this plan is to close those gaps without widening product scope. Thi ### Institutional Learnings -- `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md` establishes a project norm: persist non-secret operator state, keep credentials in environment variables. Release and onboarding docs should reinforce that split. +- `INSTALL.md` establishes the setup boundary: persist non-secret profile and catalog state separately from credentials saved in platform credential stores. Release and onboarding docs should reinforce that split. - `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` shows that environment compatibility is nuanced. Professional docs must not overpromise schema-aware behavior across all Kibana deployments. ### External References @@ -291,7 +291,7 @@ flowchart TB **Test scenarios:** - Happy path: the release candidate artifact contains the built runtime, plugin metadata, README, and license material required for redistribution. -- Edge case: untracked operator files such as `config/sources.runtime.json` or test-only fixtures are excluded from the release artifact. +- Edge case: local operator catalogs or test-only fixtures are excluded from the release artifact. - Error path: a missing built entrypoint or mismatched package include list fails pack verification before any tag-based release is published. - Integration: the release workflow uses the same build/test/contract verification path as normal CI before generating versioned outputs. @@ -364,7 +364,6 @@ flowchart TB - Related code: `plugins/kibana-log-investigation/.codex-plugin/plugin.json` - Related docs: `INSTALL.md` - Related docs: `README.md` -- Related solution: `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md` - Related solution: `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` - External docs: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/licensing-a-repository - External docs: https://docs.github.com/en/code-security/getting-started/quickstart-for-securing-your-repository diff --git a/docs/plans/2026-04-08-001-feat-github-landing-page-plan.md b/docs/plans/2026-04-08-001-feat-github-landing-page-plan.md deleted file mode 100644 index a930471..0000000 --- a/docs/plans/2026-04-08-001-feat-github-landing-page-plan.md +++ /dev/null @@ -1,212 +0,0 @@ ---- -title: feat: Create a GitHub landing page for Kibana MCP adoption -type: feat -status: superseded -date: 2026-04-08 -origin: docs/brainstorms/2026-04-02-kibana-log-investigation-requirements.md -superseded_by: docs/plans/2026-04-08-002-feat-github-pages-site-plan.md ---- - -# feat: Create a GitHub landing page for Kibana MCP adoption - -> [!NOTE] -> Historical plan. It was superseded by `docs/plans/2026-04-08-002-feat-github-pages-site-plan.md`, which was subsequently superseded by the implemented `docs/plans/2026-04-11-001-feat-github-pages-homepage-plan.md`. - -## Overview - -Rework the repository's public-facing GitHub presentation so a first-time visitor can understand why this MCP exists, what it can do, and how to install it quickly without reading the full technical reference first. The primary delivery surface is `README.md`, with supporting copy alignment in install and plugin-facing documentation where needed. - -## Problem Frame - -The repo already contains a credible MCP implementation, installation handoff, and support-policy documentation, but the current top-level README reads like technical product reference material rather than an acquisition and activation surface. A visitor landing on the repository can eventually find the install path and feature set, but the page does not currently lead with the user problem, the product value, or the fastest supported route to getting the MCP running. - -This plan keeps the product framing grounded in the original Kibana MCP requirements: a small, reliable, read-only investigation surface for agents working against Kibana-backed logs (see origin: `docs/brainstorms/2026-04-02-kibana-log-investigation-requirements.md`). It also keeps the install story grounded in the currently supported repo-local Codex plugin path, as documented in `INSTALL.md`, `docs/project/distribution-strategy.md`, and `docs/project/support-policy.md`. - -## Requirements Trace - -- R1. The GitHub landing surface must explain why the repository exists in outcome-first language before diving into detailed technical reference material. -- R2. The landing surface must present the current feature set accurately, grounded in the supported MCP capabilities and known environment constraints. -- R3. The landing surface must provide a fast, credible install path that emphasizes the guaranteed repo-local Codex plugin workflow and does not overstate the planned public package path. -- R4. The quick-start flow must get a visitor from clone to first useful use without forcing them to read the full configuration reference up front. -- R5. Public-facing copy across README, install handoff, and plugin metadata must stay aligned so users are not told conflicting stories about distribution, support, or first-run setup. - -## Scope Boundaries - -- No separate GitHub Pages or external marketing site. -- No new MCP tools, backend behavior, or packaging work beyond documentation and metadata alignment. -- No change to the current support contract that repo-local Codex plugin install is the guaranteed path and public package install is planned rather than default. -- No attempt to hide environmental limitations such as schema endpoint variability or manual plugin-install fallback on some Codex model variants. -- No GitHub repository settings automation; any repo "About" blurb or social-preview image updates remain manual follow-up outside this plan. - -## Context & Research - -### Relevant Code and Patterns - -- `README.md` is already the repository's public entry point, but its opening structure is reference-heavy and tool-oriented. -- `INSTALL.md` is the canonical operator handoff and already defines the supported fast path: clone, ensure Node.js 22+, `npm install`, `npm run build`, install the repo-local plugin, then configure the MCP. -- `docs/project/distribution-strategy.md` establishes that repo-local Codex plugin install is guaranteed and public package distribution is planned but gated. -- `docs/project/support-policy.md` captures important install and environment constraints that should be summarized, not hidden, in public-facing copy. -- `plugins/kibana-log-investigation/.codex-plugin/plugin.json` is a user-facing metadata surface inside Codex and should not drift from the README's positioning. - -### Institutional Learnings - -- `docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md` shows that install handoff quality improves when the repo-local plugin path, environment naming, manual fallback flow, and `KIBANA_BASE_URL` rule are all explicit and self-contained. -- `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md` reinforces that runtime configuration persistence is part of the operator story and should be explained accurately when describing setup. -- `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` reinforces that schema-aware features are environment-dependent and should be presented as best-effort rather than universal. - -### External References - -- None. The repository already has strong local guidance for install posture, support boundaries, and product framing, so external research would add little value for this plan. - -## Key Technical Decisions - -- **Treat `README.md` as the GitHub landing page:** The user request is best satisfied by restructuring the repository README rather than introducing a separate site or docs app. -- **Lead with operator problem and activation path, not tool inventory:** The opening sections should explain who this is for, why it exists, and what outcome it enables before listing detailed tool shapes. -- **Keep the quick install path anchored to the guaranteed repo-local workflow:** The README should present the current supported path clearly and mention the future `npx` path only as planned status, not as the primary call to action. -- **Use layered documentation instead of stuffing every caveat into the hero section:** README should offer concise positioning, features, and quick-start steps, then link to `INSTALL.md` and project policy docs for deeper detail. -- **Align all public-facing descriptions with the actual product contract:** README, install handoff, and Codex plugin metadata should all tell the same story about capabilities, limitations, and setup. - -## Open Questions - -### Resolved During Planning - -- Does "GitHub landing page" mean a separate hosted page? No. For this repo, the right implementation target is the top-level `README.md`. -- Should the landing page promote public package installation as the default path? No. The guaranteed path remains repo-local Codex plugin install; public package install stays clearly marked as planned. -- Should the README keep every setup caveat inline? No. The README should summarize the critical path and link to deeper install/support docs for full operator detail. - -### Deferred to Implementation - -- Whether the first README revision needs a visual asset such as a screenshot, diagram, or social-preview image, or whether a strong text-first structure is sufficient. -- Whether maintainers also want to manually update the GitHub repository "About" blurb, topic tags, or social preview to mirror the new README positioning. -- Whether a lightweight documentation contract test is worth adding later if copy drift becomes recurrent across README and plugin metadata. - -## High-Level Technical Design - -> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* - -```mermaid -flowchart TD - A["README hero: what this is and who it serves"] --> B["Why it exists: agent-driven Kibana log investigation"] - B --> C["Feature summary: discover, query, filter, configure, schema best-effort"] - C --> D["Quick install: clone -> install deps -> build -> install plugin -> configure"] - D --> E["First-use guidance: how to get to first investigation quickly"] - E --> F["Deep links: INSTALL, support policy, distribution strategy"] -``` - -The intended shape is a funnel: orient the visitor, prove value quickly, give them a supported install path, then move deeper details lower on the page or into linked operator docs. - -## Implementation Units - -- [ ] **Unit 1: Reframe the README above the fold** - -**Goal:** Make the first screen of the repository immediately explain the product, audience, and reason it exists. - -**Requirements:** R1, R2 - -**Dependencies:** None - -**Files:** -- Modify: `README.md` - -**Approach:** -- Replace the current reference-style opening with outcome-first positioning for operators and agents investigating incidents through Kibana-backed logs. -- Add a concise "why it exists" section derived from the origin requirements so visitors understand the user problem before the tool list. -- Summarize the core supported capabilities in landing-page language while preserving accuracy: read-only posture, configured logical sources, discovery, querying, filtering, and environment-dependent schema support. -- Push exhaustive configuration and backend detail lower in the file so the top of the README behaves like a landing page rather than an API manual. - -**Patterns to follow:** -- Reuse the product framing from `docs/brainstorms/2026-04-02-kibana-log-investigation-requirements.md`. -- Keep support caveats consistent with `docs/project/support-policy.md`. - -**Test scenarios:** -- Test expectation: none -- this unit changes documentation structure and messaging, not executable behavior. - -**Verification:** -- A first-time GitHub visitor can answer "What is this?", "Who is it for?", and "Why does it exist?" from the README opening without scrolling into the full reference content. - -- [ ] **Unit 2: Add a credible quick-install and first-use funnel** - -**Goal:** Make the path from repo landing to first working Kibana MCP session short, obvious, and accurate. - -**Requirements:** R3, R4 - -**Dependencies:** Unit 1 - -**Files:** -- Modify: `README.md` -- Modify: `INSTALL.md` - -**Approach:** -- Add a short quick-start sequence near the top of `README.md` that uses the guaranteed repo-local flow: clone, ensure Node.js 22+, `npm install`, `npm run build`, install `Kibana Log Investigation`, then configure the server. -- Include one concise "what to do next" handoff after install so the visitor knows how to get from installation into discovery/query usage. -- Keep the future `npx` path visible only as planned status so the README stays honest about current distribution. -- Use `INSTALL.md` as the deep-link target for full setup detail, multi-environment notes, manual plugin-install fallback, and `KIBANA_BASE_URL` rules instead of duplicating all edge-case guidance inline. - -**Patterns to follow:** -- Reuse the fast-path language already present in `INSTALL.md`. -- Keep distribution claims aligned with `docs/project/distribution-strategy.md`. - -**Test scenarios:** -- Test expectation: none -- this unit refines installation guidance and user flow copy rather than runtime logic. - -**Verification:** -- A visitor can follow the README quick-start path without ambiguity about the supported install method or the next step after installation. - -- [ ] **Unit 3: Align supporting public metadata and deep links** - -**Goal:** Prevent public-facing copy drift between the README and other user-visible repository surfaces. - -**Requirements:** R2, R3, R5 - -**Dependencies:** Units 1-2 - -**Files:** -- Modify: `README.md` -- Modify: `plugins/kibana-log-investigation/.codex-plugin/plugin.json` -- Modify: `docs/project/support-policy.md` -- Modify: `docs/project/distribution-strategy.md` - -**Approach:** -- Review the Codex plugin's short and long descriptions so they reinforce the same positioning as the README instead of introducing a second story about installation or capabilities. -- Tighten README deep links into support and distribution docs so users who need detail can find it from the landing page without hunting. -- Adjust wording in support/distribution docs only if the new README framing exposes mismatches or outdated phrasing; avoid gratuitous rewrites. -- Record any out-of-repo follow-up, such as a manual GitHub "About" update, as an operational note rather than stretching this implementation into repository-settings work. - -**Patterns to follow:** -- Keep plugin-facing messaging consistent with `plugins/kibana-log-investigation/.codex-plugin/plugin.json`. -- Preserve the guaranteed-vs-planned install split already defined in `docs/project/distribution-strategy.md`. - -**Test scenarios:** -- Test expectation: none -- this unit aligns public-facing copy and documentation links, not behavior under test. - -**Verification:** -- Users see one consistent product and install story across the GitHub README, Codex plugin metadata, and linked policy docs. - -## System-Wide Impact - -- **Interaction graph:** `README.md` becomes the front door, `INSTALL.md` remains the full operator handoff, and `docs/project/support-policy.md` plus `docs/project/distribution-strategy.md` stay the authoritative sources for caveats and install posture. -- **Error propagation:** If README claims drift from support/distribution reality, users will fail during setup and support churn will increase even though the runtime code is unchanged. -- **State lifecycle risks:** Duplicated quick-start and caveat text can drift over time; the plan mitigates this by keeping README concise and linking to deeper single-purpose docs for detail. -- **API surface parity:** Feature summaries must remain consistent with the actual MCP surface (`configure`, `describe_fields`, `discover`, `filter`, `query`) and with the repo's current "planned vs guaranteed" distribution contract. -- **Integration coverage:** Manual README rendering review on GitHub is the meaningful cross-surface verification here, because the change spans repo docs and Codex plugin presentation rather than executable runtime behavior. -- **Unchanged invariants:** The MCP remains read-only, log-investigation-focused, and primarily distributed through the repo-local Codex plugin workflow. - -## Risks & Dependencies - -| Risk | Mitigation | -|------|------------| -| The README becomes too long or collapses back into reference-style prose | Keep the top section tightly focused on positioning, features, and quick start, then link to deeper docs for configuration detail | -| The landing page overpromises package availability or schema support | Review all new copy against `docs/project/distribution-strategy.md` and `docs/project/support-policy.md` before landing | -| Quick-start instructions drift from the canonical install handoff over time | Keep `INSTALL.md` as the full procedure and make the README a concise funnel into it rather than a duplicate operator manual | - -## Documentation / Operational Notes - -- After the README rewrite lands, review the rendered GitHub page in the browser to confirm section order, scannability, and code-block density feel like a landing page rather than a reference dump. -- If maintainers want the GitHub repository "About" description or social preview image to match the new README framing, that is a separate manual GitHub settings task outside this repo. - -## Sources & References - -- **Origin document:** `docs/brainstorms/2026-04-02-kibana-log-investigation-requirements.md` -- Related docs: `README.md`, `INSTALL.md`, `docs/project/distribution-strategy.md`, `docs/project/support-policy.md` -- Related learnings: `docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md`, `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md`, `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` -- Related metadata: `plugins/kibana-log-investigation/.codex-plugin/plugin.json` diff --git a/docs/plans/2026-04-08-002-feat-github-pages-site-plan.md b/docs/plans/2026-04-08-002-feat-github-pages-site-plan.md deleted file mode 100644 index add2a28..0000000 --- a/docs/plans/2026-04-08-002-feat-github-pages-site-plan.md +++ /dev/null @@ -1,300 +0,0 @@ ---- -title: feat: Create a GitHub Pages site for Kibana MCP adoption -type: feat -status: superseded -date: 2026-04-08 -origin: docs/brainstorms/2026-04-02-kibana-log-investigation-requirements.md -deepened: 2026-04-08 -superseded_by: docs/plans/2026-04-11-001-feat-github-pages-homepage-plan.md ---- - -# feat: Create a GitHub Pages site for Kibana MCP adoption - -> [!NOTE] -> Historical plan. It was superseded by the implemented `docs/plans/2026-04-11-001-feat-github-pages-homepage-plan.md`. - -## Overview - -Create a real GitHub Pages site for this repository that explains why the Kibana MCP exists, what it can do, and how to install it quickly, then reposition `README.md` to act as the repo-native funnel into that hosted site and the deeper operator docs. - -This plan supersedes the narrower README-only direction in `docs/plans/2026-04-08-001-feat-github-landing-page-plan.md`. The user explicitly wants the hosted GitHub Pages surface first and the GitHub repository landing page second. - -## Problem Frame - -The repository already has credible operator docs, installation handoff, and support-policy material, but they are optimized for someone who has already committed to the repo rather than someone evaluating whether this MCP is worth adopting. The current top-level README can be improved, but it is still constrained by the repository context and by the need to carry technical reference material. - -The user now wants a dedicated GitHub Pages site that can behave like a lightweight product site: explain the problem, present the features, and offer a fast install path without forcing first-time visitors into the full reference surface immediately. That public site must stay grounded in the actual product contract: a small, read-only MCP for investigation work against Kibana-backed logs, installed today through the repo-local Codex plugin workflow (see origin: `docs/brainstorms/2026-04-02-kibana-log-investigation-requirements.md`, `INSTALL.md`, and `docs/project/distribution-strategy.md`). - -## Requirements Trace - -- R1. Provide a real GitHub Pages site for this repository, not just a rewritten `README.md`. -- R2. The Pages site must explain why the MCP exists in outcome-first language before diving into technical reference detail. -- R3. The Pages site must present the current feature set accurately, including read-only posture and environment-dependent schema behavior. -- R4. The site must offer a fast, credible installation path centered on the guaranteed repo-local Codex plugin workflow and must not overstate the future public package path. -- R5. `README.md` must be repositioned to support the hosted site rather than competing with it, while still serving repo-native readers. -- R6. The deployment approach must stay low-maintenance, repo-native, and compatible with the repo’s current Node/GitHub Actions posture. -- R7. Public-facing copy across the Pages site, `README.md`, plugin metadata, and linked project docs must remain aligned. - -## Scope Boundaries - -- No external hosting beyond GitHub Pages. -- No server-side runtime, analytics platform, CMS, or interactive backend for the site. -- No change to MCP runtime behavior, tool shapes, or install support posture beyond public-facing documentation and metadata alignment. -- No custom domain requirement in this tranche. -- No manual GitHub repository setting automation beyond documenting the required GitHub Pages configuration step. -- No attempt to replace `INSTALL.md`, `README.md`, or `docs/project/*` as the long-form operator references. - -## Context & Research - -### Relevant Code and Patterns - -- `README.md` is currently the public entry point, but it is still primarily a reference document rather than a dedicated adoption surface. -- `INSTALL.md` is the canonical operator handoff and already contains the supported fast path: clone, ensure Node.js 22+, `npm install`, `npm run build`, install the repo-local plugin, then configure the MCP. -- `docs/project/distribution-strategy.md` establishes that the guaranteed install path is the repo-local Codex plugin workflow, while public package execution is planned but gated. -- `docs/project/support-policy.md` captures important limitations that the public site must summarize honestly, especially the manual plugin-install fallback on some Codex variants and best-effort schema support. -- `.github/workflows/ci.yml` and `.github/workflows/release.yml` show that the repo already uses GitHub Actions and Node-based automation; there is no existing docs-site or Pages deployment workflow to inherit. -- `package.json` and `plugins/kibana-log-investigation/.codex-plugin/plugin.json` currently point public-facing homepage and website metadata back to the GitHub repository, which becomes a drift risk once a dedicated public site exists. - -### Institutional Learnings - -- `docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md` shows that adoption quality improves when the install path, environment naming rules, manual fallback, and `KIBANA_BASE_URL` constraints are explicit and self-contained. -- `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md` reinforces that configuration persistence is part of the operator story and should be described accurately in any public onboarding path. -- `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` reinforces that schema-aware behavior is environment-dependent and should be positioned as best-effort, not universal. - -### External References - -- GitHub Pages docs say an existing repository can publish a site either from a branch/folder or from a custom GitHub Actions workflow, and recommend a custom workflow when you want a build process other than Jekyll or do not want a dedicated branch for compiled static files. This is directly relevant because this repo already uses `docs/` for internal documentation and has no existing Pages branch or site build stack. Source: [GitHub Docs: Configuring a publishing source for your GitHub Pages site](https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site). -- GitHub Pages docs say a custom workflow should use `actions/configure-pages`, `actions/upload-pages-artifact`, and `actions/deploy-pages`, with `pages: write` and `id-token: write` permissions on the deploy job. Source: [GitHub Docs: Using custom workflows with GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages). -- GitHub Pages docs say the deployed artifact must contain the entry file at the top level. Source: [GitHub Docs: Creating a GitHub Pages site](https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site). - -## Key Technical Decisions - -- **Create a dedicated `site/` source tree for the public Pages site:** This keeps the public site separate from the repo’s internal `docs/` tree, which already contains plans, brainstorms, solution notes, and project-policy documents that should not become the publishing root. -- **Use a custom GitHub Actions Pages workflow instead of branch- or `/docs`-based publishing:** This follows GitHub’s current recommendation for non-Jekyll publishing and avoids turning the existing `docs/` folder into a public deploy source or introducing a separate compiled branch. -- **Keep the site build-light and static-first:** Given the repo’s current shape, a small static site with checked-in HTML/CSS/assets is a better fit than adding a new site generator or frontend framework purely for marketing copy and onboarding. This is an inference from the repo structure plus GitHub’s guidance that custom workflows can deploy arbitrary static artifacts. -- **Use the site as the primary narrative surface and `README.md` as the repo-native conversion surface:** Visitors arriving through GitHub Pages should get the full public story there, while repository visitors should get a concise explanation plus a clear CTA into the site and installation docs. -- **Preserve one truthful install story across all surfaces:** The site, README, plugin metadata, and package metadata must all reinforce that repo-local Codex plugin install is the guaranteed path today and that public package usage is planned rather than current default behavior. - -## Open Questions - -### Resolved During Planning - -- Should this be a hosted GitHub Pages site or just a README refresh? Hosted GitHub Pages site first, README second, per user instruction. -- Should the Pages source come from the existing `/docs` folder? No. This repo already uses `docs/` for internal documentation and planning artifacts, so making it the Pages publishing root would mix public marketing content with internal project docs. -- Should the repo introduce Jekyll or another new site stack by default? No. The repo has no existing docs-site stack, and a small static site deployed via a custom GitHub Actions workflow is the lower-maintenance fit. - -### Deferred to Implementation - -- Whether the public site should be single-page only or include one or two additional static pages such as `/install` or `/faq` after the first draft is visible. -- Whether maintainers want to move `package.json` `homepage` and plugin `websiteURL` immediately to the Pages URL or keep the repository URL until the first deployment is live. -- Whether a future custom domain or social-preview image should be added once the Pages site content stabilizes. - -## High-Level Technical Design - -> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* - -```mermaid -flowchart TB - A["GitHub Pages site"] --> B["Why this exists"] - A --> C["Feature summary"] - A --> D["Quick install path"] - D --> E["INSTALL.md for full setup"] - A --> F["Repository and source links"] - G["README.md"] --> A - G --> E - H["Plugin/package metadata"] --> A -``` - -The intended shape is a two-surface funnel: the hosted site carries the public narrative and quick activation path, while the repository README and metadata point users into that site and then into deeper operator docs. - -## Implementation Units - -- [ ] **Unit 1: Define and build the public Pages site surface** - -**Goal:** Create the checked-in static site source that presents the product story, feature set, and supported quick-start path. - -**Requirements:** R1, R2, R3, R4 - -**Dependencies:** None - -**Files:** -- Create: `site/index.html` -- Create: `site/styles.css` -- Create: `site/404.html` -- Optionally create: `site/assets/` -- Create: `test/site_contract.test.ts` - -**Approach:** -- Build a dedicated public site entry point in `site/` rather than repurposing `README.md` or the internal `docs/` tree. -- Structure the first version around four content jobs: why it exists, what it does, quick install, and where to go next for setup/reference. -- Keep install guidance concise and outcome-oriented on the site itself, then deep-link to `INSTALL.md`, `README.md`, and the relevant project-policy docs for detailed operator guidance. -- Present schema-aware capabilities and install automation honestly, using the best-effort language already established in project docs rather than polishing away those constraints. - -**Execution note:** Add the site contract test alongside the first page so future copy or structure edits cannot silently remove the required product sections. - -**Patterns to follow:** -- Reuse the product framing from `docs/brainstorms/2026-04-02-kibana-log-investigation-requirements.md`. -- Reuse install constraints and fast-path language from `INSTALL.md`. -- Keep support caveats aligned with `docs/project/support-policy.md`. - -**Test scenarios:** -- Happy path: the site entry file exposes distinct sections or landmarks for product purpose, feature summary, and quick install. -- Happy path: the site includes outbound links to the repository, `INSTALL.md`, and deeper documentation targets. -- Edge case: the custom `404.html` preserves a path back to the install/repo surfaces instead of dead-ending the visitor. -- Error path: if future edits remove the supported-install language or Pages entry file, `test/site_contract.test.ts` fails. -- Integration: the checked-in site content references the same guaranteed install path and support posture as the repo docs, not a divergent story. - -**Verification:** -- Opening the built Pages artifact locally or from the deployed site makes the product, feature set, and install CTA understandable without reading the full README first. - -- [ ] **Unit 2: Add GitHub Pages deployment workflow and repository contract** - -**Goal:** Deploy the `site/` artifact through GitHub Pages in a repo-native, low-maintenance way. - -**Requirements:** R1, R6 - -**Dependencies:** Unit 1, repository admin or maintainer access to configure GitHub Pages for this repository - -**Files:** -- Create: `.github/workflows/pages.yml` -- Modify: `test/project_contract.test.ts` - -**Approach:** -- Add a dedicated Pages workflow that follows GitHub’s current custom-workflow model using `actions/configure-pages`, `actions/upload-pages-artifact`, and `actions/deploy-pages`. -- Configure the workflow around the repo’s current default branch and manual dispatch, with a separate deploy job that has the required `pages: write` and `id-token: write` permissions. -- Upload only the `site/` artifact so the published surface stays intentionally curated and does not accidentally expose internal documentation trees. -- Document the required GitHub repository setting change in the plan’s operational notes: Pages source must be set to GitHub Actions. - -**Patterns to follow:** -- Mirror the repository’s existing GitHub Actions conventions in `.github/workflows/ci.yml` and `.github/workflows/release.yml`. -- Follow GitHub’s official Pages workflow contract from the external references above. - -**Test scenarios:** -- Happy path: workflow configuration targets the Pages artifact path and includes the official configure/upload/deploy actions. -- Happy path: the deploy job declares the `github-pages` environment and required permissions. -- Error path: if the workflow is removed or the artifact path no longer points at `site/`, the project-contract test fails. -- Integration: the deployed artifact contains a top-level entry file as required by GitHub Pages and is independent of the repo’s internal `docs/` tree. - -**Verification:** -- A push to the default branch can deploy the `site/` artifact through GitHub Pages without requiring a dedicated `gh-pages` branch or a Jekyll build path. - -- [ ] **Unit 3: Reposition the repository landing surfaces around the hosted site** - -**Goal:** Make `README.md` and repo-facing metadata reinforce the hosted site instead of competing with it. - -**Requirements:** R4, R5, R7 - -**Dependencies:** Units 1-2 - -**Files:** -- Modify: `README.md` -- Modify: `plugins/kibana-log-investigation/.codex-plugin/plugin.json` -- Modify: `package.json` -- Test: `test/site_contract.test.ts` -- Test: `test/package_contract.test.ts` - -**Approach:** -- Rewrite the top of `README.md` so repository visitors immediately understand the product and are directed to the hosted Pages site for the polished overview, while still preserving a fast path into install and technical reference material. -- Update plugin and package metadata so public-facing URLs and descriptions do not keep pointing users only at the repository once the hosted site exists. -- Keep the README shorter and more conversion-oriented than the full reference; it should summarize the value and install path, then link outward instead of trying to duplicate the entire public site. -- If changing `package.json` `homepage` immediately feels too coupled to first deployment timing, preserve that as a consciously deferred implementation decision rather than leaving it inconsistent by accident. - -**Patterns to follow:** -- Preserve the guaranteed-vs-planned install split already documented in `docs/project/distribution-strategy.md`. -- Keep plugin messaging aligned with the current interface fields in `plugins/kibana-log-investigation/.codex-plugin/plugin.json`. - -**Test scenarios:** -- Happy path: `README.md` points repo visitors to the hosted site while still exposing the supported install flow. -- Happy path: plugin/package metadata use URLs and descriptions that align with the hosted site and repo story. -- Error path: if metadata drifts back to an outdated install or capability description, the relevant contract test fails. -- Integration: repository visitors can move coherently from README -> hosted site -> install docs without contradictory claims about packaging, support, or environment limits. - -**Verification:** -- Users landing through GitHub repository surfaces receive one coherent story and one clear path into the hosted site and install docs. - -- [ ] **Unit 4: Fold the Pages site into release and documentation governance** - -**Goal:** Keep the new public site from becoming an unmanaged side surface that drifts away from the real product contract. - -**Requirements:** R6, R7 - -**Dependencies:** Units 2-3 - -**Files:** -- Modify: `docs/project/release-checklist.md` -- Optionally modify: `docs/project/distribution-strategy.md` -- Optionally modify: `docs/project/support-policy.md` -- Test: `test/project_contract.test.ts` - -**Approach:** -- Add release/verification guidance so maintainers explicitly review the Pages site, its deployment, and its public claims as part of ongoing repo hygiene. -- Update distribution/support docs only where the new hosted site changes the public entrypoint or exposes wording drift; do not rewrite those docs gratuitously. -- Treat the Pages site as part of the repository’s public contract, not as disposable marketing copy. The site must stay aligned with the same install, compatibility, and support posture already documented elsewhere. - -**Patterns to follow:** -- Reuse the repo’s existing contract-test mindset from `test/package_contract.test.ts` and `test/project_contract.test.ts`. -- Keep policy documents in `docs/project/` authoritative for support/distribution specifics. - -**Test scenarios:** -- Happy path: release/governance docs explicitly mention Pages verification and public-surface review. -- Edge case: a future maintainer updates support/distribution posture and has an obvious place to keep the site aligned. -- Integration: the public site, README, and policy docs continue to describe the same guaranteed install path and known constraints. - -**Verification:** -- The hosted site is part of normal repository maintenance instead of becoming a one-off launch artifact that drifts from reality. - -## System-Wide Impact - -```mermaid -flowchart TB - A["GitHub Pages workflow"] --> B["Hosted site"] - C["README.md"] --> B - D["Plugin metadata"] --> B - E["package.json homepage"] --> B - B --> F["INSTALL.md"] - B --> G["Project policy docs"] -``` - -- **Interaction graph:** GitHub Pages workflow, hosted static assets, `README.md`, plugin/package metadata, and the existing install/support docs all become one public adoption system rather than loosely related files. -- **Error propagation:** If the site overpromises install simplicity or schema behavior, users will fail during setup even though the MCP runtime itself is unchanged. -- **State lifecycle risks:** Public site copy can drift away from README and policy docs unless contract tests and release-checklist review make alignment explicit. -- **API surface parity:** The site’s feature descriptions must continue to match the real MCP tool surface (`configure`, `describe_fields`, `discover`, `filter`, `query`) and the project’s guaranteed-vs-planned install contract. -- **Integration coverage:** The meaningful cross-layer verification is not runtime logic; it is proving that Pages deployment, site content, repo metadata, and linked docs still describe the same product. -- **Unchanged invariants:** The MCP remains read-only, log-investigation-focused, and guaranteed today through the repo-local Codex plugin workflow. - -## Risks & Dependencies - -| Risk | Mitigation | -|------|------------| -| The hosted site becomes a second, drifting product story | Add contract tests and release-checklist review that treat the site as part of the repo’s public contract | -| The implementation over-engineers a simple static marketing surface with a new frontend stack | Keep the first tranche static-first and deploy only checked-in artifacts unless a later need clearly justifies more tooling | -| The Pages deployment path accidentally exposes internal `docs/` content or depends on Jekyll assumptions | Deploy only the curated `site/` artifact through a custom Pages workflow | -| The public site overstates future package availability or universal schema support | Review site copy against `docs/project/distribution-strategy.md` and `docs/project/support-policy.md` before landing | -| No one with the required repo permissions completes the Pages configuration step | Call out the maintainer/admin prerequisite explicitly and make Pages-source setup part of verification | -| Repo settings are forgotten and the workflow exists without a live site | Document the required Pages source setting (`GitHub Actions`) in the operational notes and verification criteria | - -## Documentation / Operational Notes - -- Repository settings must be updated so GitHub Pages uses `GitHub Actions` as the publishing source. -- Someone with admin or maintainer permissions must perform that Pages configuration step. -- After the first deployment, manually verify the rendered site, navigation, and links from the live Pages URL, not just the checked-in files. -- If maintainers later want a custom domain, social-preview image, or richer site IA, treat that as a follow-on plan rather than bloating this first Pages tranche. - -## Sources & References - -- **Origin document:** `docs/brainstorms/2026-04-02-kibana-log-investigation-requirements.md` -- Related plan superseded in scope: `docs/plans/2026-04-08-001-feat-github-landing-page-plan.md` -- Related docs: `README.md` -- Related docs: `INSTALL.md` -- Related docs: `docs/project/distribution-strategy.md` -- Related docs: `docs/project/support-policy.md` -- Related metadata: `plugins/kibana-log-investigation/.codex-plugin/plugin.json` -- Related metadata: `package.json` -- Related workflows: `.github/workflows/ci.yml` -- Related workflows: `.github/workflows/release.yml` -- Related learnings: `docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md` -- Related learnings: `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md` -- Related learnings: `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` -- External docs: https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site -- External docs: https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site -- External docs: https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages diff --git a/docs/plans/2026-04-09-001-feat-ergonomic-install-setup-plan.md b/docs/plans/2026-04-09-001-feat-ergonomic-install-setup-plan.md deleted file mode 100644 index bcc2c33..0000000 --- a/docs/plans/2026-04-09-001-feat-ergonomic-install-setup-plan.md +++ /dev/null @@ -1,407 +0,0 @@ ---- -title: feat: Add ergonomic install and durable machine setup -type: feat -status: active -date: 2026-04-09 -origin: docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md -deepened: 2026-04-09 ---- - -# feat: Add ergonomic install and durable machine setup - -## Overview - -Turn the current technical-operator onboarding flow into a durable end-user install experience with two first-class entry surfaces: a human copy-paste install command and an agent copy-paste prompt. The normal path should launch guided setup immediately, store secrets in the platform credential store, persist a machine-level default profile, and let future threads use the MCP without rerunning `configure`. - -This plan also preserves the current truth that public package installation is not yet the guaranteed default path. The implementation should therefore support a staged rollout: land the shared setup architecture and repo-based fallback first, while making the clone-free package path a controlled activation once publish prerequisites are satisfied (see origin: `docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md`). - -## Problem Frame - -The repo currently serves technical operators reasonably well, but it still exposes too much setup machinery to normal users. The top-level docs already contain a useful long-form prompt, and the runtime already persists non-secret source catalogs, but the primary onboarding contract still assumes one or more of the following: - -- a user or agent will define `KIBANA_*` variables manually -- a thread will call `configure` explicitly before the MCP is useful -- installation is primarily a repo-local developer workflow, not a normal machine install surface - -That model breaks down for the user goal captured in the origin document: non-technical users should be able to install by copying one shell command or one agent prompt, answer a short guided setup once, and then reuse the tool across later threads on the same machine without repeating configuration (see origin: `docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md`). - -## Requirements Trace - -- R1-R5. Add first-class install surfaces for humans and agents, with a staged clone-free path and a credible repo-based fallback. -- R6-R9. Replace manual env-var onboarding with immediate guided setup that validates common mistakes and supports optional additional environments. -- R10-R13. Persist a machine-level default profile so new threads work without rerunning `configure`, while still supporting optional named environments. -- R14-R17. Store secrets in platform credential stores and keep non-secret machine state separate, with explicit fallback behavior when secret storage is unavailable. -- R18-R20. Keep the guided setup behavior consistent across install surfaces and demote env vars to an advanced compatibility path rather than the default onboarding story. - -## Scope Boundaries - -- No browser-based OAuth or SSO flow. -- No multi-machine profile sync or team-wide credential distribution. -- No removal of the advanced env-var bootstrap path for power users or automation. -- No requirement for a graphical desktop UI; an ergonomic CLI/agent setup flow is sufficient. -- No attempt to solve organization-wide package distribution policy beyond the repo’s existing trusted-publishing and package-ownership boundaries. - -## Context & Research - -### Relevant Code and Patterns - -- `src/index.ts` currently serves as both the npm `bin` entrypoint and the MCP stdio server entrypoint. It only loads startup config from environment variables and otherwise starts unconfigured. -- `src/server.ts` already supports runtime `configure`, but when no startup config is resolved it throws `Server is not configured. Call the 'configure' tool first.` on normal tool use. -- `src/config.ts` already encodes one useful separation of concerns: secrets come from environment variables and non-secret source catalogs persist to `config/sources.runtime.json`. -- `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md` established a repo norm worth preserving: persist non-secret operator state, but do not spill secrets into repo-local tracked files. -- `plugins/kibana-log-investigation/.mcp.json`, `scripts/verify-mcp-entrypoint.mjs`, `test/project_contract.test.ts`, and `test/package_contract.test.ts` show that this repo prefers explicit cross-file contract checks for packaging and startup behavior. -- `plugins/kibana-log-investigation/skills/install-and-configure/SKILL.md`, `INSTALL.md`, and `README.md` currently encode a repo-local, env-var-heavy install story. These are the main public surfaces that will need to change together. -- `package.json` is already prepared for future public distribution with `publishConfig.access`, `publishConfig.provenance`, and a named package, but the artifact contract still assumes the same file is both the executable CLI surface and the MCP server entrypoint. - -### Institutional Learnings - -- `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md` shows that durability across restarts materially improves operator experience, but also reinforces the rule to keep secrets separate from persisted non-secret state. -- `docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md` shows that install quality improves when the runbook is self-contained, explicit about base-URL pitfalls, and resilient to Codex/plugin installation friction. -- `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` reinforces that the setup flow must validate base URLs and avoid hiding environment-specific caveats behind overly optimistic onboarding copy. - -### External References - -- npm’s current `npm exec` documentation confirms that a published package with a stable `bin` can support clone-free execution directly from the registry, which is the right public install target once publish prerequisites are cleared. Source: [npm Docs: `npm exec`](https://docs.npmjs.com/cli/v11/commands/npm-exec). -- npm’s current `package.json` documentation confirms the importance of a stable `bin` contract and executable naming when package consumers run binaries through npm. Source: [npm Docs: `package.json`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json). -- Apple’s Keychain Services documentation confirms macOS has a native credential-store API appropriate for storing per-user secrets outside plain config files. Source: [Apple Developer Documentation: Keychain Services](https://developer.apple.com/documentation/security/keychain-services?changes=_2&language=objc). -- Microsoft Learn documents `CredReadW` and `CredWriteW` for the current user credential set, confirming Windows has a first-class native credential-store API for per-user secret storage. Sources: [Microsoft Learn: `CredReadW`](https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credreadw), [Microsoft Learn: `CredWriteW`](https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credwritew). -- The freedesktop Secret Service specification confirms Linux has a standard secret-storage interface, but its availability depends on a user’s desktop/keyring environment. Source: [freedesktop.org: Secrets API Specification](https://www.freedesktop.org/wiki/Specifications/secret-storage-spec/secrets-api-0.1.html). - -## Key Technical Decisions - -- **Split the user-facing CLI from the MCP stdio entrypoint:** The repo can no longer treat the same binary as both “start a stdio server” and “guide a human through install/setup.” The MCP plugin should point to a dedicated server entrypoint, while the package `bin` becomes a human-oriented CLI that can run setup and other operator-facing flows. -- **Keep the current env-var bootstrap as the highest-precedence override, but make machine profiles the default normal path:** This preserves compatibility for power users and automation while enabling the new “install once, use later threads” contract. -- **Persist machine-local profile metadata separately from secrets:** Reuse the repo’s existing separation principle from `src/config.ts` and the runtime-source-catalog learning: non-secret state can live in a machine-local file, while secrets belong in the OS credential store. -- **Move the default runtime source catalog out of the repo checkout for the saved-profile path:** Repo-local `config/sources.runtime.json` is a good fallback for current client-driven sessions, but machine-default profiles should point at machine-local non-secret state so new-thread behavior does not depend on a particular checkout path. -- **Resolve startup config through a layered precedence model:** Use explicit runtime overrides first, then an explicitly selected saved profile if present, then the machine default profile, and only then fall back to an unconfigured/setup-needed state. -- **Make guided setup a shared command surface used by both install modes:** The repo-local plugin flow and the eventual clone-free package flow should call the same setup logic rather than maintaining two independent onboarding implementations. -- **Stage clone-free install activation behind publishing readiness:** The codebase should become package-ready for clone-free setup, but public docs must continue to present the repo-based fallback as the active path until npm ownership and trusted publishing are actually ready. - -## Open Questions - -### Resolved During Planning - -- Should clone-free install be treated as immediately available? No. The architecture should support it, but public activation remains gated on existing publishing prerequisites (see origin: `docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md` and `docs/project/npm-publishing.md`). -- Where should guided setup live? In a shared CLI/setup surface that both human install paths and plugin-driven workflows can invoke, rather than encoding install logic separately in docs, plugin skills, and runtime bootstrap. -- How should existing env-var-based users be handled? Keep env vars as an additive, higher-precedence compatibility layer rather than forcing a breaking migration. - -### Deferred to Implementation - -- Which concrete cross-platform secret-store library or adapter boundary best balances Linux compatibility, maintenance burden, and native behavior. -- Whether the initial human CLI should expose a separate `doctor` or `profiles` command in the first tranche, or whether `setup` plus automatic bootstrap is sufficient. -- The exact machine-local file location for non-secret profile metadata, as long as it stays outside the repo and outside tracked config files. - -## High-Level Technical Design - -> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* - -```mermaid -flowchart TB - A["Human install command or agent prompt"] --> B["Shared setup CLI"] - B --> C["Validate Kibana URL and inputs"] - C --> D["Write non-secret profile metadata"] - C --> E["Write secrets to OS credential store"] - D --> F["Machine default profile"] - E --> F - F --> G["Dedicated MCP server entrypoint"] - G --> H["Resolve startup config"] - H --> I["Configured server without thread-local configure"] -``` - -The core architecture is a split-entry design: setup and human UX move into a shared CLI, while the stdio server becomes a consumer of a resolved machine profile rather than an env-only bootstrap surface. - -## Alternative Approaches Considered - -- **Keep the current single entrypoint and bolt setup prompts onto it:** Rejected because the stdio server entrypoint is the wrong abstraction for a human install experience and would blur CLI semantics, package semantics, and MCP transport startup. -- **Persist secrets in the same machine-local JSON file as profile metadata:** Rejected because it breaks the repo’s existing “persist non-secret state only” safety line and makes the new default onboarding path materially less trustworthy. -- **Make repo-local plugin setup the only durable path and defer human CLI entirely:** Rejected because the user explicitly wants both human and agent install surfaces to be first-class rather than treating human install as secondary documentation. - -## Dependencies / Prerequisites - -- npm package ownership and trusted-publishing prerequisites in `docs/project/npm-publishing.md` remain the gate for enabling the clone-free public install path. -- Linux secret-store support depends on a functioning Secret Service-compatible environment; implementation must treat unavailable or locked stores as a real operational branch, not an impossible edge case. -- Plugin metadata, package contract tests, and docs need to move together because the current repository assumes one shared executable path across all surfaces. - -## Phased Delivery - -### Phase 1 - -- Land the split entrypoints, shared setup flow, machine profile/secret-store architecture, and repo-based guided setup. -- Update current docs and plugin surfaces so the recommended path is “guided setup” rather than manual `KIBANA_*` export. - -### Phase 2 - -- Activate the clone-free package install path once npm ownership and trusted-publishing prerequisites are actually satisfied. -- Promote the clone-free install command to the first documented install block while keeping the repo-based fallback immediately below it. - -## Implementation Units - -```mermaid -flowchart TB - U1["Unit 1: Split CLI and MCP entrypoints"] --> U2["Unit 2: Add profile and secret stores"] - U2 --> U3["Unit 3: Build guided setup flow"] - U2 --> U4["Unit 4: Resolve machine defaults at startup"] - U1 --> U4 - U3 --> U4 - U4 --> U5["Unit 5: Update docs and install surfaces"] - U1 --> U5 -``` - -- [ ] **Unit 1: Split the executable contract into a human CLI and a dedicated MCP server entrypoint** - -**Goal:** Separate the package’s human/operator surface from the stdio MCP server surface so setup UX and server startup can evolve independently. - -**Requirements:** R1-R5, R18-R20 - -**Dependencies:** None - -**Files:** -- Create: `src/cli.ts` -- Create: `src/mcp_entry.ts` -- Modify: `src/index.ts` -- Modify: `package.json` -- Modify: `plugins/kibana-log-investigation/.mcp.json` -- Modify: `scripts/verify-mcp-entrypoint.mjs` -- Modify: `test/package_contract.test.ts` -- Modify: `test/project_contract.test.ts` - -**Approach:** -- Introduce a dedicated stdio server entrypoint so plugin-based MCP startup no longer depends on the same executable that human users run from npm. -- Repoint the npm `bin` contract at a human-facing CLI surface that can own `setup` and future operator-facing commands without destabilizing plugin startup behavior. -- Preserve the current package/release verification style by updating contract tests and verification scripts rather than relying on manual reviewer memory. -- Keep the transition additive enough that repo-local plugin consumers still get a stable stdio contract, just through a new entry file. - -**Execution note:** Start with contract-test failures that prove the package and plugin surfaces can no longer share one entrypoint. - -**Patterns to follow:** -- Reuse the explicit package-contract style in `test/package_contract.test.ts` and `scripts/verify-mcp-entrypoint.mjs`. -- Preserve the current plugin-install contract shape encoded in `plugins/kibana-log-investigation/.mcp.json`. - -**Test scenarios:** -- Happy path: the npm package exposes a human CLI binary while the plugin config points at a dedicated MCP server entrypoint. -- Edge case: package artifact verification still includes both executable surfaces and excludes machine-local state. -- Error path: a mismatch between package `bin`, plugin entrypoint, and built files fails contract verification before release. -- Integration: repo-local plugin installation still launches a stdio MCP server successfully after the entrypoint split. - -**Verification:** -- The package and plugin no longer depend on one shared executable path, and contract tests clearly enforce that separation. - -- [ ] **Unit 2: Add machine profile storage and credential-store abstraction** - -**Goal:** Introduce a durable machine-local configuration model that preserves non-secret state separately from secrets. - -**Requirements:** R10-R17 - -**Dependencies:** Unit 1 - -**Files:** -- Create: `src/profile_store.ts` -- Create: `src/secret_store.ts` -- Create: `src/profile_paths.ts` -- Modify: `src/config.ts` -- Modify: `src/types.ts` -- Create: `test/profile_store.test.ts` -- Create: `test/secret_store.test.ts` - -**Approach:** -- Define a machine-local profile model that stores non-secret metadata such as saved environment names, default-profile selection, source-catalog path, and base URL outside the repo checkout. -- Add a credential-store abstraction that hides platform differences behind one repo-level contract rather than leaking macOS/Windows/Linux specifics into setup and startup code. -- Preserve the current safety boundary from `src/config.ts`: secrets do not go into repo-local files, tracked config, or normal docs-driven setup steps. -- Move the saved-profile source-catalog default into the same machine-local app-state area so durable setup no longer depends on `config/sources.runtime.json` inside a specific repo clone. -- Treat Linux secret-store availability as a supported branch with explicit detection and error messaging rather than an afterthought. - -**Patterns to follow:** -- Extend the existing separation in `src/config.ts` and the learning in `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md`. -- Keep schemas explicit and runtime-validated, following the repo’s current use of `zod`. - -**Test scenarios:** -- Happy path: machine-local profile metadata can be written and loaded without embedding secret values in the profile file. -- Happy path: saved profiles preserve one default environment and optional additional named environments. -- Edge case: switching the default profile does not corrupt other saved environments. -- Edge case: repo-local runtime source files can still support legacy/client-driven flows while saved-profile users read source state from machine-local paths. -- Error path: unavailable or locked credential storage returns a user-facing setup error path without silently dropping into raw env-var instructions. -- Integration: profile metadata and credential lookups compose into a complete startup config without requiring repo-local `config/sources.runtime.json` to contain secrets. - -**Verification:** -- The runtime can load a complete logical connection from machine-local profile data plus credential-store secrets, and repo-local files remain non-secret. - -- [ ] **Unit 3: Build a shared guided setup flow for humans and agents** - -**Goal:** Replace the normal manual env-var onboarding path with a guided first-run setup that can be invoked from both install surfaces. - -**Requirements:** R1-R9, R18-R20 - -**Dependencies:** Unit 2 - -**Files:** -- Create: `src/setup_flow.ts` -- Modify: `src/cli.ts` -- Modify: `plugins/kibana-log-investigation/skills/install-and-configure/SKILL.md` -- Create: `test/setup_flow.test.ts` - -**Approach:** -- Implement one shared setup flow that asks for the minimum first-run inputs from the requirements doc: Kibana base URL, username, password, and default environment/profile name. -- Validate known operator mistakes up front, especially the base-URL-versus-full-endpoint confusion already documented in `INSTALL.md` and the related solution note. -- Offer an immediate “add another environment” continuation after the first successful profile instead of front-loading multi-environment complexity. -- Make the repo-local plugin setup skill and the human CLI both call into the same setup semantics so docs and behavior cannot drift independently. - -**Execution note:** Start with failing setup-flow tests that capture the required prompt sequence and validation outcomes before wiring the CLI surface. - -**Patterns to follow:** -- Reuse the operator language and base-URL rules already established in `INSTALL.md`. -- Keep prompt inputs minimal and outcome-oriented, consistent with the origin requirements. - -**Test scenarios:** -- Happy path: first-run setup collects one default environment and writes a usable machine profile plus secrets. -- Happy path: after the first environment is saved, the flow can add a second named environment without restarting install. -- Edge case: a base URL that includes `/internal/search/es` or another full API endpoint is rejected with corrective guidance. -- Error path: credential-store write failure or invalid profile name stops the setup cleanly and tells the user what to do next. -- Integration: the plugin skill and CLI-driven setup produce equivalent saved state and equivalent later startup behavior. - -**Verification:** -- A normal install path can complete setup without requiring the user to export `KIBANA_*` variables or manually call `configure`. - -- [ ] **Unit 4: Teach runtime startup to resolve saved machine defaults before falling back to manual configuration** - -**Goal:** Make new threads and new MCP processes use the saved default profile automatically instead of requiring explicit `configure`. - -**Requirements:** R10-R20 - -**Dependencies:** Units 1-3 - -**Files:** -- Create: `src/runtime_config_resolver.ts` -- Modify: `src/index.ts` -- Modify: `src/server.ts` -- Modify: `src/config.ts` -- Create: `test/runtime_config_resolver.test.ts` -- Modify: `test/server.test.ts` -- Modify: `test/config.test.ts` - -**Approach:** -- Introduce a startup resolution layer that loads configuration in a strict precedence order: explicit env-var/bootstrap overrides first, then an explicitly selected saved profile if one is provided, then the machine default profile, and finally an unconfigured/setup-needed state. -- Keep `configure` available for advanced or client-driven flows, but stop requiring it for the normal “machine already set up” path. -- Ensure the startup behavior remains compatible with existing repo-local source catalog persistence for legacy/client-driven flows, while saved profiles resolve to machine-local non-secret state and do not overwrite each other unexpectedly. -- Make the unconfigured state point users toward guided setup rather than only telling them to call `configure`. - -**Technical design:** *(directional guidance, not implementation specification.)* Treat startup resolution as a pure composition layer. It should merge: override inputs -> machine-local profile metadata -> credential-store values -> source-catalog file selection -> `AppConfig`. The MCP server should receive a fully resolved config or an explicit “setup required” outcome; it should not know how secrets are stored. - -**Patterns to follow:** -- Extend the current startup flow in `src/index.ts` rather than duplicating it in a second bootstrap path. -- Preserve the existing source-catalog fallback rules in `src/config.ts` where they still apply. - -**Test scenarios:** -- Happy path: a saved machine default profile allows startup and immediate use in a new thread without calling `configure`. -- Edge case: an explicitly selected environment overrides the machine default without mutating the stored default unexpectedly. -- Edge case: existing env-var bootstrap still works and takes precedence over saved profiles for automation and advanced use. -- Error path: corrupted profile metadata, missing secrets, or locked credential storage produce a clear setup-needed path rather than partial silent misconfiguration. -- Integration: a new server process started by the plugin resolves the same saved default profile and becomes usable without thread-local reconfiguration. - -**Verification:** -- A user can open a new thread on a machine that has completed setup and use the MCP immediately under the saved default profile. - -- [ ] **Unit 5: Publish a coherent install story across docs, metadata, and release gating** - -**Goal:** Make the public install surfaces truthful, copyable, and consistent with the staged rollout from repo-based guided setup to clone-free package execution. - -**Requirements:** R1-R5, R18-R20 - -**Dependencies:** Units 1-4 - -**Files:** -- Modify: `README.md` -- Modify: `INSTALL.md` -- Modify: `plugins/kibana-log-investigation/.codex-plugin/plugin.json` -- Modify: `docs/project/distribution-strategy.md` -- Modify: `docs/project/support-policy.md` -- Modify: `docs/project/release-checklist.md` -- Modify: `docs/project/compatibility-matrix.md` -- Modify: `docs/project/npm-publishing.md` - -**Approach:** -- Add two explicit installation blocks to the public docs: one human command block and one agent prompt block, with the clone-free command promoted only when the package publish path is truly live. -- Keep the repo-based fallback immediately below the clone-free path when clone-free is unavailable or still gated. -- Reposition manual env-var instructions as an advanced path rather than the normal onboarding story. -- Align plugin metadata and operator docs so they point users toward guided setup and durable profiles instead of repeating the older “configure with env vars” framing. -- Update compatibility and release docs so maintainers explicitly verify which install surface is active in a given release posture. - -**Patterns to follow:** -- Reuse the repo’s existing public-contract style in `docs/project/distribution-strategy.md`, `docs/project/support-policy.md`, and `docs/project/release-checklist.md`. -- Keep install copy aligned with the packaging truth already documented in `docs/project/npm-publishing.md`. - -**Test scenarios:** -- Test expectation: none -- this unit updates documentation, metadata, and release posture rather than runtime behavior. - -**Verification:** -- Public-facing surfaces tell one coherent story about how to install, when clone-free install is live, how guided setup works, and why users normally no longer need to export `KIBANA_*` variables. - -## System-Wide Impact - -```mermaid -flowchart TB - A["Package CLI contract"] --> B["Guided setup flow"] - B --> C["Machine profile store"] - B --> D["OS credential store"] - C --> E["Runtime config resolver"] - D --> E - E --> F["MCP stdio startup"] - G["Docs and plugin metadata"] --> B - G --> A -``` - -- **Interaction graph:** package CLI contract, plugin metadata, machine-local profile state, OS credential stores, runtime config resolution, and public docs all become one shared onboarding system. -- **Error propagation:** a bad startup precedence rule, a missing secret-store branch, or stale install docs will surface as “tool installed but still unusable in a new thread,” which is the exact failure this plan must eliminate. -- **State lifecycle risks:** machine-local profile metadata, repo-local runtime source catalogs, and env-var overrides can diverge unless resolution order is explicit and well-tested. -- **API surface parity:** human CLI, agent prompt flow, plugin install flow, and stdio runtime startup must all converge on the same guided-setup and saved-profile semantics. -- **Integration coverage:** unit tests alone are insufficient; contract tests must cover executable entrypoints, startup resolution, credential/profile composition, and public-surface documentation promises. -- **Unchanged invariants:** the MCP remains read-only, supports env-var bootstrap for advanced users, and continues to keep secrets out of repo-local tracked config files. - -## Risk Analysis & Mitigation - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Linux secret-store support is inconsistent across environments and breaks the “works everywhere” promise | Medium | High | Treat Linux secret-store detection as a first-class branch with explicit fallback messaging and integration tests; do not silently assume a secret service exists | -| Splitting the CLI and MCP entrypoints breaks current plugin install or package consumers | Medium | High | Land the split behind contract tests and verification script updates before changing docs | -| Machine-local defaults accidentally override explicit env-based automation flows | Medium | High | Define and test a strict precedence order where explicit env/bootstrap wins over saved profiles | -| Clone-free install is documented before publish prerequisites are actually satisfied | Medium | Medium | Keep release-checklist and distribution-strategy docs as gating authorities and phase the rollout explicitly | -| Credential storage introduces a new security boundary that maintainers cannot confidently support | Medium | High | Keep secrets in native OS stores, avoid plain-file secrets by default, and document unsupported/locked-store cases clearly | - -## Documentation Plan - -- Update `README.md` and `INSTALL.md` to expose copyable install blocks for humans and agents. -- Update support and compatibility docs so they describe the new normal path and the advanced fallback path accurately. -- Keep package-publishing docs explicit about when the clone-free command is truly live versus merely prepared in code. - -## Operational / Rollout Notes - -- Do not switch the primary public install command to clone-free until npm ownership and trusted-publishing setup are complete and verified. -- Treat repo-based guided setup as a meaningful shippable milestone even before clone-free package activation is live. -- Plan for migration messaging to existing users who still rely on env vars so the new machine-profile behavior is discoverable but not breaking. - -## Sources & References - -- **Origin document:** `docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md` -- Related code: `src/index.ts` -- Related code: `src/server.ts` -- Related code: `src/config.ts` -- Related code: `src/types.ts` -- Related code: `plugins/kibana-log-investigation/.mcp.json` -- Related code: `plugins/kibana-log-investigation/.codex-plugin/plugin.json` -- Related tests: `test/config.test.ts` -- Related tests: `test/server.test.ts` -- Related tests: `test/package_contract.test.ts` -- Related tests: `test/project_contract.test.ts` -- Related docs: `README.md` -- Related docs: `INSTALL.md` -- Related docs: `docs/project/support-policy.md` -- Related docs: `docs/project/distribution-strategy.md` -- Related docs: `docs/project/npm-publishing.md` -- Related learnings: `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md` -- Related learnings: `docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md` -- Related learnings: `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` -- External docs: https://docs.npmjs.com/cli/v11/commands/npm-exec -- External docs: https://docs.npmjs.com/cli/v11/configuring-npm/package-json -- External docs: https://developer.apple.com/documentation/security/keychain-services?changes=_2&language=objc -- External docs: https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credreadw -- External docs: https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credwritew -- External docs: https://www.freedesktop.org/wiki/Specifications/secret-storage-spec/secrets-api-0.1.html diff --git a/docs/plans/2026-04-09-002-refactor-release-pipeline-plan.md b/docs/plans/2026-04-09-002-refactor-release-pipeline-plan.md deleted file mode 100644 index 2decaa1..0000000 --- a/docs/plans/2026-04-09-002-refactor-release-pipeline-plan.md +++ /dev/null @@ -1,319 +0,0 @@ ---- -title: refactor: Add semantic release pipeline -type: refactor -status: active -date: 2026-04-09 -deepened: 2026-04-09 ---- - -# refactor: Add semantic release pipeline - -## Overview - -Replace the current Changesets-based release PR flow with a semantic-commit-driven GitHub Actions release pipeline. The new pipeline should infer semver from merged commit history, create Git tags and GitHub Releases automatically, publish `@havesomecode/kibana-mcp-server` to npm through trusted publishing, and stop treating committed version bumps or changelog files as the canonical release record. - -## Problem Frame - -The repo already has a basic release workflow and package verification, but its current release posture is split across two incompatible models: - -- Changesets is configured as the versioning source of truth -- the desired future state is semantic commits driving fully automated releases -- the user explicitly does not want release commits back into the repository, which conflicts with the current release-PR model - -That mismatch matters because versioning, changelog generation, publish automation, and merge discipline all need the same source of truth. If semantic-release is added on top of Changesets rather than replacing it, maintainers will be forced to reconcile two separate semver systems and the resulting release record will drift. - -## Requirements Trace - -- R1. GitHub Actions must bump versions automatically from semantic commit history rather than manual version editing. -- R2. Releases must create Git tags, npm publishes, and GitHub Releases automatically. -- R3. The release pipeline must enforce a semantic commit contract strongly enough that automatic semver is trustworthy. -- R4. The release record must live in tags, npm, and GitHub Releases, not in committed version/changelog bumps. -- R5. The pipeline must align with the current package identity `@havesomecode/kibana-mcp-server` and npm trusted publishing posture. -- R6. Repo docs and maintainer runbooks must describe the new release contract and remove Changesets as the documented source of truth. - -## Scope Boundaries - -- No product/runtime feature changes to the MCP itself. -- No attempt to automate GitHub repository settings directly if they are not configurable from the repo; those can be documented as operational prerequisites. -- No prerelease channel design (`beta`, `next`, `alpha`) in the first pass unless the release contract requires it later. -- No committed changelog or version bump workflow; that is intentionally excluded. - -## Context & Research - -### Relevant Code and Patterns - -- `.github/workflows/ci.yml` already defines the repo’s main verification path: checkout, Node setup from `.node-version`, `npm ci`, and `npm run verify`. -- `.github/workflows/release.yml` already has the right GitHub Actions permission posture for trusted npm publishing, but it still delegates versioning and publish orchestration to `changesets/action@v1`. -- `.changeset/config.json` and `.changeset/README.md` establish that Changesets is currently the documented versioning source of truth. -- `package.json` already has `publishConfig.access`, `publishConfig.provenance`, and a verified artifact boundary through `npm run verify`. -- `docs/project/release-checklist.md`, `docs/project/npm-publishing.md`, and `docs/project/distribution-strategy.md` currently document a Changesets release PR model and will need to move in lockstep with the automation change. -- `test/package_contract.test.ts` shows this repo prefers explicit contract tests around release-critical metadata rather than trusting YAML and package settings by inspection alone. - -### Institutional Learnings - -- `docs/plans/2026-04-06-001-refactor-kibana-mcp-professionalization-plan.md` already identified versioning/release automation as a repository contract and explicitly treated validated artifacts as more important than premature public publishing. -- `docs/plans/2026-04-09-001-feat-ergonomic-install-setup-plan.md` tightened the public install contract and raised the importance of keeping release posture, docs, and actual shipped artifacts synchronized. - -### External References - -- semantic-release’s plugin model documents the default release shape around commit analysis, release notes generation, npm publishing, and GitHub release publication. Source: [semantic-release plugin docs](https://semantic-release.gitbook.io/semantic-release/usage/plugins) -- semantic-release’s release workflow docs show `master`/`main` as normal release branches and use commit history as the release input. Source: [semantic-release release workflow recipe](https://semantic-release.gitbook.io/semantic-release/recipes/release-workflow/pre-releases) -- Conventional Commits defines the commit semantics semantic-release uses to infer `major`, `minor`, and `patch` releases. Source: [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) -- commitlint’s setup docs describe the standard config and CI enforcement posture for Conventional Commits. Source: [commitlint getting started](https://commitlint.js.org/guides/getting-started.html) -- npm’s trusted publishing docs remain the authoritative source for GitHub Actions OIDC publication. Source: [npm trusted publishers](https://docs.npmjs.com/trusted-publishers) - -## Key Technical Decisions - -- **Replace Changesets instead of running both systems in parallel:** semantic-release should become the only semver and release-notes engine. Running Changesets and semantic-release together would create two sources of truth for versioning and release intent. -- **Do not commit release artifacts back into the repository:** semantic-release should publish from its prepared workspace and create tags/releases, but it should not use changelog or git plugins that mutate `package.json` or `CHANGELOG.md` in git history. This matches the stated release-record requirement. -- **Treat semantic merge inputs as a repo contract, not a maintainer convention:** the plan should enforce Conventional Commits in CI and at PR merge boundaries, not just document them. Without enforcement, auto-semver is a guess, not a contract. -- **Prefer squash-merge-compatible release semantics:** semantic-release analyzes default-branch commits. Because this repo releases from `master` and squash merges collapse branch history into one final commit, semantic PR titles should be treated as the primary release signal that survives onto `master`; commit-message linting is a secondary guardrail, not the source of truth. -- **Keep GitHub Actions and repo-local verification aligned:** CI and release should continue reusing `npm run verify` and explicit contract checks rather than encoding fragile logic directly in workflow YAML. -- **Move release-note authority to GitHub Releases rather than committed changelog files:** if the repo will not commit release notes back, the canonical human-readable changelog should be the generated GitHub Release body, not a drifting `CHANGELOG.md`. - -## Open Questions - -### Resolved During Planning - -- **Should release automation write committed version bumps or changelog updates back into the repo?** No. Tags, npm, and GitHub Releases are the release record. -- **Should the repo keep Changesets as a parallel mechanism?** No. semantic-release should replace it outright. -- **What should be treated as the release-safe semantic input?** Semantic PR titles are the authoritative release input, with squash merge expected on the default branch and commit-message linting used only as a supporting guardrail. - -### Deferred to Implementation - -- **Exact semantic-release plugin package set and config file shape:** this is an implementation detail once the pipeline contract is accepted. -- **Whether to keep `CHANGELOG.md` as a static informational file or remove it from shipped package files:** the plan should resolve the contract, but the exact artifact treatment can be finalized while editing `package.json`. -- **Exact GitHub repository settings for squash merge, merge commits, and branch protection:** these may need manual maintainer actions outside the repo even if the docs and workflows are updated here. - -## High-Level Technical Design - -> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* - -```mermaid -flowchart TB - A["Developer opens PR"] --> B["Semantic commit / PR title enforcement"] - B --> C["CI runs npm run verify"] - C --> D["Squash merge to master"] - D --> E["Release workflow on push to master"] - E --> F["semantic-release analyzes merged commit history"] - F --> G{"Release type?"} - G -->|none| H["Exit with no publish"] - G -->|patch/minor/major| I["Prepare package version in workspace"] - I --> J["Publish npm package via OIDC trusted publishing"] - J --> K["Create Git tag"] - K --> L["Create GitHub Release with generated notes"] -``` - -The critical contract is that the merged commit history on `master` is semantically meaningful. Everything else in the pipeline depends on that being true. - -## Alternative Approaches Considered - -- **Keep Changesets and only add semantic commit linting:** rejected because the versioning source of truth would still be Changesets, not commit history. -- **Use release-please instead of semantic-release:** rejected because release-please still centers committed release PRs and repo-mutating version bumps, which conflicts with the desired release record. -- **Use semantic-release with changelog/git plugins:** rejected because it would reintroduce committed release artifacts and blur the “tags/npm/GitHub Releases only” boundary. - -## Dependencies / Prerequisites - -- The npm package identity must remain `@havesomecode/kibana-mcp-server`. -- npm trusted publishing must be configured for the actual GitHub repository and workflow after the pipeline is updated. -- Branch protection and merge policy must support the semantic-input contract; the repo cannot rely on direct pushes to `master` if release automation is driven from commit history. -- Existing `.changeset/*.md` files need a one-time migration decision because they no longer fit the post-Changesets model. - -## Phased Delivery - -### Phase 1 - -- Establish the semantic-release contract in repo config and tests. -- Remove Changesets as the documented and automated release path. - -### Phase 2 - -- Enforce semantic inputs in CI/PR workflows and document required repo settings. -- Replace the release workflow and trusted publishing documentation. - -## Implementation Units - -- [ ] **Unit 1: Replace Changesets with a semantic-release contract** - -**Goal:** Make semantic-release the only release/versioning authority in the repository. - -**Requirements:** R1, R2, R4, R5 - -**Dependencies:** None - -**Files:** -- Create: `.releaserc.json` -- Create: `test/release_contract.test.ts` -- Modify: `package.json` -- Modify: `package-lock.json` -- Modify or Delete: `CHANGELOG.md` -- Modify: `test/package_contract.test.ts` -- Delete: `.changeset/config.json` -- Delete: `.changeset/README.md` -- Delete: `.changeset/eight-radios-admire.md` -- Delete: `.changeset/forty-deers-unite.md` - -**Approach:** -- Remove Changesets scripts and dependencies from `package.json`. -- Add semantic-release plus the npm/GitHub/commit-analysis plugins needed for fully automated semver and release-note generation. -- Configure semantic-release so it publishes and creates releases without committing version or changelog updates back to git. -- Add a release contract test that proves the repo no longer depends on Changesets and that the semantic-release config matches the intended package/release contract. -- Resolve the shipped artifact stance for `CHANGELOG.md` explicitly rather than leaving a stale file in the package by accident; either remove it from published files or convert it into a clearly non-canonical static note. - -**Execution note:** Start with a failing release contract test so the migration is encoded as a repo contract, not just a YAML edit. - -**Patterns to follow:** -- Reuse the explicit package-contract verification style in `test/package_contract.test.ts`. -- Keep release-critical logic verifiable via repo-local scripts/tests instead of hidden inside GitHub Actions. - -**Test scenarios:** -- Happy path: the repo contains a semantic-release config that targets `master` and the npm/GitHub publish plugins. -- Happy path: `package.json` no longer references Changesets scripts or dependencies. -- Edge case: the package contract still points at the correct binary entrypoints and public package name after the release-tool migration. -- Error path: a stale Changesets config or stale release script causes the release contract test to fail before CI can publish. -- Integration: `npm run verify` still passes with the new release-tool contract in place. - -**Verification:** -- The repo has one release engine, not two, and release-critical metadata is protected by contract tests. - -- [ ] **Unit 2: Enforce semantic release inputs at PR and commit boundaries** - -**Goal:** Make commit semantics reliable enough for automatic semver inference. - -**Requirements:** R1, R3, R4 - -**Dependencies:** Unit 1 - -**Files:** -- Create: `commitlint.config.cjs` -- Create: `.github/workflows/semantic-commits.yml` -- Modify: `.github/pull_request_template.md` -- Modify: `docs/project/release-checklist.md` -- Test: `test/release_contract.test.ts` - -**Approach:** -- Add commitlint configuration for Conventional Commits. -- Enforce semantic PR titles in GitHub Actions as the required merge gate, because the squash-merged PR title is the release input semantic-release will actually analyze on `master`. -- If commit-message linting is added, keep it secondary and compatible with normal branch cleanup so local WIP history does not become the primary release contract. -- Document the expected merge strategy in repo guidance: squash merge should be the default so the final `master` commit carries the reviewed semantic title. -- Keep the enforcement model compatible with automation and maintainers: the goal is deterministic release inputs, not maximal friction for every local WIP commit. - -**Patterns to follow:** -- Mirror the repo’s existing single-purpose workflow style in `.github/workflows/ci.yml` and `.github/workflows/release.yml`. -- Keep maintainer-facing policy changes in `docs/project/release-checklist.md` and adjacent project docs rather than scattering them across issue comments or tribal knowledge. - -**Test scenarios:** -- Happy path: a PR title like `feat: add X` or `fix: correct Y` passes semantic validation. -- Edge case: a `feat!:` or `BREAKING CHANGE:` signal is accepted and preserved as a major-release indicator. -- Error path: a non-conventional PR title or invalid commit message fails the semantic enforcement workflow. -- Integration: the release contract test asserts the semantic enforcement workflow and commitlint config exist and point at the Conventional Commits policy. - -**Verification:** -- A change cannot merge cleanly into the release branch without passing semantic input validation. - -- [ ] **Unit 3: Replace the GitHub Actions release workflow with semantic-release publishing** - -**Goal:** Publish npm packages, tags, and GitHub Releases automatically from verified default-branch commits. - -**Requirements:** R1, R2, R4, R5 - -**Dependencies:** Units 1-2 - -**Files:** -- Modify: `.github/workflows/release.yml` -- Modify: `.github/workflows/ci.yml` -- Modify: `package.json` -- Create: `scripts/verify-release-contract.mjs` -- Test: `test/release_contract.test.ts` - -**Approach:** -- Replace `changesets/action@v1` with a release step that runs semantic-release after `npm run verify`. -- Preserve the current trusted-publishing permission posture (`id-token: write`) and keep npm publish running from GitHub Actions rather than local maintainer machines. -- Add a release-contract verification script for workflow/package alignment so the pipeline shape remains testable as the repo evolves. -- Ensure the workflow exits cleanly when there is no releasable commit type, rather than treating “no release” as a failure. -- Keep release creation on `push` to `master`, with `workflow_dispatch` retained only if maintainers still need manual reruns. - -**Patterns to follow:** -- Reuse the repo’s existing `npm run verify` gate instead of duplicating test/build logic in workflow YAML. -- Preserve the current minimal-permissions posture already encoded in `.github/workflows/release.yml`. - -**Test scenarios:** -- Happy path: a `feat:` commit on `master` produces a minor release path with npm publish, git tag, and GitHub Release creation. -- Happy path: a `fix:` commit produces a patch release path. -- Edge case: a commit batch with no releasable type exits without publishing and without failing the workflow. -- Edge case: a `BREAKING CHANGE` signal produces a major release path. -- Error path: misaligned workflow/config/package settings fail `scripts/verify-release-contract.mjs` or `test/release_contract.test.ts` before a publish attempt. -- Integration: the release workflow, release config, and package metadata stay aligned around `@havesomecode/kibana-mcp-server` and OIDC publishing. - -**Verification:** -- A verified push to `master` is sufficient to create a release end to end, with no release PR and no manual version editing. - -- [ ] **Unit 4: Update release docs and maintainer operations for the new source of truth** - -**Goal:** Make the new release contract understandable and operable for maintainers. - -**Requirements:** R4, R5, R6 - -**Dependencies:** Units 1-3 - -**Files:** -- Modify: `README.md` -- Modify: `docs/project/distribution-strategy.md` -- Modify: `docs/project/npm-publishing.md` -- Modify: `docs/project/release-checklist.md` -- Modify: `docs/project/support-policy.md` - -**Approach:** -- Remove or rewrite references to Changesets release PRs, committed version bumps, and repo-committed changelog generation. -- Document the new release record explicitly: semantic commit history -> semantic-release -> tag/npm/GitHub Release. -- Document the operational prerequisites that live outside the repo, including trusted publisher setup, branch protection, and squash merge policy. -- Keep public install docs aligned with the actual package name and actual release workflow. - -**Patterns to follow:** -- Reuse the project posture style already established in `docs/project/distribution-strategy.md`, `docs/project/release-checklist.md`, and `docs/project/support-policy.md`. - -**Test scenarios:** -- Test expectation: none -- this unit updates release posture, governance, and docs rather than runtime behavior. - -**Verification:** -- A maintainer reading repo docs can understand how releases happen, what semantic inputs are required, and what operational settings must exist outside the repo. - -## System-Wide Impact - -- **Interaction graph:** PR titles/commit messages, CI validation, release workflow, package metadata, npm trusted publishing, and GitHub Releases become one shared contract rather than separate maintainer habits. -- **Error propagation:** invalid semantic inputs should fail before merge; workflow/config drift should fail during `npm run verify`; publish/auth problems should fail in the release workflow rather than silently producing partial release state. -- **State lifecycle risks:** the package version in git will no longer be a durable release record, so docs and maintainer expectations must stop treating it that way. -- **API surface parity:** release docs, package metadata, npm publishing docs, and GitHub workflow behavior all need to agree on the package name `@havesomecode/kibana-mcp-server`. -- **Integration coverage:** cross-file verification needs to prove workflow/config/package consistency because unit tests alone will not catch drift across YAML, package metadata, and release docs. -- **Unchanged invariants:** the runtime artifact boundary, Node 22+ support posture, and trusted-publishing preference remain intact even though the versioning engine changes. - -## Risks & Dependencies - -| Risk | Mitigation | -|------|------------| -| semantic-release and Changesets overlap during migration and create conflicting release expectations | Remove Changesets config/scripts/docs in the same tranche that introduces semantic-release | -| semantic version inference is wrong because merge inputs are inconsistent | Enforce Conventional Commits in CI and document squash merge / branch protection as required operational policy | -| npm publish still fails because trusted publishing or 2FA posture is wrong | Keep npm trusted publishing setup explicit in docs and verify release workflow permissions before enabling the pipeline | -| Maintainers still look for committed version bumps or `CHANGELOG.md` updates | Rewrite release docs and checklist to make tags/npm/GitHub Releases the only release record | -| Existing unreleased `.changeset/*.md` files create migration ambiguity | Handle them as a one-time cleanup/migration unit rather than leaving them in the repo post-cutover | - -## Documentation / Operational Notes - -- Branch protection should require CI and semantic-input checks before merge to `master`. -- Squash merge should be the default merge strategy if semantic PR titles are treated as the final release input. -- Trusted publishing on npm must be reconfigured or verified for the actual maintainer account and the package `@havesomecode/kibana-mcp-server`. -- If GitHub Releases become the canonical changelog, package/readme/docs should stop implying that `CHANGELOG.md` is authoritative. - -## Sources & References - -- Related workflow: `.github/workflows/release.yml` -- Related workflow: `.github/workflows/ci.yml` -- Related config: `.changeset/config.json` -- Related docs: `docs/project/npm-publishing.md` -- Related docs: `docs/project/release-checklist.md` -- Related docs: `docs/project/distribution-strategy.md` -- External docs: https://semantic-release.gitbook.io/semantic-release/usage/plugins -- External docs: https://semantic-release.gitbook.io/semantic-release/recipes/release-workflow/pre-releases -- External docs: https://www.conventionalcommits.org/en/v1.0.0/ -- External docs: https://commitlint.js.org/guides/getting-started.html -- External docs: https://docs.npmjs.com/trusted-publishers diff --git a/docs/plans/2026-04-11-001-feat-github-pages-homepage-plan.md b/docs/plans/2026-04-11-001-feat-github-pages-homepage-plan.md index 09b2a08..42d3473 100644 --- a/docs/plans/2026-04-11-001-feat-github-pages-homepage-plan.md +++ b/docs/plans/2026-04-11-001-feat-github-pages-homepage-plan.md @@ -10,7 +10,7 @@ origin: docs/brainstorms/2026-04-11-github-pages-homepage-requirements.md ## Overview -Launch a one-page GitHub Pages homepage for this repository that acts as the public adoption surface for the Kibana MCP: Warp-led in first impression, structured and credible below the fold, and explicit about the two supported install paths (`npx` and repo-local Codex/plugin setup). This plan supersedes `docs/plans/2026-04-08-002-feat-github-pages-site-plan.md`, which was written against an older distribution and release story. +Launch a one-page GitHub Pages homepage for this repository that acts as the public adoption surface for the Kibana MCP: Warp-led in first impression, structured and credible below the fold, and explicit about the two supported install paths (`npx` and repo-local Codex/plugin setup). ## Problem Frame @@ -54,7 +54,7 @@ The new requirements doc establishes the product and design decisions already: o ### Institutional Learnings - `docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md` shows that adoption improves when install instructions are self-contained, explicit, and honest about fallbacks and environment-specific setup. -- `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md` reinforces that durable setup is part of the product story; the homepage should mention saved setup/profile ergonomics without implying secret persistence. +- `INSTALL.md` defines durable connection bootstrap: the homepage should distinguish saved profile/catalog metadata from credentials stored in the operating system. - `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` reinforces that schema-aware features are environment-dependent and must not be marketed as universal behavior. ### External References @@ -287,7 +287,6 @@ The site should be a static, DOM-driven composition: the hero and proof surfaces ## Sources & References - **Origin document:** `docs/brainstorms/2026-04-11-github-pages-homepage-requirements.md` -- Superseded plan: `docs/plans/2026-04-08-002-feat-github-pages-site-plan.md` - Related docs: `README.md`, `INSTALL.md`, `docs/project/distribution-strategy.md`, `docs/project/support-policy.md`, `docs/project/release-checklist.md` -- Related learnings: `docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md`, `docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md`, `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` +- Related learnings: `docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md`, `docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md` - External docs: [Configuring a publishing source for your GitHub Pages site](https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site), [Using custom workflows with GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) diff --git a/docs/project/compatibility-matrix.md b/docs/project/compatibility-matrix.md index d17310d..13a171a 100644 --- a/docs/project/compatibility-matrix.md +++ b/docs/project/compatibility-matrix.md @@ -15,9 +15,11 @@ This matrix distinguishes guaranteed support from best-effort behavior. Each cla | Fully autonomous plugin install by prompting alone across Codex model variants | Best-effort | Field reports show model-dependent behavior | Manual install from the local marketplace is the supported fallback when the agent cannot complete the UI step itself. | | `kibana_internal_search_es` backend | Guaranteed | Verified in tests | Primary search transport. | | `elasticsearch_search` backend | Guaranteed | Verified in tests | Direct Elasticsearch-compatible search path. | -| Schema metadata endpoints | Best-effort | Known 404s in some deployments | `describe_fields` may fail if endpoints are blocked. | +| Schema metadata endpoints | Best-effort | Known 404s in some deployments | The server can sample fields through the configured search backend on `404`; this fallback is heuristic and `describe_fields` can still fail. | | Nested query features | Best-effort | Depends on schema availability | Requires schema metadata for validation. | | Connection-only package bootstrap | Guaranteed | Verified through CLI, rollback, packlist, and release-contract tests | Use `npx -y @havesomecode/kibana-mcp-server bootstrap --client codex`; configure an index only after the user names it. | | Agent Skill installation | Guaranteed after publication | Repository discovery is verified with the `skills` CLI; skills.sh indexing follows the public branch | Install `kibana-log-investigation` from `Havesomecode/kibana-mcp-server`. | +Saved profiles carry an explicit catalog path. Direct connection startup requires `KIBANA_SOURCE_CATALOG_PATH` or a configuration-file catalog store; no checkout catalog is loaded implicitly. Without a saved connection, startup fails with `No saved Kibana connection is available. Run connection bootstrap first.` Guided `setup` remains supported for hand-authored catalogs. + Upgrade a row from best-effort to guaranteed only after verifying it in CI or in a reproducible environment test and documenting the evidence here. diff --git a/docs/project/distribution-strategy.md b/docs/project/distribution-strategy.md index ecaf86b..65188de 100644 --- a/docs/project/distribution-strategy.md +++ b/docs/project/distribution-strategy.md @@ -66,7 +66,7 @@ The release artifact must include: The release artifact must exclude: - tests and fixtures -- local operator config (`config/sources.json`, `config/sources.runtime.json`) +- local operator catalogs and saved machine profiles - development-only scripts and tooling that do not affect runtime execution ## Versioning diff --git a/docs/project/release-checklist.md b/docs/project/release-checklist.md index 24a92a8..076977a 100644 --- a/docs/project/release-checklist.md +++ b/docs/project/release-checklist.md @@ -12,6 +12,8 @@ updated: 2026-04-09 - `npm run verify` passes locally. - The compatibility matrix is up to date (`docs/project/compatibility-matrix.md`). - Any new env or config requirements are documented in `README.md` and `INSTALL.md`. +- Startup requires a connection and an explicit catalog path or store; saved profiles use their stored path. Docs must not advertise a credential-taking MCP setup tool or implicit checkout catalog. +- Connection-only bootstrap remains the default install flow; guided setup remains supported for explicitly selected hand-authored catalogs. - The hosted homepage still matches the supported install paths and support posture. - The npm scope and package name (`@havesomecode/kibana-mcp-server`) are owned by the maintainers. - Maintainers understand that released versions are tracked in npm, tags, and GitHub Releases, not by the `package.json` version committed in git. diff --git a/docs/project/support-policy.md b/docs/project/support-policy.md index b38900f..3f37b3d 100644 --- a/docs/project/support-policy.md +++ b/docs/project/support-policy.md @@ -15,8 +15,10 @@ This MCP is designed for operators and AI agents that need read-only access to K - Node.js 22+ runtime - Repo-local Codex plugin install - `kibana_internal_search_es` and `elasticsearch_search` backends -- Guided machine setup with saved profiles and imported source catalogs -- Runtime configuration via `configure` plus persisted non-secret source catalogs +- Connection-only bootstrap with saved profiles and platform credentials +- Guided machine setup for explicitly selected hand-authored source catalogs +- Startup with a saved profile, or a complete explicit connection plus `KIBANA_SOURCE_CATALOG_PATH` or a configuration-file catalog store +- Explicit user-approved source configuration through `configure_index`; no MCP tool accepts connection credentials ## Best-Effort Support @@ -27,14 +29,16 @@ This MCP is designed for operators and AI agents that need read-only access to K Best-effort means we will investigate issues but cannot guarantee behavior across all deployments. +Startup without a saved connection fails with `No saved Kibana connection is available. Run connection bootstrap first.` Run connection-only bootstrap before reconnecting the MCP client. For saved-profile use, leave direct connection variables unset in the server environment. + ## Known Environment Constraints - Some Codex model variants are less reliable at completing repo-local plugin installation without a manual Codex UI step. The supported fallback is to install the plugin manually from the local marketplace, then continue with MCP configuration. -- Some Kibana deployments proxy or block schema metadata endpoints. In those cases, schema-aware features may be unavailable. +- Some Kibana deployments proxy or block schema metadata endpoints. On `404`, the server can sample fields through the configured search backend. This fallback is heuristic, not authoritative; schema-dependent features can still be unavailable. - `KIBANA_BASE_URL` must be the Kibana base prefix, not a full search endpoint such as `/internal/search/es`. - Linux secure storage depends on a Secret Service-compatible keyring being available and unlocked. -- If you run staging and production side by side, prefer saved machine profiles plus `KIBANA_PROFILE` selection. If you stay on env bootstrap, keep `KIBANA_SOURCE_CATALOG_PATH` distinct per entry so runtime-persisted sources do not overwrite each other. -- Credentials are not persisted to repo-local files or tracked config. Guided setup stores them in the platform credential store. +- If you run staging and production side by side, prefer saved machine profiles plus `KIBANA_PROFILE` selection. For direct environment startup, name `KIBANA_SOURCE_CATALOG_PATH` explicitly and keep it distinct per entry so managed sources from different environments do not mix. +- Credentials are not persisted to repo-local files or tracked config. Bootstrap and guided setup store them in the platform credential store. ## Security Reporting diff --git a/docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md b/docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md index bb23674..289b46e 100644 --- a/docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md +++ b/docs/solutions/documentation-gaps/self-contained-codex-install-handoff-2026-04-08.md @@ -39,9 +39,11 @@ Treat `INSTALL.md` as the canonical operator handoff for this repo. An agent tha - the manual plugin-install fallback path if the model cannot complete the Codex UI step itself - the MCP configuration and verification flow -For multi-environment setup, always ask the user for a short environment name such as `staging`, `prod`, `preprod`, or `qa`. Derive the source catalog path as `config/sources..json` and use a distinct value per MCP entry. Do not invent the environment name, and do not reuse one shared runtime catalog path across staging and production. +For multi-environment setup, ask the user for a short profile name such as `staging`, `prod`, `preprod`, or `qa`, then run connection-only bootstrap with `--profile `. Bootstrap assigns that profile its machine-local catalog; do not invent profile names or catalog contents. Call `configure_index` only after the user explicitly names an index or pattern. -If the operator already keeps target-specific host variables such as `KIBANA_BASE_URL_STAGING` and `KIBANA_BASE_URL_PROD`, preserve that host-side convention if it is useful to them. For each MCP entry, map those values into the standard runtime variables expected by the server: +For direct environment-based startup, the operator must explicitly provide `KIBANA_SOURCE_CATALOG_PATH` or a configuration-file store. Keep catalogs distinct across environments; no checkout catalog is loaded implicitly. + +If the operator already keeps target-specific host variables such as `KIBANA_BASE_URL_STAGING` and `KIBANA_BASE_URL_PROD`, preserve that host-side convention if it is useful to them. For direct environment-based MCP entries, map those values into the standard runtime variables expected by the server: - `KIBANA_BASE_URL` - `KIBANA_USERNAME` @@ -56,7 +58,7 @@ Keep `KIBANA_BASE_URL` limited to the Kibana base prefix. Endpoint paths such as The main benefit is durable handoff quality. A colleague should be able to send only the raw `INSTALL.md` link to another agent and get the same setup flow every time, instead of relying on undocumented tribal knowledge about the repo structure or Codex behavior. -The environment-name rule matters because this repo persists runtime source catalogs. If two MCP entries share one runtime catalog path, one environment can silently overwrite the other. Deriving `config/sources..json` from the user-provided environment name prevents that collision. +The profile-name rule matters because saved profiles keep connections and catalog paths distinct. `configure_index` persists sources in the selected managed catalog; sharing one catalog across environments would mix their sources. Guided setup can instead import a hand-authored catalog explicitly selected by the operator. The env-var mapping rule matters because operators often already have separate secrets or shell variables for each target environment. Allowing those host-side names while standardizing each MCP entry onto the server's `KIBANA_*` contract keeps the server simple without forcing users to reorganize their environment management. @@ -87,16 +89,15 @@ Manual plugin-install fallback: If you cannot complete the repo-local plugin install yourself, open this repo in Codex, open plugins/kibana-log-investigation, install "Kibana Log Investigation" from the local marketplace, restart Codex if needed, then continue with MCP configuration. ``` -Derived source catalog paths: - -```text -User says: staging -Agent derives: config/sources.staging.json +Named saved profiles: -User says: prod -Agent derives: config/sources.prod.json +```bash +npx -y @havesomecode/kibana-mcp-server bootstrap --profile staging --client codex +npx -y @havesomecode/kibana-mcp-server bootstrap --profile prod --no-default --client codex ``` +Saved-profile server processes should not receive direct connection variables: those select environment startup and require an explicit catalog or store. No connection means startup fails with `No saved Kibana connection is available. Run connection bootstrap first.` + Mapping target-specific host variables into the standard runtime variables for one MCP entry: ```text @@ -128,6 +129,5 @@ KIBANA_BASE_URL=https://gateway.example.com/logs/internal/search/es ## Related -- [Persist runtime source catalog for client-configured Kibana MCP sessions](../integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md) - [Kibana schema metadata endpoints may be unavailable behind a proxy](../integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md) - GitHub issue search was skipped because network access was unavailable in this session. diff --git a/docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md b/docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md deleted file mode 100644 index f9ad82b..0000000 --- a/docs/solutions/integration-issues/kibana-mcp-config-reset-after-restart-2026-04-03.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -title: Persist runtime source catalog for client-configured Kibana MCP sessions -date: 2026-04-03 -category: integration-issues -module: kibana-mcp-server -problem_type: integration_issue -component: tooling -symptoms: - - After an MCP update or restart, the agent had to call `configure` again before `discover` or `query` worked. - - Kibana credentials were still available from environment variables, but the configured source catalog had disappeared. -root_cause: missing_tooling -resolution_type: tooling_addition -severity: medium -tags: [mcp, kibana, codex, configuration-persistence, source-catalog] ---- - -# Persist runtime source catalog for client-configured Kibana MCP sessions - -## Problem - -The Kibana MCP supported client-side `configure`, but that configuration only lived in process memory. Any MCP restart wiped the logical source catalog, so agents lost the ability to use `discover`, `filter`, and `query` until they reconfigured the server again. - -## Symptoms - -- Codex or MCP restarts caused the previously working Kibana session setup to disappear. -- The agent reported that the Kibana session config had been reset after an MCP update. -- Environment-based credentials still worked, but the source definitions needed for log queries were gone. - -## What Didn't Work - -- Relying on in-memory state set by `configure` alone. That made the current session work, but it guaranteed the next process restart would drop the source catalog. -- Assuming exported environment variables were enough. They preserved credentials, not the logical source configuration that had been provided dynamically by the client. - -## Solution - -Persist only the non-secret runtime source catalog to an untracked file and reload it automatically on startup. - -Key changes: - -- `configure` now writes `sources` to `config/sources.runtime.json` -- `.gitignore` excludes `config/sources.runtime.json` -- startup prefers `config/sources.runtime.json`, then falls back to `config/sources.json` -- Kibana credentials continue to come only from environment variables - -Relevant implementation shape: - -```ts -export const DEFAULT_RUNTIME_SOURCE_CATALOG_PATH = "config/sources.runtime.json"; - -export async function persistSourceCatalog( - sources: AppConfig["sources"], - envInput: NodeJS.ProcessEnv = process.env -): Promise { - const sourceCatalogPath = resolveSourceCatalogPath(envInput); - await mkdir(dirname(sourceCatalogPath), { recursive: true }); - await writeFile( - sourceCatalogPath, - `${JSON.stringify({ sources }, null, 2)}\n`, - "utf8" - ); - return sourceCatalogPath; -} -``` - -And startup now uses the runtime file by default: - -```ts -export function resolveSourceCatalogPath(envInput: NodeJS.ProcessEnv = process.env): string { - const explicitPath = envInput.KIBANA_SOURCE_CATALOG_PATH?.trim(); - if (explicitPath) { - return explicitPath; - } - - return DEFAULT_RUNTIME_SOURCE_CATALOG_PATH; -} -``` - -## Why This Works - -The problem was not authentication. It was that the server had no durable store for client-supplied source definitions. Persisting the source catalog solves the exact gap without introducing secret sprawl: - -- credentials remain in env -- source definitions survive process restarts -- the MCP can restart cleanly and still answer queries without another `configure` call - -This keeps the client-driven configuration model while making it operationally stable under Codex MCP restarts. - -## Prevention - -- If an MCP accepts runtime configuration that is required for normal operation after startup, persist the non-secret portion instead of keeping it only in memory. -- Keep secrets and durable configuration separate. In this case: - - secrets: environment variables - - durable runtime state: `config/sources.runtime.json` -- Add a restart-oriented test or verification path whenever a tool mutates server state that agents depend on across calls. -- Prefer startup fallback order that matches operational reality: - - runtime-persisted file first - - static example or bootstrap file second - -## Related Issues - -- None found in `docs/solutions/` at the time of writing. -- Related implementation context: - - `docs/plans/2026-04-02-001-feat-kibana-log-investigation-plan.md` diff --git a/docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md b/docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md index 19edf40..42574ae 100644 --- a/docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md +++ b/docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md @@ -45,9 +45,10 @@ So source discovery and log querying remain usable even when schema metadata is For deployments with this shape: -- leave `schema` unset on sources unless a working metadata endpoint is explicitly confirmed -- rely on `discover`, `filter`, and `query` for log investigation -- treat `describe_fields`, exact-safe field promotion, and nested-field validation as unavailable until a schema backend starts returning data +- configure the appropriate supported schema backend; Kibana data-view and index-pattern backends try their known metadata endpoints +- if all metadata endpoints return `404`, the server falls back to sampling fields through the configured search backend +- treat sampled capabilities as heuristic, not authoritative; they can recover `.keyword` promotion and object paths, but cannot prove nested mappings +- if schema loading and sampling both fail, use configured field hints and non-schema-dependent `discover`, `filter`, and `query` operations ## Verification Checklist @@ -57,5 +58,4 @@ Before re-enabling schema-dependent behavior in a deployment, confirm one of the - Kibana legacy index patterns field metadata endpoint - direct Elasticsearch field capabilities endpoint -If none respond, schema-aware features should remain disabled for that source. -If none respond, schema-aware features should remain disabled for that source. +If none respond, verify the search-backend sampling fallback and inspect its provenance and advisories. Do not equate sampled fields with authoritative mapping metadata. diff --git a/docs/sources/README.md b/docs/sources/README.md index d5e6da2..0fa9442 100644 --- a/docs/sources/README.md +++ b/docs/sources/README.md @@ -6,4 +6,3 @@ Current sources: - `handoff.md` for the original MCP handoff - `features-v1.md` for the v1 capability gap list -- `feature-v1-review.md` for the early live-environment review of remaining gaps diff --git a/docs/sources/feature-v1-review.md b/docs/sources/feature-v1-review.md deleted file mode 100644 index 8c68118..0000000 --- a/docs/sources/feature-v1-review.md +++ /dev/null @@ -1,137 +0,0 @@ -# Feature v1 Review - -## Scope - -This review reflects live checks against the updated Kibana MCP server using a real Kibana deployment and one high-volume structured-log source. - -## Closed Gaps - -### 1. Config Persistence - -This is now working. - -Observed behavior: - -- `configure` returned `persisted: true` -- `source_catalog_path` was reported as `config/sources.runtime.json` - -Impact: - -- source registration no longer appears to be purely in-memory -- this closes the earlier friction where MCP updates forced manual source reconfiguration every time - -### 2. Cursor Pagination - -This is now working. - -Observed behavior: - -- `filter(... mode="hits")` returned a `next_cursor` -- calling the same filter again with `cursor` returned the next page of hits correctly - -Impact: - -- large correlated timelines are now navigable -- this closes a major gap for high-volume request or workflow reconstruction - -### 3. Numeric Sorting on Hits - -This is still working and remains a useful capability. - -Observed behavior: - -- exact filter on `event.keyword = WORKFLOW_RELOAD_STATS` -- `sort_by = total_duration_ms` -- correct descending ranking of slow hits - -Impact: - -- performance-oriented log analysis can now stay inside the MCP - -## Remaining Gaps - -### 1. `describe_fields` Is Still Broken - -This is not yet usable. - -Observed behavior: - -- `describe_fields(source_id="workflow_metrics", query="event")` -- `describe_fields(source_id="workflow_metrics", query="steps")` - -Both failed with: - -- Kibana `404 Not Found` - -Impact: - -- schema discovery is still missing in practice -- the agent still cannot reliably discover: - - field types - - aggregatable vs searchable fields - - `.keyword` subfields - - nested field structure - -Required fix: - -- make `describe_fields` resolve against the actual backend path and source configuration - -### 2. Exact-Match Auto-Resolution Is Still Missing - -This is still a real correctness issue. - -Observed behavior: - -- filtering on `field = event`, `value = CACHE_REFRESH_PHASES` returned `0` hits -- the MCP resolved the field as plain `event` -- the same search works when using `event.keyword` - -Impact: - -- false negatives are still easy to produce -- the user must still know internal mapping details - -Required fix: - -- either auto-upgrade `event` to `event.keyword` when exact matching on a text field -- or add an explicit `exact: true` mode -- or return a structured warning suggesting the keyword field - -### 3. Nested Querying / Extraction Still Fails - -This is still not operational. - -Observed behavior: - -- query attempted with: - - `nested_filters` - - path `steps` - - field `name` - - value `CACHE_REFRESH` - - `extract_nested = true` -- result: Kibana `404 Not Found` - -Impact: - -- checks that depend on `steps[]` still cannot be done natively -- object-level validation inside `WORKFLOW_RELOAD_STATS` still requires manual raw-document inspection or external calls - -Required fix: - -- make nested filtering work against the configured search backend -- make nested extraction return matching nested objects, not just parent documents - -## Summary - -The updated server clearly improved in two important areas: - -- persistent config -- cursor pagination - -However, three high-value capabilities are still not usable in live checks: - -1. `describe_fields` -2. exact-match safe field resolution -3. nested filtering and nested extraction - -These are now the main remaining Kibana MCP gaps for schema-aware log investigation. diff --git a/docs/sources/features-v1.md b/docs/sources/features-v1.md index e9d9a6a..637b206 100644 --- a/docs/sources/features-v1.md +++ b/docs/sources/features-v1.md @@ -168,25 +168,6 @@ Why this is needed: - investigation work often requires `trace_id + timestamp window + job_id/region/service` - correlated workflow inspection is a common operator need -### 9. Config Bootstrap From Environment - -Add a convenience helper for restoring the MCP session quickly. - -Proposed capability: - -- `configure_from_env` - -Suggested env support: - -- `KIBANA_BASE_URL` -- `KIBANA_USERNAME` -- `KIBANA_PASSWORD` - -Why this is needed: - -- MCP configuration was lost after the server update -- re-registering sources manually adds avoidable friction - ## Summary The current MCP already covers: @@ -204,4 +185,3 @@ The remaining gaps are: - numeric aggregation - grouped top-hit retrieval - timeline reconstruction -- config bootstrap diff --git a/scripts/verify-release-contract.mjs b/scripts/verify-release-contract.mjs index a5c70c2..c6d69e6 100644 --- a/scripts/verify-release-contract.mjs +++ b/scripts/verify-release-contract.mjs @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const repoRoot = process.cwd(); @@ -27,14 +27,6 @@ if (!pkg.scripts?.verify?.includes("npm run verify:release-contract")) { throw new Error("npm run verify must include verify:release-contract."); } -if ("changeset" in (pkg.scripts ?? {})) { - throw new Error("package.json must not keep Changesets scripts after the release migration."); -} - -if ("@changesets/cli" in (pkg.devDependencies ?? {})) { - throw new Error("package.json must not depend on @changesets/cli after the release migration."); -} - if (pkg.files?.includes("CHANGELOG.md")) { throw new Error("CHANGELOG.md must not be part of the published artifact surface."); } @@ -78,10 +70,6 @@ if (!releaseWorkflow.includes("npm run release")) { throw new Error("release workflow must run npm run release."); } -if (releaseWorkflow.includes("changesets/action")) { - throw new Error("release workflow must not invoke Changesets."); -} - if (!semanticWorkflow.includes("pull_request:")) { throw new Error("semantic input workflow must validate pull requests."); } @@ -90,16 +78,4 @@ if (!semanticWorkflow.includes("commitlint")) { throw new Error("semantic input workflow must validate PR titles with commitlint."); } -const removedPaths = [ - ".changeset/config.json", - ".changeset/README.md", - ".changeset/eight-radios-admire.md", - ".changeset/forty-deers-unite.md", -]; -for (const path of removedPaths) { - if (existsSync(resolve(repoRoot, path))) { - throw new Error(`${path} must be removed after the semantic-release migration.`); - } -} - console.log("Release contract verified."); diff --git a/skills/kibana-log-investigation/SKILL.md b/skills/kibana-log-investigation/SKILL.md index a5f905e..e3dae06 100644 --- a/skills/kibana-log-investigation/SKILL.md +++ b/skills/kibana-log-investigation/SKILL.md @@ -57,7 +57,7 @@ If you are not running in Codex and the tools of this server (`discover`, `confi - in Claude Code, by installing the plugin: `/plugin marketplace add Havesomecode/kibana-mcp-server`, then `/plugin install kibana-log-investigation@kibana-mcp-server`; - in any other agent, by declaring the stdio command `npx -y @havesomecode/kibana-mcp-server serve` in its MCP configuration. -With `--client none`, a server that was already running when bootstrap completed still has no connection. Ask the operator to reconnect the MCP server or start a new session before calling its tools. +With `--client none`, reconnect the MCP server or start a new session after bootstrap. A server without a configured connection fails at startup; it does not wait for an MCP tool to supply credentials. ### What bootstrap does @@ -120,6 +120,8 @@ To select an existing non-default profile: npx -y @havesomecode/kibana-mcp-server serve --profile prod ``` +For saved-profile startup, do not pass `KIBANA_BASE_URL`, `KIBANA_USERNAME`, or `KIBANA_PASSWORD` to the server process. Supplying all three selects direct environment startup, which requires an explicit `KIBANA_SOURCE_CATALOG_PATH` or a configuration-file catalog store. Saved profiles already carry their catalog path; no checkout catalog is loaded implicitly. Guided `setup` remains available when the operator explicitly wants to import a hand-authored catalog. + ## Failure rules - Never silently import the bundled example catalog. @@ -128,7 +130,7 @@ npx -y @havesomecode/kibana-mcp-server serve --profile prod - Never add `--replace` merely to make a failed install pass. It intentionally replaces an existing catalog with an empty managed catalog. - Never overwrite a conflicting MCP client registration. - Never write or edit an MCP client configuration file to register the server; only bootstrap in Codex, a plugin, or the operator does that. -- Never use the legacy `configure` tool to supply credentials. If it is the only setup tool offered, the server has no saved connection: run bootstrap, then have the server reconnected. +- If startup fails with `No saved Kibana connection is available. Run connection bootstrap first.`, run connection-only bootstrap, then reconnect the server. No MCP tool accepts connection credentials. - Never expose a password in logs, summaries, shell history, generated catalogs, or MCP client configuration. - If URL, username, or password is unavailable, report the exact missing prerequisite rather than inventing it. diff --git a/src/bin/run_cli.ts b/src/bin/run_cli.ts index 638b002..c155408 100644 --- a/src/bin/run_cli.ts +++ b/src/bin/run_cli.ts @@ -1,8 +1,9 @@ import { Command, CommanderError } from "commander"; +import registerBootstrapCommand from "../commands/bootstrap.js"; import type { CliCommandContext } from "../commands/context.js"; import registerServeCommand from "../commands/serve.js"; -import registerSetupCommands from "../commands/setup.js"; +import registerSetupCommand from "../commands/setup.js"; import { startMcpServer } from "../transports/stdio.js"; import { type CliIo, createCliIo } from "../utils/cli_io.js"; import { createCliUi } from "../utils/cli_ui.js"; @@ -17,7 +18,6 @@ export async function runCli( const io = createCliIo(overrides); let exitCode = 0; const context: CliCommandContext = { - argv, io, ui: createCliUi(io), dependencies, @@ -43,7 +43,8 @@ export async function runCli( } }); - registerSetupCommands(program, context); + registerSetupCommand(program, context); + registerBootstrapCommand(program, context); registerServeCommand(program, context); try { diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts new file mode 100644 index 0000000..927416c --- /dev/null +++ b/src/commands/bootstrap.ts @@ -0,0 +1,131 @@ +import { type Command, InvalidArgumentError, Option } from "commander"; + +import { type BootstrapResult, runBootstrap } from "../setup/bootstrap.js"; +import { type PromptIo, createPromptIo } from "../utils/cli_prompts.js"; +import type { CliCommandContext } from "./context.js"; + +interface BootstrapOptions { + profile?: string; + url?: string; + username?: string; + passwordStdin?: boolean; + passwordEnv?: string; + timeout?: number; + client?: "codex" | "none"; + package?: string; + mcpName?: string; + replace?: boolean; + default: boolean; +} + +export default function registerBootstrap(program: Command, context: CliCommandContext): void { + program + .command("bootstrap") + .description("Verify and save only the Kibana connection") + .addHelpText( + "after", + "\nBootstrap does not inspect or configure indexes. Missing connection values are prompted for.\n\nConnection options use KIBANA_BASE_URL, KIBANA_USERNAME, and KIBANA_PASSWORD as fallbacks. Passwords are accepted only through --password-stdin, --password-env , KIBANA_PASSWORD, or a secret prompt. Profile defaults to KIBANA_PROFILE, then default.", + ) + .option("--profile ", "Saved profile name") + .option("--url ", "Kibana base URL") + .option("--username ", "Kibana basic-auth username") + .addOption( + new Option("--password-stdin", "Read the password from stdin").conflicts("passwordEnv"), + ) + .option("--password-env ", "Read the password from an environment variable") + .addOption( + new Option("--timeout ", "Kibana request timeout").argParser(parseTimeout), + ) + .addOption( + new Option("--client ", "Client registration mode").choices(["codex", "none"]), + ) + .option("--package ", "Package specifier for client registration") + .option("--mcp-name ", "MCP registration name") + .option("--replace", "Replace an existing catalog") + .option("--no-default", "Do not make this profile the default") + .action(async (options: BootstrapOptions) => { + const { env, stdin } = context.io; + let promptIo: PromptIo | undefined; + try { + let baseUrl = options.url?.trim() || env.KIBANA_BASE_URL?.trim(); + let username = options.username?.trim() || env.KIBANA_USERNAME?.trim(); + let password = options.passwordStdin + ? (await readAllInput(stdin)).replace(/\r?\n$/, "") + : options.passwordEnv !== undefined + ? env[options.passwordEnv] + : env.KIBANA_PASSWORD; + const promptForPassword = + !password && !options.passwordStdin && options.passwordEnv === undefined; + if (!baseUrl || !username || promptForPassword) { + promptIo = await createPromptIo(context.io); + } + baseUrl ||= (await promptIo?.prompt("Kibana base URL"))?.trim(); + if (!baseUrl) { + throw new Error( + "Kibana base URL is required via --url, KIBANA_BASE_URL, or the associated prompt.", + ); + } + username ||= (await promptIo?.prompt("Kibana username"))?.trim(); + if (!username) { + throw new Error( + "Kibana username is required via --username, KIBANA_USERNAME, or the associated prompt.", + ); + } + if (promptForPassword) { + password = (await promptIo?.prompt("Kibana password", { secret: true }))?.trim(); + } + if (!password) { + throw new Error( + "Kibana password is required via an option, KIBANA_PASSWORD, or the associated prompt.", + ); + } + const result = await (context.dependencies.runBootstrapFn ?? runBootstrap)({ + profileName: options.profile?.trim() || env.KIBANA_PROFILE?.trim() || "default", + baseUrl, + username, + password, + client: options.client ?? "codex", + packageSpecifier: options.package, + mcpName: options.mcpName, + makeDefault: options.default, + replaceExisting: options.replace ?? false, + timeoutMs: options.timeout, + }); + context.ui.success(renderBootstrapResult(result)); + } catch (error) { + context.ui.error(error instanceof Error ? error.message : String(error)); + context.setExitCode(1); + } finally { + await promptIo?.close(); + } + }); +} + +function parseTimeout(value: string): number { + const timeout = Number(value); + if (!Number.isInteger(timeout) || timeout <= 0 || timeout > 120000) { + throw new InvalidArgumentError( + "--timeout must be a positive integer no greater than 120000 milliseconds.", + ); + } + return timeout; +} + +function renderBootstrapResult(result: BootstrapResult): string { + return [ + `Bootstrap verified for profile '${result.profileName}'.`, + result.sourceCount === 0 + ? "Source catalog is empty. No Kibana indexes were inspected or configured. Ask the user which index or index pattern to configure before querying logs." + : `Preserved ${result.sourceCount} explicitly configured source${result.sourceCount === 1 ? "" : "s"}; bootstrap did not inspect them.`, + result.registered + ? `Codex MCP registration is installed and verified for profile '${result.profileName}'.` + : "Client registration was skipped by request.", + ].join("\n"); +} + +async function readAllInput(stdin: import("node:stream").Readable): Promise { + let raw = ""; + for await (const chunk of stdin) + raw += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + return raw; +} diff --git a/src/commands/context.ts b/src/commands/context.ts index ba5d23e..efc5350 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -6,7 +6,6 @@ import type { CliIo } from "../utils/cli_io.js"; import type { CliUi } from "../utils/cli_ui.js"; export interface CliCommandContext { - argv: string[]; io: CliIo; ui: CliUi; dependencies: { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 10b32ad..2e8d237 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,171 +1,25 @@ -import { type Command, InvalidArgumentError, Option } from "commander"; +import type { Command } from "commander"; -import { type BootstrapResult, runBootstrap as runBootstrapFlow } from "../setup/bootstrap.js"; import { runSetupFlow } from "../setup/setup_flow.js"; import { PromptCancelledError, createPromptIo } from "../utils/cli_prompts.js"; import type { CliCommandContext } from "./context.js"; -interface SetupOptions { - profile?: string; - url?: string; - username?: string; - passwordStdin?: boolean; - passwordEnv?: string; - timeout?: number; - client?: "codex" | "none"; - package?: string; - mcpName?: string; - replace?: boolean; - default: boolean; -} - export default function registerSetup(program: Command, context: CliCommandContext): void { - const setupCommand = addOptions( - program - .command("setup") - .alias("bootstrap") - .description("Run guided machine setup and connection bootstrap") - .addHelpText( - "after", - "\nBootstrap is an alias for setup. Without options, setup is guided; with options, it verifies and saves only the Kibana connection and does not inspect or configure indexes.\n\nConnection options use KIBANA_BASE_URL, KIBANA_USERNAME, and KIBANA_PASSWORD as fallbacks. Passwords are accepted only through --password-stdin, --password-env , or KIBANA_PASSWORD.", - ), - ); - setupCommand.action(async () => { - const options = readOptions(context.argv); - if (context.argv.some((argument) => argument.startsWith("-"))) - return runBootstrap(options, context); - const promptIo = await createPromptIo(context.io); - try { - const result = await (context.dependencies.runSetupFlowFn ?? runSetupFlow)(promptIo); - context.ui.success( - `Saved ${result.profiles.length} environment${result.profiles.length === 1 ? "" : "s"}. Default environment: ${result.defaultProfileName}.`, - ); - } catch (error) { - if (error instanceof PromptCancelledError) context.setExitCode(130); - else throw error; - } finally { - await promptIo.close(); - } - }); -} - -function readOptions(argv: string[]): SetupOptions { - const value = (flag: string) => { - const index = argv.indexOf(flag); - return index === -1 ? undefined : argv[index + 1]; - }; - return { - profile: value("--profile"), - url: value("--url"), - username: value("--username"), - passwordStdin: argv.includes("--password-stdin"), - passwordEnv: value("--password-env"), - timeout: value("--timeout") === undefined ? undefined : Number(value("--timeout")), - client: value("--client") as SetupOptions["client"], - package: value("--package"), - mcpName: value("--mcp-name"), - replace: argv.includes("--replace"), - default: !argv.includes("--no-default"), - }; -} - -function addOptions(command: Command): Command { - return command - .option("--profile ", "Saved profile name") - .option("--url ", "Kibana base URL") - .option("--username ", "Kibana basic-auth username") - .addOption( - new Option("--password-stdin", "Read the password from stdin").conflicts("passwordEnv"), - ) - .option("--password-env ", "Read the password from an environment variable") - .addOption( - new Option("--timeout ", "Kibana request timeout").argParser(parseTimeout), - ) - .addOption( - new Option("--client ", "Client registration mode").choices(["codex", "none"]), - ) - .option("--package ", "Package specifier for client registration") - .option("--mcp-name ", "MCP registration name") - .option("--replace", "Replace an existing catalog") - .option("--no-default", "Do not make this profile the default"); -} - -async function runBootstrap(options: SetupOptions, context: CliCommandContext): Promise { - const needsPrompt = - !options.url || - !options.username || - (!options.passwordStdin && options.passwordEnv === undefined); - const promptIo = needsPrompt ? await createPromptIo(context.io) : undefined; - try { - const result: BootstrapResult = await (context.dependencies.runBootstrapFn ?? runBootstrapFlow)( - await resolveBootstrapOptions(options, context, promptIo), - ); - context.ui.success(renderBootstrapResult(result)); - } catch (error) { - context.ui.error(error instanceof Error ? error.message : String(error)); - context.setExitCode(1); - } finally { - await promptIo?.close(); - } -} - -async function resolveBootstrapOptions( - values: SetupOptions, - context: CliCommandContext, - prompter?: import("../setup/setup_flow.js").SetupPrompter, -): Promise[0]> { - const { env, stdin } = context.io; - const baseUrl = values.url?.trim() || (await prompter?.prompt("Kibana base URL"))?.trim(); - if (!baseUrl) throw new Error("Kibana base URL is required via --url or the associated prompt."); - const username = values.username?.trim() || (await prompter?.prompt("Kibana username"))?.trim(); - if (!username) - throw new Error("Kibana username is required via --username or the associated prompt."); - - const password = values.passwordStdin - ? (await readAllInput(stdin)).replace(/\r?\n$/, "") - : values.passwordEnv - ? env[values.passwordEnv] - : (await prompter?.prompt("Kibana password", { secret: true }))?.trim(); - if (!password) - throw new Error("Kibana password is required via an option or the associated prompt."); - - return { - profileName: values.profile?.trim() || env.KIBANA_PROFILE?.trim() || "default", - baseUrl, - username, - password, - client: values.client ?? "codex", - packageSpecifier: values.package, - mcpName: values.mcpName, - makeDefault: values.default, - replaceExisting: values.replace ?? false, - timeoutMs: values.timeout, - }; -} - -function parseTimeout(value: string): number { - const timeout = Number(value); - if (!Number.isFinite(timeout)) { - throw new InvalidArgumentError("--timeout must be a number of milliseconds."); - } - return timeout; -} - -function renderBootstrapResult(result: BootstrapResult): string { - return [ - `Bootstrap verified for profile '${result.profileName}'.`, - result.sourceCount === 0 - ? "Source catalog is empty. No Kibana indexes were inspected or configured. Ask the user which index or index pattern to configure before querying logs." - : `Preserved ${result.sourceCount} explicitly configured source${result.sourceCount === 1 ? "" : "s"}; bootstrap did not inspect them.`, - result.registered - ? `Codex MCP registration is installed and verified for profile '${result.profileName}'.` - : "Client registration was skipped by request.", - ].join("\n"); -} - -async function readAllInput(stdin: import("node:stream").Readable): Promise { - let raw = ""; - for await (const chunk of stdin) - raw += typeof chunk === "string" ? chunk : chunk.toString("utf8"); - return raw; + program + .command("setup") + .description("Run guided catalog and profile setup") + .action(async () => { + const promptIo = await createPromptIo(context.io); + try { + const result = await (context.dependencies.runSetupFlowFn ?? runSetupFlow)(promptIo); + context.ui.success( + `Saved ${result.profiles.length} environment${result.profiles.length === 1 ? "" : "s"}. Default environment: ${result.defaultProfileName}.`, + ); + } catch (error) { + if (error instanceof PromptCancelledError) context.setExitCode(130); + else throw error; + } finally { + await promptIo.close(); + } + }); } diff --git a/src/config/environment.ts b/src/config/environment.ts index 2a014be..9fb4900 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -5,20 +5,10 @@ import { z } from "zod"; import { type SecretStore, SecretStoreError, createSecretStore } from "../profiles/secret_store.js"; import { ProfileStore } from "../profiles/store.js"; import type { AppConfig, ResolvedAppConfig } from "../types.js"; -import { writeTextAtomically } from "../utils/fs.js"; -export const DEFAULT_RUNTIME_SOURCE_CATALOG_PATH = "config/sources.runtime.json"; -export const DEFAULT_STATIC_SOURCE_CATALOG_PATH = "config/sources.json"; export const PROFILE_NAME_ENV = "KIBANA_PROFILE"; export const PROFILE_PLACEHOLDER = "{profile}"; -export class NoSavedProfileError extends Error { - constructor(message: string) { - super(message); - this.name = "NoSavedProfileError"; - } -} - const bootstrapEnvSchema = z.object({ KIBANA_BASE_URL: z.url(), KIBANA_USERNAME: z.string().min(1), @@ -124,15 +114,6 @@ function buildKibanaConfig(env: z.output): AppConfig[ }; } -export function resolveSourceCatalogPath(envInput: NodeJS.ProcessEnv = process.env): string { - const explicitPath = envInput.KIBANA_SOURCE_CATALOG_PATH?.trim(); - if (explicitPath) { - return explicitPath; - } - - return DEFAULT_RUNTIME_SOURCE_CATALOG_PATH; -} - export function parseAppConfig(envInput: unknown, sourceCatalogInput: unknown): AppConfig { const env = bootstrapEnvSchema.parse(envInput); const sourceCatalog = sourceCatalogSchema.parse(sourceCatalogInput); @@ -158,23 +139,14 @@ export async function loadConfigFromEnvironment( const kibana = readBootstrapConnection(envInput); if (kibana) { - const preferredPath = resolveSourceCatalogPath(envInput); - const sourceCatalog = await loadSourceCatalog( - preferredPath === DEFAULT_RUNTIME_SOURCE_CATALOG_PATH - ? [preferredPath, DEFAULT_STATIC_SOURCE_CATALOG_PATH] - : [preferredPath], - readFileImpl, - ); + const sourceCatalogPath = overrides.KIBANA_SOURCE_CATALOG_PATH?.trim(); + if (!sourceCatalogPath) { + throw new Error("KIBANA_SOURCE_CATALOG_PATH is required for an environment connection."); + } return { kibana, - sources: sourceCatalogSchema.parse(sourceCatalog.value).sources, - sourceCatalogPath: preferredPath, - sourceCatalogOrigin: - sourceCatalog.loadedFrom === DEFAULT_STATIC_SOURCE_CATALOG_PATH - ? "static_default" - : preferredPath === DEFAULT_RUNTIME_SOURCE_CATALOG_PATH - ? "runtime_default" - : "environment", + sources: await loadSourceCatalog(sourceCatalogPath, readFileImpl), + sourceCatalogPath, }; } @@ -188,13 +160,10 @@ export async function loadConfigFromEnvironment( : await profileStore.getDefaultProfile(); if (!profile) { - if (!requestedProfileName) { - throw new NoSavedProfileError("No saved Kibana profile is available."); - } throw new Error( requestedProfileName ? `Saved Kibana profile '${requestedProfileName}' was not found.` - : "No saved Kibana profile is available.", + : "No saved Kibana connection is available. Run connection bootstrap first.", ); } @@ -208,7 +177,6 @@ export async function loadConfigFromEnvironment( }); const sourceCatalogPath = overrides.KIBANA_SOURCE_CATALOG_PATH?.trim() || profile.sourceCatalogPath; - const sourceCatalog = await loadSourceCatalog([sourceCatalogPath], readFileImpl); return { kibana: { @@ -217,10 +185,9 @@ export async function loadConfigFromEnvironment( password: savedSecret.password, timeoutMs: parseTimeoutOverride(overrides.KIBANA_TIMEOUT_MS) ?? profile.timeoutMs, }, - sources: sourceCatalogSchema.parse(sourceCatalog.value).sources, + sources: await loadSourceCatalog(sourceCatalogPath, readFileImpl), profileName: profile.name, sourceCatalogPath, - sourceCatalogOrigin: overrides.KIBANA_SOURCE_CATALOG_PATH?.trim() ? "environment" : "profile", }; } @@ -241,22 +208,6 @@ export function readBootstrapConnection( : undefined; } -export async function persistSourceCatalog( - sources: AppConfig["sources"], - options: { - envInput?: NodeJS.ProcessEnv; - sourceCatalogPath?: string; - catalog?: unknown; - } = {}, -): Promise { - const sourceCatalogPath = options.sourceCatalogPath ?? resolveSourceCatalogPath(options.envInput); - await writeTextAtomically( - sourceCatalogPath, - `${JSON.stringify(options.catalog ?? { sources }, null, 2)}\n`, - ); - return sourceCatalogPath; -} - function hasBootstrapConfig(envInput: z.infer): boolean { return Boolean( envInput.KIBANA_BASE_URL?.trim() && @@ -287,32 +238,8 @@ function parseTimeoutOverride(value: string | undefined): number | undefined { } async function loadSourceCatalog( - candidatePaths: string[], + path: string, readFileImpl: typeof readFile, -): Promise<{ - loadedFrom: string; - value: unknown; -}> { - let sourceCatalogRaw: string | undefined; - let loadedFrom = candidatePaths[0]; - let lastError: unknown; - - for (const candidatePath of candidatePaths) { - try { - sourceCatalogRaw = await readFileImpl(candidatePath, "utf8"); - loadedFrom = candidatePath; - break; - } catch (error) { - lastError = error; - } - } - - if (!sourceCatalogRaw) { - throw lastError instanceof Error ? lastError : new Error("Source catalog could not be loaded"); - } - - return { - loadedFrom, - value: JSON.parse(sourceCatalogRaw) as unknown, - }; +): Promise { + return sourceCatalogSchema.parse(JSON.parse(await readFileImpl(path, "utf8"))).sources; } diff --git a/src/config/startup.ts b/src/config/startup.ts index 25590da..b26c616 100644 --- a/src/config/startup.ts +++ b/src/config/startup.ts @@ -9,7 +9,6 @@ import { loadConfigFile, } from "./config_file.js"; import { - NoSavedProfileError, PROFILE_NAME_ENV, PROFILE_PLACEHOLDER, loadConfigFromEnvironment, @@ -124,15 +123,8 @@ async function resolveConfiguration( { ...dependencies, exactProfileName: true }, ); - try { - return { - defaultConfig: await loadConfigFromEnvironment(envInput, dependencies), - loadProfileConfig, - }; - } catch (error) { - if (error instanceof NoSavedProfileError) { - return {}; - } - throw error; - } + return { + defaultConfig: await loadConfigFromEnvironment(envInput, dependencies), + loadProfileConfig, + }; } diff --git a/src/profiles/runtime.ts b/src/profiles/runtime.ts index d56c86f..f25919f 100644 --- a/src/profiles/runtime.ts +++ b/src/profiles/runtime.ts @@ -36,9 +36,12 @@ export class ProfileRuntimeRegistry { private readonly inFlight = new Map>(); private readonly clients = new Map(); private readonly refreshes = new WeakMap(); - private defaultRuntime?: ProfileRuntime; + private readonly defaultRuntime?: ProfileRuntime; constructor(options: ProfileRuntimeRegistryOptions = {}) { + if (!options.defaultConfig && !options.loadProfileConfig) { + throw new Error("No saved Kibana connection is available. Run connection bootstrap first."); + } this.kibanaClientFactory = options.kibanaClientFactory ?? ((config: AppConfig["kibana"]) => new KibanaClient(config)); this.loadProfileConfig = options.loadProfileConfig; @@ -56,29 +59,13 @@ export class ProfileRuntimeRegistry { return this.loadProfileConfig !== undefined; } - /** False only for a server started without any configuration, waiting for the legacy 'configure' tool. */ - get isConfigured(): boolean { - return this.hasDefault || this.supportsNamedProfiles; - } - - getDefault(): ProfileRuntime | undefined { - return this.defaultRuntime; - } - - setDefault(config: ResolvedAppConfig): ProfileRuntime { - this.defaultRuntime = this.createRuntime(config); - return this.defaultRuntime; - } - async resolve(profileName?: string): Promise { if (profileName === undefined) { if (this.defaultRuntime) { return this.refresh(this.defaultRuntime); } throw new Error( - this.loadProfileConfig - ? "No profile was supplied for this call and no default profile is configured." - : "Server is not configured. Call the 'configure' tool first.", + "No profile was supplied for this call and no default profile is configured.", ); } diff --git a/src/server.ts b/src/server.ts index d50c7db..2402132 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,12 +3,6 @@ import { type CallToolResult, McpServer } from "@modelcontextprotocol/server"; import { type KibanaClient, KibanaRequestError } from "./kibana/client.js"; import { requireProfileRuntime, runWithProfileRuntime } from "./profiles/context.js"; import { type ProfileRuntime, ProfileRuntimeRegistry } from "./profiles/runtime.js"; -import { - configureInputSchema, - configureOutputSchema, - createConfigureCallToolResult, - executeConfigure, -} from "./tools/configure.js"; import { configureIndexInputSchema, configureIndexOutputSchema, @@ -72,7 +66,6 @@ async function withKibanaRequestDiagnostics( export interface Application { server: McpServer; handlers: { - configure: (input: unknown) => Promise>["result"]>; configure_index: ( input: unknown, callerSignal?: AbortSignal, @@ -145,24 +138,7 @@ export function createApplication( } } - const configureHandler = async (input: unknown) => { - const current = registry.getDefault()?.config; - const { nextConfig, result } = await executeConfigure(configureInputSchema.parse(input), { - sourceCatalogPath: current?.sourceCatalogPath, - }); - registry.setDefault({ - ...nextConfig, - sourceCatalogPath: result.source_catalog_path, - profileName: current?.profileName, - sourceCatalogOrigin: current?.sourceCatalogOrigin, - }); - return result; - }; - const configureIndexHandler = async (input: unknown, callerSignal?: AbortSignal) => { - if (!registry.isConfigured) { - throw new Error("No saved Kibana connection is available. Run connection bootstrap first."); - } const configureIndexFn = dependencies?.configureIndexFn ?? executeConfigureIndex; return withProfile((runtime) => withKibanaToolDeadline(callerSignal, async (signal) => { @@ -214,34 +190,21 @@ export function createApplication( ), ); - if (!registry.isConfigured) { - server.registerTool( - "configure", - { - description: - "Advanced legacy setup for a server started without a saved profile. Replaces the Kibana connection and full source catalog.", - inputSchema: configureInputSchema, - outputSchema: configureOutputSchema, - }, - async (input) => createConfigureCallToolResult(await configureHandler(input)), - ); - } else { - server.registerTool( - "configure_index", - { - description: - "Configure exactly one Kibana index or index pattern after the user has explicitly named it. Never guess, list, scan, or auto-select indexes before calling this tool.", - inputSchema: configureIndexInputSchema, - outputSchema: configureIndexOutputSchema, - }, - async (input, context) => - withKibanaRequestDiagnostics(async () => - createConfigureIndexCallToolResult( - await configureIndexHandler(input, context.mcpReq.signal), - ), + server.registerTool( + "configure_index", + { + description: + "Configure exactly one Kibana index or index pattern after the user has explicitly named it. Never guess, list, scan, or auto-select indexes before calling this tool.", + inputSchema: configureIndexInputSchema, + outputSchema: configureIndexOutputSchema, + }, + async (input, context) => + withKibanaRequestDiagnostics(async () => + createConfigureIndexCallToolResult( + await configureIndexHandler(input, context.mcpReq.signal), ), - ); - } + ), + ); server.registerTool( "describe_fields", @@ -299,7 +262,6 @@ export function createApplication( return { server, handlers: { - configure: configureHandler, configure_index: configureIndexHandler, describe_fields: describeFieldsHandler, discover: discoverHandler, diff --git a/src/setup/bootstrap.ts b/src/setup/bootstrap.ts index f2b8142..7828356 100644 --- a/src/setup/bootstrap.ts +++ b/src/setup/bootstrap.ts @@ -394,9 +394,8 @@ export async function resolveCurrentPackageSpecifier(): Promise { } export function createGeneratedCatalog( - sourceOrSources: SourceDefinition | SourceDefinition[], + sources: SourceDefinition[], ): ReturnType { - const sources = Array.isArray(sourceOrSources) ? sourceOrSources : [sourceOrSources]; const parsedSources = sources.map((source) => sourceDefinitionSchema.parse(source)); return sourceCatalogSchema.parse({ generatedBy: { diff --git a/src/tools/configure.ts b/src/tools/configure.ts deleted file mode 100644 index 85e649c..0000000 --- a/src/tools/configure.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { CallToolResult } from "@modelcontextprotocol/server"; -import { z } from "zod"; - -import { persistSourceCatalog, sourceDefinitionSchema } from "../config/environment.js"; -import type { AppConfig } from "../types.js"; - -export const configureInputSchema = z.object({ - kibana: z.object({ - baseUrl: z.url(), - username: z.string().min(1), - password: z.string().min(1), - timeoutMs: z.number().int().positive().max(120000).default(10000), - }), - sources: z.array(sourceDefinitionSchema).min(1), -}); - -export const configureOutputSchema = z.object({ - configured: z.literal(true), - persisted: z.literal(true), - source_catalog_path: z.string(), - source_count: z.number().int().positive(), - source_ids: z.array(z.string()), - base_url: z.string(), -}); - -export async function executeConfigure( - input: z.infer, - options: { - envInput?: NodeJS.ProcessEnv; - sourceCatalogPath?: string; - } = {}, -): Promise<{ nextConfig: AppConfig; result: z.infer }> { - const nextConfig: AppConfig = { - kibana: { - baseUrl: input.kibana.baseUrl.replace(/\/+$/, ""), - username: input.kibana.username, - password: input.kibana.password, - timeoutMs: input.kibana.timeoutMs, - }, - sources: input.sources, - }; - const sourceCatalogPath = await persistSourceCatalog(input.sources, options); - - return { - nextConfig, - result: { - configured: true, - persisted: true, - source_catalog_path: sourceCatalogPath, - source_count: input.sources.length, - source_ids: input.sources.map((source) => source.id), - base_url: nextConfig.kibana.baseUrl, - }, - }; -} - -export function createConfigureCallToolResult( - result: z.infer, -): CallToolResult { - return { - content: [ - { - type: "text", - text: `Configured Kibana connection for ${result.base_url} with ${result.source_count} sources. Source catalog persisted to ${result.source_catalog_path}.`, - }, - ], - structuredContent: result, - }; -} diff --git a/src/tools/configure_index.ts b/src/tools/configure_index.ts index 6a491be..676a4d4 100644 --- a/src/tools/configure_index.ts +++ b/src/tools/configure_index.ts @@ -55,8 +55,7 @@ export async function executeConfigureIndex( // only happens if the catalog is still the one that was read, so Kibana is never called under a lock. const catalog = options.config.catalog ?? fileCatalogOf(options.config, options.stateRoot); const existingContent = await catalog.read(); - // Sources without a catalog at the persisted location were loaded from somewhere else, such as - // the static fallback catalog: starting from an empty catalog would silently drop them. + // A disappeared catalog must not silently discard sources already loaded into memory. if (existingContent === undefined && options.config.sources.length > 0) { throw new Error( `Source catalog '${catalog.location}' does not exist although sources are configured. Refusing to replace them with a new managed catalog.`, diff --git a/src/transports/http.ts b/src/transports/http.ts index 85c5c5e..80d2e1b 100644 --- a/src/transports/http.ts +++ b/src/transports/http.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import { createHash, timingSafeEqual } from "node:crypto"; import { type IncomingMessage, type Server as NodeHttpServer, @@ -9,10 +9,7 @@ import type { AddressInfo } from "node:net"; import { Readable } from "node:stream"; import { - type McpServer, - WebStandardStreamableHTTPServerTransport, createMcpHandler, - isLegacyRequest, localhostAllowedHostnames, localhostAllowedOrigins, validateHostHeader, @@ -36,9 +33,6 @@ const DEFAULT_PORT = 3000; const DEFAULT_PATH = "/mcp"; const HEALTH_PATH = "/healthz"; const MAX_BODY_BYTES = 4 * 1024 * 1024; -const MAX_LEGACY_SESSIONS = 128; -const MAX_LEGACY_SESSION_REQUESTS = 1024; -const LEGACY_SESSION_IDLE_MS = 30 * 60 * 1000; const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]); export interface HttpServerOptions { @@ -101,108 +95,22 @@ async function listen( } if (!resolved.profileHeader && !registry.hasDefault) { throw new Error( - "The HTTP transport needs a profile header, or a default profile named with --profile or KIBANA_PROFILE to serve every caller the same catalog. The legacy 'configure' tool is available over stdio only.", + "The HTTP transport needs a profile header, or a default profile named with --profile or KIBANA_PROFILE to serve every caller the same catalog.", ); } - // Modern and handshake-free legacy calls stay per-request. - const handler = createMcpHandler((context) => { - const requestProfile = resolved.profileHeader - ? readProfileHeader(context.requestInfo?.headers, resolved.profileHeader) - : undefined; - return createApplication(undefined, { profileRegistry: registry, requestProfile }).server; - }); - - const legacySessions = new Map< - string, - { - server: McpServer; - transport: WebStandardStreamableHTTPServerTransport; - profile: string | undefined; - lastUsed: number; - requests: number; - } - >(); - let initializingSessions = 0; - const fetchHandler = async (request: Request): Promise => { - if (!(await isLegacyRequest(request))) return handler.fetch(request); - for (const session of legacySessions.values()) { - if (Date.now() - session.lastUsed >= LEGACY_SESSION_IDLE_MS) await session.server.close(); - } - const profile = resolved.profileHeader - ? readProfileHeader(request.headers, resolved.profileHeader) - : undefined; - const sessionId = request.headers.get("mcp-session-id"); - if (sessionId) { - let session = legacySessions.get(sessionId); - // ponytail: recycle bounded sessions until the SDK releases cancelled request metadata. - if ( - session && - session.profile === profile && - session.requests >= MAX_LEGACY_SESSION_REQUESTS - ) { - await session.server.close(); - session = undefined; - } - if (!session || session.profile !== profile) { - return Response.json( - { - jsonrpc: "2.0", - id: null, - error: { code: -32001, message: "Session not found" }, - }, - { status: 404 }, - ); - } - session.lastUsed = Date.now(); - session.requests += 1; - return session.transport.handleRequest(request); - } - const body = - request.method === "POST" - ? await request - .clone() - .json() - .catch(() => undefined) + const handler = createMcpHandler( + (context) => { + const requestProfile = resolved.profileHeader + ? readProfileHeader(context.requestInfo?.headers, resolved.profileHeader) : undefined; - if (body?.method !== "initialize") return handler.fetch(request); - if (legacySessions.size + initializingSessions >= MAX_LEGACY_SESSIONS) { - return Response.json( - { error: "Too many MCP sessions. Close an existing session and retry." }, - { status: 503 }, - ); - } - - // The SDK owns request IDs and cancellation inside each unguessable client session. - initializingSessions += 1; - const server = createApplication(undefined, { - profileRegistry: registry, - requestProfile: profile, - }).server; - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: randomUUID, - onsessioninitialized: (id) => { - legacySessions.set(id, { server, transport, profile, lastUsed: Date.now(), requests: 0 }); - }, - }); - transport.onclose = () => { - legacySessions.delete(transport.sessionId ?? ""); - }; - try { - await server.connect(transport); - const response = await transport.handleRequest(request, { parsedBody: body }); - if (!transport.sessionId || response.status >= 400) await server.close(); - return response; - } catch (error) { - await server.close().catch(() => {}); - throw error; - } finally { - initializingSessions -= 1; - } - }; + return createApplication(undefined, { profileRegistry: registry, requestProfile }).server; + }, + { legacy: "reject" }, + ); const httpServer = createServer((request, response) => { - void handleRequest(request, response, resolved, fetchHandler).catch(() => { + void handleRequest(request, response, resolved, handler.fetch).catch(() => { if (!response.headersSent) { writeJson(response, 500, { error: "internal_error" }); return; @@ -225,7 +133,6 @@ async function listen( url: `http://${urlHost}:${address.port}${resolved.path}`, close: async () => { await handler.close(); - await Promise.all([...legacySessions.values()].map((session) => session.server.close())); await closeHttpServer(httpServer); await startupConfiguration.close(); }, diff --git a/src/transports/stdio.ts b/src/transports/stdio.ts index 2674ada..c08a6bf 100644 --- a/src/transports/stdio.ts +++ b/src/transports/stdio.ts @@ -4,8 +4,7 @@ import { DEFAULT_PROFILE_NAME, loadStartupConfiguration } from "../config/startu import { createApplication } from "../server.js"; export async function startMcpServer(envInput: NodeJS.ProcessEnv = process.env): Promise { - // stdio serves one global configuration: the default profile, or the legacy 'configure' flow. - // It has a single caller, so per-profile catalogs fall back to one well-known profile. + // stdio has one caller and serves the configured default profile. const { startupConfiguration } = await loadStartupConfiguration(envInput); let server: ReturnType["server"] | undefined; let closing: Promise | undefined; diff --git a/src/types.ts b/src/types.ts index 83555ae..aa71cc1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,7 +73,6 @@ export interface ResolvedAppConfig extends AppConfig { /** Set when the catalog of the profile lives in a catalog store rather than at a plain path. */ catalog?: CatalogHandle; sourceCatalogPath?: string; - sourceCatalogOrigin?: "environment" | "profile" | "runtime_default" | "static_default"; } export interface SavedProfile { diff --git a/test/bootstrap.test.ts b/test/bootstrap.test.ts index 88df1d1..190cadf 100644 --- a/test/bootstrap.test.ts +++ b/test/bootstrap.test.ts @@ -80,7 +80,7 @@ describe("deterministic bootstrap", () => { }); it("marks generated catalogs with a content hash for overwrite provenance", () => { - const catalog = createGeneratedCatalog(buildGeneratedSource(["consumer-*"])); + const catalog = createGeneratedCatalog([buildGeneratedSource(["consumer-*"])]); expect(catalog.generatedBy).toMatchObject({ tool: "@havesomecode/kibana-mcp-server", formatVersion: 1, @@ -334,7 +334,7 @@ describe("deterministic bootstrap", () => { }, { makeDefault: true }, ); - const tamperedCatalog = createGeneratedCatalog(buildGeneratedSource(["consumer-*"])) as { + const tamperedCatalog = createGeneratedCatalog([buildGeneratedSource(["consumer-*"])]) as { generatedBy: { tool: string; formatVersion: number; sourceHash: string }; sources: Array>; }; @@ -394,7 +394,7 @@ describe("deterministic bootstrap", () => { ); await writeFile( catalogPath, - JSON.stringify(createGeneratedCatalog(buildGeneratedSource(["consumer-*"]))), + JSON.stringify(createGeneratedCatalog([buildGeneratedSource(["consumer-*"])])), "utf8", ); let saved = false; @@ -451,7 +451,7 @@ describe("deterministic bootstrap", () => { ); await writeFile( catalogPath, - JSON.stringify(createGeneratedCatalog(buildGeneratedSource(["consumer-*"]))), + JSON.stringify(createGeneratedCatalog([buildGeneratedSource(["consumer-*"])])), "utf8", ); let stored: SavedSecret | undefined = { username: "old", password: "old-secret" }; @@ -506,7 +506,7 @@ describe("deterministic bootstrap", () => { await mkdir(paths.sourceCatalogsDir, { recursive: true }); await writeFile( catalogPath, - JSON.stringify(createGeneratedCatalog(buildGeneratedSource(["consumer-*"]))), + JSON.stringify(createGeneratedCatalog([buildGeneratedSource(["consumer-*"])])), "utf8", ); let stored: SavedSecret | undefined = { username: "orphan", password: "recoverable" }; diff --git a/test/cli.test.ts b/test/cli.test.ts index ecf54ca..2c7055f 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,7 +1,22 @@ import { Readable } from "node:stream"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { runCli } from "../src/bin/run_cli.js"; +const connectionEnv = { + KIBANA_BASE_URL: "https://kibana.example.com", + KIBANA_USERNAME: "elastic", + KIBANA_PASSWORD: " env-secret ", +}; +const bootstrapResult = { + profileName: "default", + profileId: "default", + sourceCount: 0, + sourceCatalogPath: "/tmp/default.json", + client: "codex" as const, + registered: true, + verified: true, +}; + describe("runCli", () => { it("starts the MCP server without prompting when no command is supplied", async () => { let started = false; @@ -35,6 +50,7 @@ describe("runCli", () => { stderr: (text) => stderr.push(text), stdin: Readable.from("\nsecret\n\n"), stdinIsTTY: false, + env: connectionEnv, }, { async runSetupFlowFn(promptIo) { @@ -137,6 +153,203 @@ describe("runCli", () => { expect(stdout.join("\n")).toContain("Bootstrap verified"); }); + it.each([undefined, " staging "])( + "bootstraps without flags using connection environment values and profile %s", + async (profile) => { + const stdout: string[] = []; + const runBootstrapFn = vi.fn().mockResolvedValue(bootstrapResult); + const runSetupFlowFn = vi.fn(); + const exitCode = await runCli( + ["node", "kibana-mcp-server", "bootstrap"], + { + env: { ...connectionEnv, KIBANA_PROFILE: profile }, + // A prompt must not consume stdin when all connection values are supplied. + stdin: new Readable({ + read() { + throw new Error("Unexpected stdin read"); + }, + }), + stdout: (text) => stdout.push(text), + stderr: () => {}, + }, + { runBootstrapFn, runSetupFlowFn }, + ); + + expect(exitCode).toBe(0); + expect(runSetupFlowFn).not.toHaveBeenCalled(); + expect(runBootstrapFn).toHaveBeenCalledExactlyOnceWith({ + profileName: profile?.trim() ?? "default", + baseUrl: connectionEnv.KIBANA_BASE_URL, + username: connectionEnv.KIBANA_USERNAME, + password: connectionEnv.KIBANA_PASSWORD, + client: "codex", + makeDefault: true, + replaceExisting: false, + packageSpecifier: undefined, + mcpName: undefined, + timeoutMs: undefined, + }); + expect(stdout.join("\n")).not.toContain(connectionEnv.KIBANA_PASSWORD); + expect(stdout.join("\n")).toContain("No Kibana indexes were inspected or configured"); + }, + ); + + it("prompts only for the connection when bootstrap has no flags or environment values", async () => { + const stdout: string[] = []; + const runBootstrapFn = vi.fn().mockResolvedValue(bootstrapResult); + const runSetupFlowFn = vi.fn(); + const exitCode = await runCli( + ["node", "kibana-mcp-server", "bootstrap"], + { + env: {}, + stdin: Readable.from("https://kibana.example.com\nelastic\nprompt-secret\n"), + stdinIsTTY: false, + stdout: (text) => stdout.push(text), + stderr: () => {}, + }, + { runBootstrapFn, runSetupFlowFn }, + ); + + expect(exitCode).toBe(0); + expect(runSetupFlowFn).not.toHaveBeenCalled(); + expect(runBootstrapFn).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://kibana.example.com", + username: "elastic", + password: "prompt-secret", + }), + ); + expect(stdout.slice(0, 3)).toEqual([ + "Kibana base URL:", + "Kibana username:", + "Kibana password:", + ]); + expect(stdout.join("\n")).not.toContain("prompt-secret"); + }); + + it("registers setup and bootstrap separately and keeps connection flags off setup", async () => { + const stdout: string[] = []; + expect( + await runCli(["node", "kibana-mcp-server", "--help"], { + stdout: (text) => stdout.push(text), + stderr: () => {}, + }), + ).toBe(0); + expect(stdout.join("\n")).toMatch(/\n\s+setup\b/); + expect(stdout.join("\n")).toMatch(/\n\s+bootstrap\b/); + expect(stdout.join("\n")).not.toContain("setup|bootstrap"); + stdout.length = 0; + expect( + await runCli(["node", "kibana-mcp-server", "setup", "--help"], { + stdout: (text) => stdout.push(text), + stderr: () => {}, + }), + ).toBe(0); + expect(stdout.join("\n")).toContain("guided"); + expect(stdout.join("\n")).not.toContain("--url"); + expect(stdout.join("\n")).not.toContain("--password"); + }); + + it("passes Commander-parsed equals, repeated, numeric and negated bootstrap options", async () => { + const runBootstrapFn = vi.fn().mockResolvedValue(bootstrapResult); + expect( + await runCli( + [ + "node", + "kibana-mcp-server", + "bootstrap", + "--url=https://override.example.com", + "--username=first", + "--username=last", + "--profile=prod", + "--password-env=OTHER_SECRET", + "--client=none", + "--package=example@1.0.0", + "--mcp-name=custom", + "--timeout=120000", + "--no-default", + ], + { + env: { ...connectionEnv, OTHER_SECRET: " alternate-secret " }, + stdin: Readable.from(""), + stdout: () => {}, + stderr: () => {}, + }, + { runBootstrapFn }, + ), + ).toBe(0); + expect(runBootstrapFn).toHaveBeenCalledExactlyOnceWith({ + profileName: "prod", + baseUrl: "https://override.example.com", + username: "last", + password: " alternate-secret ", + client: "none", + packageSpecifier: "example@1.0.0", + mcpName: "custom", + timeoutMs: 120000, + makeDefault: false, + replaceExisting: false, + }); + }); + + it.each(["0", "-1", "120001", "1.5", "NaN", "Infinity", "bad", ""])( + "rejects invalid bootstrap timeout %s before running bootstrap", + async (timeout) => { + const stderr: string[] = []; + const runBootstrapFn = vi.fn(); + const exitCode = await runCli( + ["node", "kibana-mcp-server", "bootstrap", `--timeout=${timeout}`], + { + env: connectionEnv, + stdin: Readable.from(""), + stdout: () => {}, + stderr: (text) => stderr.push(text), + }, + { runBootstrapFn }, + ); + expect(exitCode).toBe(1); + expect(runBootstrapFn).not.toHaveBeenCalled(); + expect(stderr.join("\n")).toContain("--timeout"); + }, + ); + + it.each([ + ["--password-stdin", "--password-env", "OTHER_SECRET"], + ["--password-env", "OTHER_SECRET", "--password-stdin"], + ["--index", "logs-*"], + ["--password", "argument-secret"], + ])("rejects conflicting or unsupported bootstrap flags %j", async (...flags) => { + const runBootstrapFn = vi.fn(); + expect( + await runCli( + ["node", "kibana-mcp-server", "bootstrap", ...flags], + { env: connectionEnv, stdin: Readable.from(""), stdout: () => {}, stderr: () => {} }, + { runBootstrapFn }, + ), + ).toBe(1); + expect(runBootstrapFn).not.toHaveBeenCalled(); + }); + + it("does not fall back to KIBANA_PASSWORD when an explicit password environment variable is missing", async () => { + const runBootstrapFn = vi.fn(); + const stderr: string[] = []; + expect( + await runCli( + ["node", "kibana-mcp-server", "bootstrap", "--password-env", "MISSING_SECRET"], + { + env: connectionEnv, + stdin: Readable.from(""), + stdout: () => {}, + stderr: (text) => stderr.push(text), + }, + { runBootstrapFn }, + ), + ).toBe(1); + expect(runBootstrapFn).not.toHaveBeenCalled(); + expect(stderr.join("\n")).toContain("Kibana password is required"); + expect(stderr.join("\n")).not.toContain(connectionEnv.KIBANA_PASSWORD); + }); + it("documents bootstrap as connection-only and does not expose index selection flags", async () => { const stdout: string[] = []; @@ -151,51 +364,20 @@ describe("runCli", () => { expect(stdout.join("\n")).not.toContain("--time-field"); }); - it("accepts deterministic setup flags as a bootstrap-compatible alias", async () => { - let called = false; + it("rejects bootstrap flags on guided setup", async () => { + const runBootstrapFn = vi.fn(); + const runSetupFlowFn = vi.fn(); + const stderr: string[] = []; const exitCode = await runCli( - [ - "node", - "kibana-mcp-server", - "setup", - "--profile", - "default", - "--url", - "https://kibana.example.com", - "--username", - "elastic", - "--password-env", - "KIBANA_PASSWORD", - "--client", - "none", - ], - { - stdout: () => {}, - stderr: () => {}, - env: { - KIBANA_BASE_URL: "https://kibana.example.com", - KIBANA_USERNAME: "elastic", - KIBANA_PASSWORD: "secret", - }, - }, - { - async runBootstrapFn() { - called = true; - return { - profileName: "default", - profileId: "default", - sourceCount: 0, - sourceCatalogPath: "/tmp/consumer.json", - client: "none" as const, - registered: false, - verified: true as const, - }; - }, - }, + ["node", "kibana-mcp-server", "setup", "--profile", "default"], + { env: connectionEnv, stdout: () => {}, stderr: (text) => stderr.push(text) }, + { runBootstrapFn, runSetupFlowFn }, ); - expect(exitCode).toBe(0); - expect(called).toBe(true); + expect(exitCode).toBe(1); + expect(stderr.join("\n")).toContain("unknown option '--profile'"); + expect(runBootstrapFn).not.toHaveBeenCalled(); + expect(runSetupFlowFn).not.toHaveBeenCalled(); }); it("reads a password from stdin without exposing it as a command argument", async () => { @@ -214,8 +396,8 @@ describe("runCli", () => { "none", ], { - stdin: Readable.from("stdin-secret\n"), - env: {}, + stdin: Readable.from(" stdin-secret \r\n"), + env: connectionEnv, stdout: () => {}, stderr: () => {}, }, @@ -236,13 +418,13 @@ describe("runCli", () => { ); expect(exitCode).toBe(0); - expect(password).toBe("stdin-secret"); + expect(password).toBe(" stdin-secret "); }); - it("fails closed when non-interactive setup inputs are missing", async () => { + it("fails closed when non-interactive bootstrap inputs are missing", async () => { const stderr: string[] = []; const exitCode = await runCli( - ["node", "kibana-mcp-server", "setup", "--client", "none"], + ["node", "kibana-mcp-server", "bootstrap", "--client", "none"], { stdin: Readable.from(""), env: {}, @@ -323,14 +505,17 @@ describe("runCli", () => { expect(stderr.join("\n")).toContain("unknown option '--unknown'"); }); - it("uses Commander validation for local setup option constraints", async () => { + it("uses Commander validation for local bootstrap option constraints", async () => { const stderr: string[] = []; - const exitCode = await runCli(["node", "kibana-mcp-server", "setup", "--client", "invalid"], { - stdout: () => {}, - stderr: (text) => stderr.push(text), - env: {}, - }); + const exitCode = await runCli( + ["node", "kibana-mcp-server", "bootstrap", "--client", "invalid"], + { + stdout: () => {}, + stderr: (text) => stderr.push(text), + env: {}, + }, + ); expect(exitCode).toBe(1); expect(stderr.join("\n").toLowerCase()).toContain("allowed choices are codex, none"); diff --git a/test/config.test.ts b/test/config.test.ts index d21a22b..cd47256 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -7,7 +7,6 @@ import { PROFILE_NAME_ENV, loadConfigFromEnvironment, parseAppConfig, - resolveSourceCatalogPath, } from "../src/config/environment.js"; import { resolveProfilePaths } from "../src/profiles/paths.js"; import { ProfileStore } from "../src/profiles/store.js"; @@ -156,15 +155,6 @@ describe("parseAppConfig", () => { ).toThrow(); }); - it("defaults the source catalog path to the runtime file", () => { - expect(resolveSourceCatalogPath({})).toBe("config/sources.runtime.json"); - expect( - resolveSourceCatalogPath({ - KIBANA_SOURCE_CATALOG_PATH: "config/custom.json", - } as NodeJS.ProcessEnv), - ).toBe("config/custom.json"); - }); - it("loads a saved default profile when no bootstrap environment is present", async () => { const root = await mkdtemp(join(tmpdir(), "kibana-config-")); tempDirectories.push(root); @@ -305,6 +295,48 @@ describe("parseAppConfig", () => { }); describe("loadConfigFromEnvironment", () => { + it("requires an explicit catalog path for an environment connection without reading implicit files", async () => { + const reads: unknown[] = []; + await expect( + loadConfigFromEnvironment( + { + KIBANA_BASE_URL: "https://kibana.example.com", + KIBANA_USERNAME: "elastic", + KIBANA_PASSWORD: "secret", + }, + { + readFile: (async (path: unknown) => { + reads.push(path); + return JSON.stringify(createGeneratedCatalog([])); + }) as never, + }, + ), + ).rejects.toThrow("KIBANA_SOURCE_CATALOG_PATH is required"); + expect(reads).toEqual([]); + }); + + it("reads only the named catalog and propagates its filesystem error", async () => { + const error = Object.assign(new Error("catalog access denied"), { code: "EACCES" }); + const reads: unknown[] = []; + await expect( + loadConfigFromEnvironment( + { + KIBANA_BASE_URL: "https://kibana.example.com", + KIBANA_USERNAME: "elastic", + KIBANA_PASSWORD: "secret", + KIBANA_SOURCE_CATALOG_PATH: "config/sources.runtime.json", + }, + { + readFile: (async (path: unknown) => { + reads.push(path); + throw error; + }) as never, + }, + ), + ).rejects.toBe(error); + expect(reads).toEqual(["config/sources.runtime.json"]); + }); + it("loads config when KIBANA_TIMEOUT_MS is present", async () => { const catalogDirectory = await mkdtemp(join(tmpdir(), "kibana-mcp-server-")); tempDirectories.push(catalogDirectory); diff --git a/test/config/startup.test.ts b/test/config/startup.test.ts index ba521f8..1fa6a68 100644 --- a/test/config/startup.test.ts +++ b/test/config/startup.test.ts @@ -140,10 +140,10 @@ describe("resolveStartupConfiguration", () => { await expect(loadProfileConfig?.("STAGING")).rejects.toThrow("was not found"); }); - it("falls back to the legacy unconfigured mode when no profile was ever saved", async () => { - const unconfigured = await resolveStartupConfiguration({}, savedProfiles(null)); - expect(unconfigured.defaultConfig).toBeUndefined(); - expect(unconfigured.loadProfileConfig).toBeUndefined(); + it("refuses to start without a saved connection or with an unknown profile", async () => { + await expect(resolveStartupConfiguration({}, savedProfiles(null))).rejects.toThrow( + "No saved Kibana connection is available. Run connection bootstrap first.", + ); await expect( resolveStartupConfiguration({ KIBANA_PROFILE: "unknown" }, savedProfiles()), ).rejects.toThrow("Saved Kibana profile 'unknown' was not found"); diff --git a/test/configure_index.test.ts b/test/configure_index.test.ts index 88a9c63..96b00d2 100644 --- a/test/configure_index.test.ts +++ b/test/configure_index.test.ts @@ -50,7 +50,7 @@ function config(sourceCatalogPath: string): ResolvedAppConfig { }, sources: [], sourceCatalogPath, - sourceCatalogOrigin: "profile", + profileName: "default", }; } @@ -305,11 +305,10 @@ describe("configure_index", () => { ).rejects.toThrow("changed concurrently"); }); - it("refuses to start a new catalog when the configured sources were loaded from elsewhere", async () => { + it("refuses to recreate a disappeared catalog while sources are still configured", async () => { const { root } = await createEmptyCatalog(); - // Environment bootstrap can load the static fallback catalog while persisting to a runtime - // path that does not exist yet. - const runtimePath = join(root, "sources.runtime.json"); + const catalogPath = join(root, "sources.json"); + await rm(catalogPath); let kibanaCalls = 0; await expect( @@ -317,7 +316,7 @@ describe("configure_index", () => { { index: "consumer-*" }, { config: { - ...config(runtimePath), + ...config(catalogPath), sources: [ { id: "hand-authored", @@ -344,7 +343,7 @@ describe("configure_index", () => { ).rejects.toThrow("does not exist although sources are configured"); expect(kibanaCalls).toBe(0); - await expect(readFile(runtimePath, "utf8")).rejects.toThrow("ENOENT"); + await expect(readFile(catalogPath, "utf8")).rejects.toThrow("ENOENT"); }); it("requires a catalog to persist into", async () => { diff --git a/test/profiles/runtime.test.ts b/test/profiles/runtime.test.ts index 8e6c2f7..c531451 100644 --- a/test/profiles/runtime.test.ts +++ b/test/profiles/runtime.test.ts @@ -45,7 +45,7 @@ describe("profile names", () => { }); describe("ProfileRuntimeRegistry", () => { - it("serves the default profile, also under its own name, and replaces it on reconfiguration", async () => { + it("serves the default profile, also under its own name", async () => { const registry = new ProfileRuntimeRegistry({ defaultConfig: config({ profileName: "Production" }), kibanaClientFactory, @@ -58,18 +58,12 @@ describe("ProfileRuntimeRegistry", () => { await expect(registry.resolve("staging")).rejects.toThrow( "Profile selection is not available on this server", ); - - const reconfigured = registry.setDefault(config({ sources: [source("orders")] })); - expect(await registry.resolve()).toBe(reconfigured); - expect(registry.getDefault()?.sourceCatalog.count()).toBe(1); }); - it("asks for configuration when it has neither a default nor named profiles", async () => { - const registry = new ProfileRuntimeRegistry({ kibanaClientFactory }); - - expect(registry.hasDefault).toBe(false); - await expect(registry.resolve()).rejects.toThrow("Call the 'configure' tool first"); - await expect(registry.resolve(" ")).rejects.toThrow("must not be empty"); + it("requires a default configuration or a named profile loader", () => { + expect(() => new ProfileRuntimeRegistry({ kibanaClientFactory })).toThrow( + "No saved Kibana connection is available. Run connection bootstrap first.", + ); }); it("shares one Kibana client between profiles of the same connection only", async () => { @@ -258,22 +252,25 @@ describe("ProfileRuntimeRegistry", () => { it("does not let an in-flight refresh undo configure_index replacement", async () => { let finishRead: ((content: string) => void) | undefined; + let deferRead = false; const registry = new ProfileRuntimeRegistry({ kibanaClientFactory, defaultConfig: config({ catalog: { location: "memory://alice", read: () => - new Promise((resolve) => { - finishRead = resolve; - }), + deferRead + ? new Promise((resolve) => { + finishRead = resolve; + }) + : Promise.resolve(undefined), replace: async () => {}, }, }), }); + const runtime = await registry.resolve(); + deferRead = true; const pending = registry.resolve(); - const runtime = registry.getDefault(); - if (!runtime) throw new Error("Default runtime was not created"); const content = JSON.stringify(createGeneratedCatalog([source("configured")])); registry.replaceSources(runtime, [source("configured")], content); const schemaCatalog = runtime.schemaCatalog; @@ -317,7 +314,7 @@ describe("ProfileRuntimeRegistry", () => { const lower = await registry.resolve("alice"); const upper = await registry.resolve("Alice"); - expect(lower).toBe(registry.getDefault()); + expect(lower).toBe(await registry.resolve()); expect(upper).not.toBe(lower); expect(upper.config.profileName).toBe("Alice"); expect(await registry.resolve("Alice")).toBe(upper); diff --git a/test/release_contract.test.ts b/test/release_contract.test.ts index f0432b0..76b0028 100644 --- a/test/release_contract.test.ts +++ b/test/release_contract.test.ts @@ -8,7 +8,6 @@ const repoRoot = process.cwd(); describe("release contract", () => { it("keeps package release scripts and published files aligned", () => { const pkg = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")) as { - devDependencies?: Record; files?: string[]; name?: string; scripts?: Record; @@ -20,10 +19,7 @@ describe("release contract", () => { "node scripts/verify-release-contract.mjs", ); expect(pkg.scripts?.verify).toContain("npm run verify:release-contract"); - expect(pkg.scripts).not.toHaveProperty("changeset"); - expect(pkg.scripts).not.toHaveProperty("changeset:version"); - expect(pkg.scripts).not.toHaveProperty("changeset:release"); - expect(pkg.devDependencies).not.toHaveProperty("@changesets/cli"); + expect(pkg.files).not.toContain("CHANGELOG.md"); }); @@ -65,7 +61,6 @@ describe("release contract", () => { expect(releaseWorkflow).toContain("contents: write"); expect(releaseWorkflow).toContain("npm run verify"); expect(releaseWorkflow).toContain("npm run release"); - expect(releaseWorkflow).not.toContain("changesets/action"); expect(semanticWorkflow).toContain("pull_request:"); expect(semanticWorkflow).toContain("commitlint"); diff --git a/test/server.test.ts b/test/server.test.ts index 1605848..2ae4a53 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -38,7 +38,6 @@ describe("createApplication", () => { }); expect(Object.keys(application.handlers).sort()).toEqual([ - "configure", "configure_index", "describe_fields", "discover", @@ -54,15 +53,10 @@ describe("createApplication", () => { expect(registeredTools).not.toContain("configure"); }); - it("advertises legacy full configuration only when no saved connection exists", () => { - const application = createApplication(); - const registeredTools = Object.keys( - (application.server as unknown as { _registeredTools: Record }) - ._registeredTools, + it("refuses to create an unconfigured application", () => { + expect(() => createApplication()).toThrow( + "No saved Kibana connection is available. Run connection bootstrap first.", ); - - expect(registeredTools).toContain("configure"); - expect(registeredTools).not.toContain("configure_index"); }); it("invalidates cached schema fields after configure_index replaces a source", async () => { @@ -84,7 +78,6 @@ describe("createApplication", () => { ...config, profileName: "default", sourceCatalogPath: "/tmp/default.json", - sourceCatalogOrigin: "profile", }, { kibanaClient: { @@ -218,97 +211,6 @@ describe("createApplication", () => { expect(schemaAttempts).toBe(1); }); - it("supports runtime configuration from the client", async () => { - const executeMany = async () => [ - { - source: { - id: "consumer", - name: "Consumer", - tags: ["consumer"], - timeField: "@timestamp", - backend: { - kind: "elasticsearch_search" as const, - path: "/consumer/_search", - }, - fieldHints: [], - defaultTextFields: ["message"], - evidenceFields: [], - }, - rawResponse: { - hits: { - total: { value: 0 }, - hits: [], - }, - }, - }, - ]; - const describeFields = async () => []; - - const application = createApplication(undefined, { - kibanaClientFactory: () => - ({ - executeMany, - describeFields, - }) as never, - }); - - await expect( - application.handlers.discover({ - limit: 10, - }), - ).rejects.toThrow("Server is not configured"); - - const configureResult = await application.handlers.configure({ - kibana: { - baseUrl: "https://kibana.example.com", - username: "elastic", - password: "secret", - timeoutMs: 1000, - }, - sources: config.sources, - }); - - expect(configureResult.source_count).toBe(1); - expect(configureResult.persisted).toBe(true); - const persistedCatalogPath = configureResult.source_catalog_path; - expect(persistedCatalogPath).toContain("config/sources.runtime.json"); - - const discoverResult = await application.handlers.discover({ - limit: 10, - }); - expect(discoverResult.total).toBe(1); - - const queryResult = await application.handlers.query({ - source_ids: ["consumer"], - start_time: "2026-04-02T12:00:00Z", - end_time: "2026-04-02T12:05:00Z", - mode: "hits", - limit: 10, - }); - - expect(queryResult.total).toBe(0); - - const filterResult = await application.handlers.filter({ - source_ids: ["consumer"], - start_time: "2026-04-02T12:00:00Z", - end_time: "2026-04-02T12:05:00Z", - field: "productId", - value: "123", - mode: "hits", - sort_by: "total_duration_ms", - }); - - expect(filterResult.total).toBe(0); - expect(filterResult.query_echo.sort_by).toBe("total_duration_ms"); - - const describeFieldsResult = await application.handlers.describe_fields({ - source_id: "consumer", - limit: 20, - }); - - expect(describeFieldsResult.source_id).toBe("consumer"); - }); - it("propagates handler cancellation signals to backend work", async () => { const controller = new AbortController(); let querySignal: AbortSignal | undefined; diff --git a/test/transports/helpers.ts b/test/transports/helpers.ts index 41b5f2a..40f296b 100644 --- a/test/transports/helpers.ts +++ b/test/transports/helpers.ts @@ -1,3 +1,8 @@ +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, +} from "@modelcontextprotocol/server"; import type { SourceDefinition } from "../../src/types.js"; export function source(id: string): SourceDefinition { @@ -13,40 +18,55 @@ export function source(id: string): SourceDefinition { }; } -export async function callTool( +export const PROTOCOL_VERSION = "2026-07-28"; +export const requestMeta = { + [PROTOCOL_VERSION_META_KEY]: PROTOCOL_VERSION, + [CLIENT_INFO_META_KEY]: { name: "http-test", version: "1.0.0" }, + [CLIENT_CAPABILITIES_META_KEY]: {}, +}; + +export function sendRpc( url: string, - name: string, + message: Record, headers: Record = {}, - toolArguments: Record = {}, -): Promise<{ status: number; result?: Record }> { - const response = await fetch(url, { + signal?: AbortSignal, +) { + const params = (message.params ?? {}) as Record; + return fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": PROTOCOL_VERSION, + "Mcp-Method": String(message.method), + ...(typeof params.name === "string" ? { "Mcp-Name": params.name } : {}), ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name, arguments: toolArguments }, + ...message, + params: { + ...params, + _meta: requestMeta, + }, }), + signal, }); - const text = await response.text(); - if (response.status !== 200) { - return { status: response.status }; - } - const payload = text.startsWith("{") - ? text - : (text - .split("\n") - .find((line) => line.startsWith("data:")) - ?.slice("data:".length) ?? "{}"); - return { - status: response.status, - result: (JSON.parse(payload) as { result?: Record }).result, - }; +} + +export async function callTool( + url: string, + name: string, + headers: Record = {}, + toolArguments: Record = {}, +): Promise<{ status: number; result?: Record }> { + const response = await sendRpc( + url, + { id: 1, method: "tools/call", params: { name, arguments: toolArguments } }, + headers, + ); + const payload = await response.json(); + return { status: response.status, result: payload.result }; } export function sourceIds(result: Record | undefined): string[] { diff --git a/test/transports/http.test.ts b/test/transports/http.test.ts index 09b9d94..f333265 100644 --- a/test/transports/http.test.ts +++ b/test/transports/http.test.ts @@ -3,13 +3,27 @@ import { type ServerResponse, createServer, request as httpRequest } from "node: import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"; +import { PerRequestHTTPServerTransport } from "@modelcontextprotocol/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { toProfileKey } from "../../src/profiles/names.js"; import { createGeneratedCatalog } from "../../src/setup/bootstrap.js"; import { type HttpServerHandle, startHttpMcpServer } from "../../src/transports/http.js"; -import { callTool, source, sourceIds } from "./helpers.js"; +import { PROTOCOL_VERSION, callTool, requestMeta, sendRpc, source, sourceIds } from "./helpers.js"; + +const queryMessage = { + id: 1, + method: "tools/call", + params: { + name: "query", + arguments: { + source_ids: ["orders"], + start_time: "2026-04-02T12:00:00Z", + end_time: "2026-04-02T12:05:00Z", + mode: "count", + }, + }, +}; // Answers the two Kibana calls configure_index makes: field discovery, then a probe search. async function startFakeKibana( @@ -83,36 +97,12 @@ describe("startHttpMcpServer", () => { headers: Record = {}, signal?: AbortSignal, ) { - return fetch(handle?.url ?? "", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", - Authorization: "Bearer gateway-secret", - "X-Profile": "alice", - ...headers, - }, - body: JSON.stringify({ jsonrpc: "2.0", ...message }), + return sendRpc( + handle?.url ?? "", + message, + { Authorization: "Bearer gateway-secret", "X-Profile": "alice", ...headers }, signal, - }); - } - - async function initialize(headers: Record = {}): Promise { - const response = await postRpc( - { - id: 0, - method: "initialize", - params: { - protocolVersion: "2025-06-18", - capabilities: {}, - clientInfo: { name: "http-test", version: "1.0.0" }, - }, - }, - headers, ); - expect(response.status).toBe(200); - await response.text(); - return response.headers.get("mcp-session-id"); } it("serves each caller the catalog of the profile carried by the trusted header", async () => { @@ -195,6 +185,9 @@ describe("startHttpMcpServer", () => { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "X-Profile": "alice", + "MCP-Protocol-Version": PROTOCOL_VERSION, + "Mcp-Method": "tools/call", + "Mcp-Name": "discover", ...headers, }, }, @@ -209,7 +202,7 @@ describe("startHttpMcpServer", () => { jsonrpc: "2.0", id: 1, method: "tools/call", - params: { name: "discover", arguments: {} }, + params: { name: "discover", arguments: {}, _meta: requestMeta }, }), ); }); @@ -222,7 +215,7 @@ describe("startHttpMcpServer", () => { ).rejects.toThrow("Refusing to listen on non-loopback host"); await expect( startHttpMcpServer({}, { port: 0 }, { startupConfiguration: { close: async () => {} } }), - ).rejects.toThrow("needs a profile header, or a default profile"); + ).rejects.toThrow("No saved Kibana connection is available"); // Per-profile catalogs without a header would hand every caller the same catalog. await expect(startHttpMcpServer(env, { port: 0 })).rejects.toThrow( "needs a profile header, or a default profile", @@ -353,51 +346,84 @@ describe("startHttpMcpServer", () => { } }); - it("reconnects a legacy GET stream after disconnect without closing its session", async () => { - const requests = vi.spyOn(WebStandardStreamableHTTPServerTransport.prototype, "handleRequest"); - const interval = vi.spyOn(globalThis, "setInterval"); - const clear = vi.spyOn(globalThis, "clearInterval"); - const controllers = [new AbortController(), new AbortController()]; - const timers: Array> = []; + it("rejects the old session flow without allocating sessions and serves modern requests", async () => { + await writeCatalog("alice", ["orders"]); + handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); + const headers = { + Authorization: "Bearer gateway-secret", + "X-Profile": "alice", + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + for (const message of [ + { + id: 0, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "http-test", version: "1.0.0" }, + }, + }, + { id: 1, method: "tools/call", params: { name: "discover", arguments: {} } }, + ]) { + const response = await fetch(handle.url, { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", ...message }), + }); + expect(response.status).toBe(400); + expect(response.headers.get("mcp-session-id")).toBeNull(); + expect((await response.json()).error.code).toBe(-32022); + } + for (const method of ["GET", "DELETE"]) { + const response = await fetch(handle.url, { + method, + headers: { ...headers, "Mcp-Session-Id": "unknown" }, + }); + expect(response.status).toBe(405); + expect(response.headers.get("mcp-session-id")).toBeNull(); + await response.text(); + } + const listed = await postRpc({ id: 2, method: "tools/list" }); + expect(listed.status).toBe(200); + expect(listed.headers.get("mcp-session-id")).toBeNull(); + expect((await listed.json()).result.tools.map((tool: { name: string }) => tool.name)).toContain( + "discover", + ); + expect(sourceIds((await callTool(handle.url, "discover", headers)).result)).toEqual(["orders"]); + }); + + it("cancels backend work when a modern request disconnects before a response", async () => { + let backend: ServerResponse | undefined; + const kibana = await startFakeKibana((response) => { + backend = response; + }); + const controller = new AbortController(); + let pending: Promise | undefined; try { await writeCatalog("alice", ["orders"]); - handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); - const sessionId = await initialize(); - expect(sessionId).toBeTruthy(); - const transport = requests.mock.contexts[0] as WebStandardStreamableHTTPServerTransport; - const headers = { - Authorization: "Bearer gateway-secret", - "X-Profile": "alice", - "Mcp-Session-Id": sessionId ?? "", - Accept: "application/json, text/event-stream", - }; - for (const controller of controllers) { - const pending = fetch(handle.url, { headers, signal: controller.signal }); - await vi.waitFor(() => expect(requests.mock.lastCall?.[0].method).toBe("GET")); - // GET has no initial frame: use an actual SDK notification to flush the HTTP headers. - await transport.send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }); - const response = await pending; - expect(response.status).toBe(200); - const request = requests.mock.lastCall?.[0]; - timers.push(interval.mock.results.at(-1)?.value); - controller.abort(); - await vi.waitFor(() => expect(request?.signal.aborted).toBe(true)); - expect(sourceIds((await callTool(handle.url, "discover", headers)).result)).toEqual([ - "orders", - ]); - } - for (const timer of timers) expect(clear).toHaveBeenCalledWith(timer); + handle = await startHttpMcpServer( + { ...env, KIBANA_BASE_URL: kibana.url }, + { port: 0, profileHeader: "x-profile" }, + ); + pending = postRpc(queryMessage, {}, controller.signal).catch(() => undefined); + await vi.waitFor(() => expect(backend).toBeDefined()); + controller.abort(); + await pending; + await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); + expect((await postRpc({ id: 2, method: "tools/list" })).status).toBe(200); } finally { - for (const controller of controllers) controller.abort(); - requests.mockRestore(); - interval.mockRestore(); - clear.mockRestore(); + controller.abort(); + await pending; + backend?.destroy(); + await kibana.close(); } }); - it("cancels a legacy body when its socket closes before stream attachment", async () => { - const original = WebStandardStreamableHTTPServerTransport.prototype.handleRequest; - const requests = vi.spyOn(WebStandardStreamableHTTPServerTransport.prototype, "handleRequest"); + it("cleans up a modern SSE response disconnected before stream attachment", async () => { + const original = PerRequestHTTPServerTransport.prototype.handleMessage; + const requests = vi.spyOn(PerRequestHTTPServerTransport.prototype, "handleMessage"); const interval = vi.spyOn(globalThis, "setInterval"); const clear = vi.spyOn(globalThis, "clearInterval"); const controller = new AbortController(); @@ -406,49 +432,54 @@ describe("startHttpMcpServer", () => { resume = resolve; }); let arrived = false; + let backend: ServerResponse | undefined; + const kibana = await startFakeKibana((response) => { + backend = response; + }); + let pending: Promise | undefined; try { - handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); - const sessionId = await initialize(); - const transport = requests.mock.contexts[0]; - const state = transport as unknown as { _streamMapping: Map }; + await writeCatalog("alice", ["orders"]); + handle = await startHttpMcpServer( + { ...env, KIBANA_BASE_URL: kibana.url }, + { port: 0, profileHeader: "x-profile" }, + ); requests.mockImplementationOnce(async function ( - this: WebStandardStreamableHTTPServerTransport, - request, - options, + this: PerRequestHTTPServerTransport, + message, + extra, ) { - const response = await original.call(this, request, options); + const response = await original.call(this, message, extra); arrived = true; await paused; return response; }); - const pending = fetch(handle.url, { - headers: { - Authorization: "Bearer gateway-secret", - "X-Profile": "alice", - "Mcp-Session-Id": sessionId ?? "", - Accept: "text/event-stream", + pending = postRpc(queryMessage, {}, controller.signal).catch(() => undefined); + await vi.waitFor(() => expect(backend).toBeDefined()); + const transport = requests.mock.contexts[0] as PerRequestHTTPServerTransport; + await transport.send( + { + jsonrpc: "2.0", + method: "notifications/progress", + params: { progressToken: 1, progress: 0 }, }, - signal: controller.signal, - }).catch(() => undefined); + { relatedRequestId: 1 }, + ); await vi.waitFor(() => expect(arrived).toBe(true)); - expect(state._streamMapping.has("_GET_stream")).toBe(true); - const request = requests.mock.lastCall?.[0]; const timer = interval.mock.results.at(-1)?.value; + expect(timer).toBeDefined(); + const request = requests.mock.lastCall?.[1]?.request; controller.abort(); await pending; await vi.waitFor(() => expect(request?.signal.aborted).toBe(true)); + await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); resume(); - await vi.waitFor(() => expect(state._streamMapping.size).toBe(0)); - expect(clear).toHaveBeenCalledWith(timer); - const healthy = await postRpc( - { id: 1, method: "tools/list" }, - { "Mcp-Session-Id": sessionId ?? "" }, - ); - expect(healthy.status).toBe(200); - await healthy.text(); + await vi.waitFor(() => expect(clear).toHaveBeenCalledWith(timer)); } finally { controller.abort(); resume(); + await pending; + backend?.destroy(); + await kibana.close(); requests.mockRestore(); interval.mockRestore(); clear.mockRestore(); @@ -456,12 +487,9 @@ describe("startHttpMcpServer", () => { }); it.each(["cancelled", "completed"])( - "releases disconnected %s tool streams without disturbing another exchange", + "releases %s modern streams without disturbing identical request IDs in another profile", async (outcome) => { - const requests = vi.spyOn( - WebStandardStreamableHTTPServerTransport.prototype, - "handleRequest", - ); + const requests = vi.spyOn(PerRequestHTTPServerTransport.prototype, "handleMessage"); const interval = vi.spyOn(globalThis, "setInterval"); const clear = vi.spyOn(globalThis, "clearInterval"); const backends: ServerResponse[] = []; @@ -469,89 +497,82 @@ describe("startHttpMcpServer", () => { const kibana = await startFakeKibana((response) => backends.push(response)); try { await writeCatalog("alice", ["orders"]); + await writeCatalog("bob", ["payments"]); handle = await startHttpMcpServer( { ...env, KIBANA_BASE_URL: kibana.url }, { port: 0, profileHeader: "x-profile" }, ); - const sessionId = await initialize(); - const headers = { "Mcp-Session-Id": sessionId ?? "" }; - const transport = requests.mock.contexts[0] as WebStandardStreamableHTTPServerTransport; - // Read the installed SDK's actual retained response streams, not a mocked transport. - const state = transport as unknown as { - _streamMapping: Map; - _requestToStreamMapping: Map; - _requestResponseMap: Map; - }; - const query = async (id: number) => { + const query = async (profile: string, sourceId: string) => { const controller = new AbortController(); controllers.push(controller); const pending = postRpc( { - id, - method: "tools/call", + ...queryMessage, params: { - name: "query", - arguments: { - source_ids: ["orders"], - start_time: "2026-04-02T12:00:00Z", - end_time: "2026-04-02T12:05:00Z", - mode: "count", - }, + ...queryMessage.params, + arguments: { ...queryMessage.params.arguments, source_ids: [sourceId] }, }, }, - headers, + { "X-Profile": profile }, controller.signal, ); await vi.waitFor(() => expect(backends).toHaveLength(controllers.length)); - const request = requests.mock.lastCall?.[0]; - const timer = interval.mock.results.at(-1)?.value; + const transport = requests.mock.contexts.at(-1) as PerRequestHTTPServerTransport; + const request = requests.mock.lastCall?.[1]?.request; await transport.send( { jsonrpc: "2.0", method: "notifications/progress", - params: { progressToken: id, progress: 0 }, + params: { progressToken: 1, progress: 0 }, }, - { relatedRequestId: id }, + { relatedRequestId: 1 }, ); + const timer = interval.mock.results.at(-1)?.value; + expect(timer).toBeDefined(); const response = await pending; expect(response.status).toBe(200); return { controller, request, timer, response }; }; - const sibling = await query(100); + const sibling = await query("bob", "payments"); const siblingBody = sibling.response.text().catch(() => ""); - for (const id of [1, 2, 3]) { - const current = await query(id); - expect(state._streamMapping.size).toBe(2); - if (outcome === "cancelled") { - expect( - ( - await postRpc( - { method: "notifications/cancelled", params: { requestId: id } }, - headers, - ) - ).status, - ).toBe(202); - await vi.waitFor(() => expect(backends[id].destroyed).toBe(true)); + for (let count = 0; count < 3; count += 1) { + const current = await query("alice", "orders"); + const body = current.response.text().catch(() => ""); + if (outcome === "completed") { + backends + .at(-1) + ?.end(JSON.stringify({ rawResponse: { hits: { total: { value: 0 } } } })); + expect(await body).toContain('"id":1'); + } else { + // A separate notification cannot cancel another stateless exchange. + const cancellation = await postRpc({ + method: "notifications/cancelled", + params: { requestId: 1 }, + }); + await cancellation.text(); + expect(backends.at(-1)?.destroyed).toBe(false); + current.controller.abort(); + await body; + await vi.waitFor(() => expect(backends.at(-1)?.destroyed).toBe(true)); } - current.controller.abort(); await vi.waitFor(() => expect(current.request?.signal.aborted).toBe(true)); - await vi.waitFor(() => expect(state._streamMapping.size).toBe(1)); expect(clear).toHaveBeenCalledWith(current.timer); expect(clear).not.toHaveBeenCalledWith(sibling.timer); expect(backends[0].destroyed).toBe(false); - if (outcome === "completed") { - backends[id].end(JSON.stringify({ rawResponse: { hits: { total: { value: 0 } } } })); - await vi.waitFor(() => expect(state._requestToStreamMapping.has(id)).toBe(false)); - } } backends[0].end(JSON.stringify({ rawResponse: { hits: { total: { value: 0 } } } })); - expect(await siblingBody).toContain('"id":100'); - await vi.waitFor(() => expect(state._streamMapping.size).toBe(0)); - expect(clear).toHaveBeenCalledWith(sibling.timer); - // The SDK suppresses final responses for cancelled handlers, retaining request-ID - // metadata; this adapter only owns response streams and their keepalives. - if (outcome === "completed") expect(state._requestToStreamMapping.size).toBe(0); - expect(state._requestResponseMap.size).toBe(0); + expect(await siblingBody).toContain('"id":1'); + await vi.waitFor(() => expect(clear).toHaveBeenCalledWith(sibling.timer)); + expect( + sourceIds( + ( + await callTool(handle.url, "discover", { + Authorization: "Bearer gateway-secret", + "X-Profile": "alice", + }) + ).result, + ), + ).toEqual(["orders"]); } finally { for (const controller of controllers) controller.abort(); for (const backend of backends) backend.destroy(); @@ -563,245 +584,56 @@ describe("startHttpMcpServer", () => { }, ); - it("routes separate legacy cancellation POSTs to the initialized client's backend work", async () => { + it("keeps backend deadlines on modern tool calls", async () => { let backend: ServerResponse | undefined; - let cancelled = false; const kibana = await startFakeKibana((response) => { backend = response; - response.on("close", () => { - cancelled = true; - }); }); - const controller = new AbortController(); - let query: Promise | undefined; try { await writeCatalog("alice", ["orders"]); handle = await startHttpMcpServer( - { ...env, KIBANA_BASE_URL: kibana.url }, + { ...env, KIBANA_BASE_URL: kibana.url, KIBANA_TIMEOUT_MS: "100" }, { port: 0, profileHeader: "x-profile" }, ); - const sessionId = await initialize(); - const headers: Record = sessionId ? { "Mcp-Session-Id": sessionId } : {}; - query = postRpc( - { - id: 1, - method: "tools/call", - params: { - name: "query", - arguments: { - source_ids: ["orders"], - start_time: "2026-04-02T12:00:00Z", - end_time: "2026-04-02T12:05:00Z", - mode: "count", - }, - }, - }, - headers, - controller.signal, - ); - await vi.waitFor(() => expect(backend).toBeDefined()); - expect( - ( - await postRpc( - { - method: "notifications/cancelled", - params: { requestId: 1 }, - }, - headers, - ) - ).status, - ).toBe(202); - await vi.waitFor(() => expect(cancelled).toBe(true)); - expect(sessionId).toBeTruthy(); + const response = await postRpc(queryMessage); + expect(response.status).toBe(200); + expect((await response.json()).result).toMatchObject({ + isError: true, + _meta: { kibana_request_error: { code: "KIBANA_TIMEOUT", timeout_ms: 100 } }, + }); + await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); } finally { - controller.abort(); - await query?.catch(() => undefined); backend?.destroy(); await kibana.close(); } }); - it("isolates identical request IDs between legacy sessions and rejects profile changes", async () => { - const backends: ServerResponse[] = []; + it("cancels active modern backend work on server close", async () => { + let backend: ServerResponse | undefined; const kibana = await startFakeKibana((response) => { - backends.push(response); + backend = response; }); const controller = new AbortController(); - const queries: Array> = []; + let pending: Promise | undefined; try { await writeCatalog("alice", ["orders"]); handle = await startHttpMcpServer( { ...env, KIBANA_BASE_URL: kibana.url }, { port: 0, profileHeader: "x-profile" }, ); - const first = await initialize(); - const second = await initialize(); - expect(first).toBeTruthy(); - expect(second).toBeTruthy(); - expect(first).not.toBe(second); - for (const sessionId of [first, second]) { - queries.push( - postRpc( - { - id: 1, - method: "tools/call", - params: { - name: "query", - arguments: { - source_ids: ["orders"], - start_time: "2026-04-02T12:00:00Z", - end_time: "2026-04-02T12:05:00Z", - mode: "count", - }, - }, - }, - { "Mcp-Session-Id": sessionId ?? "" }, - controller.signal, - ), - ); - await vi.waitFor(() => expect(backends).toHaveLength(queries.length)); - } - const cancellation = { method: "notifications/cancelled", params: { requestId: 1 } }; - expect((await postRpc(cancellation, { "Mcp-Session-Id": "unknown" })).status).toBe(404); - expect( - ( - await postRpc(cancellation, { - "Mcp-Session-Id": first ?? "", - "X-Profile": "bob", - }) - ).status, - ).toBe(404); - expect( - ( - await postRpc(cancellation, { - "Mcp-Session-Id": first ?? "", - Authorization: "Bearer wrong", - }) - ).status, - ).toBe(401); - // No session cannot claim an initialized client's request, even under the same profile. - expect((await postRpc(cancellation)).status).toBe(202); - expect(backends.map((response) => response.destroyed)).toEqual([false, false]); - expect((await postRpc(cancellation, { "Mcp-Session-Id": second ?? "" })).status).toBe(202); - await vi.waitFor(() => expect(backends[1].destroyed).toBe(true)); - expect(backends[0].destroyed).toBe(false); - expect((await postRpc(cancellation, { "Mcp-Session-Id": first ?? "" })).status).toBe(202); - await vi.waitFor(() => expect(backends[0].destroyed).toBe(true)); + pending = postRpc(queryMessage, {}, controller.signal).catch(() => undefined); + await vi.waitFor(() => expect(backend).toBeDefined()); + await handle.close(); + handle = undefined; + await pending; + await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); } finally { controller.abort(); - await Promise.all(queries.map((query) => query.catch(() => undefined))); - for (const response of backends) response.destroy(); + await pending; + backend?.destroy(); await kibana.close(); } }); - - it("deletes legacy sessions and keeps authentication required on each session request", async () => { - await writeCatalog("alice", ["orders"]); - handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); - const sessionId = await initialize(); - expect(sessionId).toBeTruthy(); - const headers = { - Authorization: "Bearer gateway-secret", - "X-Profile": "alice", - "Mcp-Session-Id": sessionId ?? "", - }; - expect(sourceIds((await callTool(handle.url, "discover", headers)).result)).toEqual(["orders"]); - expect( - ( - await fetch(handle.url, { - method: "DELETE", - headers: { - ...headers, - Authorization: "Bearer wrong", - }, - }) - ).status, - ).toBe(401); - expect((await fetch(handle.url, { method: "DELETE", headers })).status).toBe(200); - expect((await callTool(handle.url, "discover", headers)).status).toBe(404); - expect( - sourceIds( - ( - await callTool(handle.url, "discover", { - Authorization: "Bearer gateway-secret", - "X-Profile": "alice", - }) - ).result, - ), - ).toEqual(["orders"]); - }); - - it("expires idle legacy sessions without expiring recently used sessions", async () => { - handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); - const expired = await initialize(); - const current = await initialize(); - const headers = (id: string | null) => ({ - Authorization: "Bearer gateway-secret", - "X-Profile": "alice", - "Mcp-Session-Id": id ?? "", - }); - const now = Date.now(); - const clock = vi.spyOn(Date, "now"); - try { - clock.mockReturnValue(now + 20 * 60 * 1000); - expect((await callTool(handle.url, "discover", headers(current))).status).toBe(200); - clock.mockReturnValue(now + 31 * 60 * 1000); - expect((await callTool(handle.url, "discover", headers(expired))).status).toBe(404); - expect((await callTool(handle.url, "discover", headers(current))).status).toBe(200); - expect(await initialize()).toBeTruthy(); - } finally { - clock.mockRestore(); - } - }); - - it("bounds concurrent legacy sessions and releases capacity on DELETE", async () => { - handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); - const responses = await Promise.all( - Array.from({ length: 129 }, () => - postRpc({ - id: 0, - method: "initialize", - params: { - protocolVersion: "2025-06-18", - capabilities: {}, - clientInfo: { name: "capacity-test", version: "1.0.0" }, - }, - }), - ), - ); - await Promise.all(responses.map((response) => response.text())); - expect(responses.filter((response) => response.status === 200)).toHaveLength(128); - expect(responses.filter((response) => response.status === 503)).toHaveLength(1); - const sessionId = responses - .find((response) => response.status === 200) - ?.headers.get("mcp-session-id"); - expect( - ( - await fetch(handle.url, { - method: "DELETE", - headers: { - Authorization: "Bearer gateway-secret", - "X-Profile": "alice", - "Mcp-Session-Id": sessionId ?? "", - }, - }) - ).status, - ).toBe(200); - expect(await initialize()).toBeTruthy(); - }); - - it("retires long-lived sessions to bound SDK cancellation metadata", async () => { - handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); - const sessionId = await initialize(); - const headers = { "Mcp-Session-Id": sessionId ?? "" }; - for (let count = 0; count < 1024; count += 1) { - const response = await postRpc({ method: "notifications/initialized" }, headers); - expect(response.status).toBe(202); - await response.text(); - } - expect((await postRpc({ id: 1, method: "tools/list" }, headers)).status).toBe(404); - expect(await initialize()).toBeTruthy(); - }, 20_000); }); describe("configuration file", () => { diff --git a/test/transports/stdio.test.ts b/test/transports/stdio.test.ts index f98a8bd..7e8a3e5 100644 --- a/test/transports/stdio.test.ts +++ b/test/transports/stdio.test.ts @@ -99,6 +99,13 @@ describe("startMcpServer", () => { await rm(directory, { recursive: true, force: true }); }); + it("fails before opening a transport when no connection is configured", async () => { + await expect(startMcpServer({ KIBANA_STATE_DIR: directory })).rejects.toThrow( + "No saved Kibana connection is available. Run connection bootstrap first.", + ); + expect(transports.instances).toEqual([]); + }); + it("closes its store once when connecting the transport fails", async () => { const configPath = join(directory, "failed.mjs"); const closedPath = join(directory, "closed.txt"); diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 16d88be..38b54e3 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -145,7 +145,6 @@ export default defineConfig({ { text: "describe_fields", link: "/reference/tools/describe-fields" }, { text: "query", link: "/reference/tools/query" }, { text: "filter", link: "/reference/tools/filter" }, - { text: "configure (legacy)", link: "/reference/tools/configure" }, ], }, { text: "Errors", link: "/reference/errors" }, diff --git a/website/ai/agent-plugins.md b/website/ai/agent-plugins.md index cb7211e..25f03f3 100644 --- a/website/ai/agent-plugins.md +++ b/website/ai/agent-plugins.md @@ -153,6 +153,8 @@ export KIBANA_PASSWORD='use-a-secret-provider' npx -y @havesomecode/kibana-mcp-server bootstrap --client none ``` -Bootstrap verifies the connection, stores the credentials in the secret store of the operating system and creates an empty source catalog. Reconnect the MCP server or start a new session afterwards: the agent then asks which exact index or index pattern to use, and calls `configure_index` with your answer. +Bootstrap verifies the connection, stores the credentials in the secret store of the operating system and creates an empty source catalog. Start the MCP server without direct connection variables so it uses the saved profile, then reconnect the client or start a new session: the agent asks which exact index or index pattern to use, and calls `configure_index` with your answer. + +Without a saved connection the server fails at startup with `No saved Kibana connection is available. Run connection bootstrap first.` Direct environment-based startup requires an explicit `KIBANA_SOURCE_CATALOG_PATH` or configuration-file store, never an implicit checkout catalog. You can also let the agent do it. The skill knows which mode fits the agent it runs in: `--client codex` in Codex, where bootstrap registers the server itself, and `--client none` in Claude Code and elsewhere, where a plugin or your own configuration does. diff --git a/website/contributing/architecture.md b/website/contributing/architecture.md index 2e75163..804707e 100644 --- a/website/contributing/architecture.md +++ b/website/contributing/architecture.md @@ -45,4 +45,5 @@ Every catalog write goes through one contract: read the content, compute the nex | Connection plus a store, or `{profile}` in the catalog path | One connection, one catalog per profile | | Connection plus a plain catalog path | One connection, one catalog | | Saved profiles | One connection and one catalog per saved profile | -| Nothing | stdio only, waiting for the legacy `configure` tool | +| Connection without a catalog path or store | Startup fails; an explicit catalog is required | +| Nothing | Startup fails: `No saved Kibana connection is available. Run connection bootstrap first.` | diff --git a/website/deployment/http.md b/website/deployment/http.md index 6b91525..1a6d68d 100644 --- a/website/deployment/http.md +++ b/website/deployment/http.md @@ -23,7 +23,7 @@ One process then serves every caller. Each tool call is bound to the profile nam - without `--profile-header`, the server refuses to start unless a default profile is named (`--profile`, `KIBANA_PROFILE` or `profile` in the configuration file); every caller is then served that one profile. Over stdio, per-profile catalogs fall back to the `default` profile. - profile names are compared exactly: `Alice` and `alice` are two profiles. -A profile without a catalog starts empty and fills it through `configure_index`. Profile names are turned into filesystem-safe keys before they reach a path or a store: a plain lowercase slug is kept as is, anything else gets a digest suffix. Literal names ending in the reserved `-<12 hex digits>` suffix are hashed too, so they cannot impersonate another profile's generated key. Catalogs previously written under an ambiguous literal name require explicit operator migration; the server never adopts another identity's catalog automatically. +A profile without a catalog starts empty and fills it through `configure_index`. Profile names are turned into filesystem-safe keys before they reach a path or a store: a plain lowercase slug is kept as is, anything else gets a digest suffix. Literal names ending in the reserved `-<12 hex digits>` suffix are hashed too, so they cannot impersonate another profile's generated key. The server never adopts another identity's catalog. HTTP options, as flags or environment variables: @@ -35,8 +35,8 @@ HTTP options, as flags or environment variables: `GET /healthz` answers `200` without authentication. -The profile header is trusted as sent: the server does not authenticate end users itself. Only a gateway that has already authenticated the caller should be able to reach it. The server refuses to listen on a non-loopback host without `KIBANA_HTTP_AUTH_TOKEN`, and the legacy `configure` tool is not exposed over HTTP. +The profile header is trusted as sent: the server does not authenticate end users itself. Only a gateway that has already authenticated the caller should be able to reach it. The server refuses to listen on a non-loopback host without `KIBANA_HTTP_AUTH_TOKEN`. Without a token, MCP requests must also have a loopback `Host` and, when present, a loopback `Origin`. Authenticated gateways may forward their own host and origin headers. -Legacy clients that send `initialize` receive an `Mcp-Session-Id` and must echo it on subsequent requests, including cancellation notifications and `DELETE` when finished. Sessions are bound to the original profile, limited to 128 per process, and expire after 30 minutes without a request (reclaimed on the next legacy request). They also retire after 1,024 requests to bound cancellation metadata retained by the SDK; retirement closes any remaining exchanges in that session. A full session registry returns `503`; an expired, exhausted, unknown, or mismatched session returns `404` and requires reinitialization. Modern requests and handshake-free legacy calls remain supported, but cross-request cancellation requires a session. +HTTP tool calls use the stateless protocol revision `2026-07-28`: send the current protocol's per-request `_meta` envelope and its `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` headers as required by the SDK; no initialization handshake or `Mcp-Session-Id` is needed. The server does not allocate client sessions or provide session-based GET streams, DELETE, or cross-request cancellation. Disconnecting an in-flight request cancels its backend work; backend deadlines still bound every tool call. Authentication and profile selection are checked on every request. diff --git a/website/deployment/security.md b/website/deployment/security.md index b1d63fe..82b1ae1 100644 --- a/website/deployment/security.md +++ b/website/deployment/security.md @@ -25,7 +25,7 @@ The profile header is taken as sent. Anyone who can reach the port and knows the - **A missing profile is refused.** With a profile header configured, a request without it gets `401`. - **Profile names never reach a path or key as is.** They are turned into a key of lowercase letters, digits and dashes; anything else gets a digest suffix. `../../etc/passwd` becomes `etc-passwd-`. - **Profiles are compared exactly.** `Alice` and `alice` are two profiles with two catalogs. -- **The legacy `configure` tool is not exposed over HTTP.** It accepts credentials as tool input and would replace the connection for everyone. +- **MCP tools never accept connection credentials.** Supply the connection at startup, from saved platform credentials or explicit environment/configuration values; `configure_index` changes only the caller’s managed source catalog. ## Kibana credentials diff --git a/website/deployment/stdio.md b/website/deployment/stdio.md index 3e6afb2..d8d8250 100644 --- a/website/deployment/stdio.md +++ b/website/deployment/stdio.md @@ -14,9 +14,11 @@ npx -y @havesomecode/kibana-mcp-server serve - After [guided setup or bootstrap](../guide/getting-started), the default saved profile. - With `--profile ` or `KIBANA_PROFILE`, that saved profile. -- With the connection in [environment variables](../reference/environment), the catalog named by `KIBANA_SOURCE_CATALOG_PATH`. +- With the connection in [environment variables](../reference/environment), the catalog explicitly named by `KIBANA_SOURCE_CATALOG_PATH`. No checkout file is selected implicitly. - With a [configuration file](../guide/configuration-file) that declares a store, the profile named by `profile`, `--profile` or `KIBANA_PROFILE`, falling back to `default`. +The server must have a connection before it starts. Without a saved or explicit connection, startup fails with `No saved Kibana connection is available. Run connection bootstrap first.` + ## Running from a checkout ```bash diff --git a/website/guide/configuration-file.md b/website/guide/configuration-file.md index 8b81d99..767971e 100644 --- a/website/guide/configuration-file.md +++ b/website/guide/configuration-file.md @@ -26,7 +26,7 @@ export default defineKibanaMcpConfig({ ``` - `url`, `user`, `password`, `timeoutMs`, `profile` and `http.*` are defaults: the matching `KIBANA_*` environment variable or `serve` flag wins when set. -- `store` decides where source catalogs live, one per profile. `storeOptions` is handed to the store factory. Without `store`, catalogs are files. +- `store` decides where source catalogs live, one per profile. `storeOptions` is handed to the store factory. Without `store`, direct connection startup requires an explicit `KIBANA_SOURCE_CATALOG_PATH`; saved profiles carry their own file path. No catalog is discovered from the checkout. - The file is executed as code, so it is loaded only when named with `--config` or `KIBANA_MCP_CONFIG`, never discovered from the working directory. A `.ts` file needs Node.js 22.18 or later; `.mjs` works everywhere. ## Next steps diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index 813cebf..07558a4 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -8,7 +8,7 @@ description: "Requirements, prompt-free bootstrap, agent skill installation and - Node.js 22+ - A Kibana-compatible read endpoint reachable with basic auth -- One or more Elasticsearch index patterns. `bootstrap` generates the source catalog; hand-authored catalogs remain supported for advanced setups. +- No index is needed for bootstrap. The user names the exact Elasticsearch index or pattern only when adding a source through `configure_index`. Hand-authored catalogs remain supported for advanced setups. ## Installation @@ -27,7 +27,9 @@ npx -y @havesomecode/kibana-mcp-server bootstrap \ This command performs no prompts and does not edit Codex configuration files directly. It verifies only the Kibana connection, writes an empty managed source catalog on first install, stores credentials securely, and registers the exact running package transport. It does not list, scan, infer, or configure indexes. -After bootstrap, call `discover`. When no source is configured, the agent must ask the user which exact Kibana index or index pattern to use. Only after the user answers may the agent call `configure_index` with that value. That tool validates only the approved index and persists it without accepting credentials. +After bootstrap, start the server without `KIBANA_BASE_URL`, `KIBANA_USERNAME`, and `KIBANA_PASSWORD` in its environment so it uses the saved profile. Direct connection startup instead requires an explicit `KIBANA_SOURCE_CATALOG_PATH` or configuration-file store; no checkout catalog is loaded implicitly. + +Reconnect the MCP client, then call `discover`. When no source is configured, the agent must ask the user which exact Kibana index or index pattern to use. Only after the user answers may the agent call `configure_index` with that value. That tool validates only the approved index and persists it without accepting credentials. Bootstrap reruns preserve sources that were configured through this explicit flow. It refuses to replace a hand-authored or modified catalog unless `--replace` is supplied explicitly; `--replace` creates an empty managed catalog. @@ -67,8 +69,8 @@ npm run build 4. Open the cloned repo in Codex. 5. Open the plugin directory in Codex and install `Kibana Log Investigation` from the repo marketplace. - if the current model cannot complete that install itself, do the Codex UI click manually and let the agent continue with configuration afterward -6. Check the plugin's MCP process startup status. If startup fails, inspect sanitized stderr, rebuild and reinstall the plugin. Restart once only after startup succeeds if the tools still need refreshing. -7. Run `node dist/src/bin/kibana-mcp-server.js bootstrap --client none` to verify the connection and save an empty profile. The repo plugin already supplies the client registration. +6. Run `node dist/src/bin/kibana-mcp-server.js bootstrap --client none` to verify the connection and save an empty profile. The repo plugin already supplies the client registration. +7. Reconnect the plugin's MCP process and check its startup status. It requires a saved connection before it can start. Inspect sanitized stderr on failure; rebuild and reinstall only if the plugin runtime is missing. Restart once after startup succeeds if the tools still need refreshing. 8. In a fresh thread, call `discover`, ask the user for the exact index or pattern, then call `configure_index` only after they answer. Repo-scoped plugin files: diff --git a/website/guide/profiles.md b/website/guide/profiles.md index 5856d06..53e9f23 100644 --- a/website/guide/profiles.md +++ b/website/guide/profiles.md @@ -24,16 +24,13 @@ Guided setup remains available for hand-authored source catalogs: - imports the selected source catalog into machine-local state so later threads do not depend on the repo checkout - requires an explicit catalog path; type `example` to opt into the bundled example deliberately -The server also supports two advanced compatibility paths: +For direct process startup, supply the connection through environment variables together with an explicit `KIBANA_SOURCE_CATALOG_PATH`, or declare a catalog store in a [configuration file](./configuration-file). There is no implicit checkout catalog and no MCP tool accepts connection credentials. -- bootstrap at process start through environment variables plus a JSON source catalog -- runtime from your MCP client through the `configure` tool - -When `configure` is called, the source catalog is still persisted to disk so restarts do not wipe the logical source setup. +Without a saved or explicit connection, startup fails: `No saved Kibana connection is available. Run connection bootstrap first.` ## Machine profiles -After guided setup, the default saved profile loads automatically on startup. +After connection bootstrap or guided setup, the default saved profile loads automatically on startup. Do not leave `KIBANA_BASE_URL`, `KIBANA_USERNAME`, and `KIBANA_PASSWORD` set in the server environment when you intend to use saved credentials: supplying all three selects direct environment startup and requires an explicit catalog path or store. If you saved more than one environment, select a non-default profile with: @@ -47,7 +44,7 @@ KIBANA_PROFILE=staging npm run dev ## Multiple environments -The recommended path is to save multiple machine profiles during setup. +Save a separate profile per environment with `bootstrap --profile --client codex`, or import hand-authored catalogs through guided setup. Use `--no-default` during bootstrap when the new profile must not replace the current default. Then: @@ -62,11 +59,11 @@ KIBANA_PROFILE=staging npm run dev KIBANA_PROFILE=prod npm run dev ``` -If you still prefer env-bootstrap entries, keep these distinct per environment: +For direct environment-based entries, keep these distinct per environment: - `KIBANA_BASE_URL` - `KIBANA_USERNAME` - `KIBANA_PASSWORD` - `KIBANA_SOURCE_CATALOG_PATH` -Using separate source-catalog paths matters because the `configure` tool persists sources to disk, and one shared runtime file would cause the environments to overwrite each other. +Use separate catalog paths because `configure_index` persists managed sources. Sharing a file would mix sources from different environments. Hand-authored catalogs remain operator-managed and are not overwritten by that tool. diff --git a/website/guide/what-is-it.md b/website/guide/what-is-it.md index 13d6995..b8a7dc2 100644 --- a/website/guide/what-is-it.md +++ b/website/guide/what-is-it.md @@ -8,10 +8,9 @@ Read-only MCP server for agent-driven log investigation against Kibana-backed se The server is intentionally small: -- `setup` saves one or more machine-level Kibana profiles for later threads -- `bootstrap` verifies only the Kibana connection, creates an empty managed catalog, saves credentials, and registers Codex without prompts or config-file edits +- guided `setup` imports an explicitly selected hand-authored catalog and saves machine-level Kibana profiles for later threads +- `bootstrap` verifies only the Kibana connection, creates an empty managed catalog, saves credentials, and registers Codex without config-file edits; it is prompt-free when all connection values are supplied - `configure_index` validates and persists exactly one index or pattern after the user explicitly names it -- `configure` still exists for advanced MCP clients that want to drive credentials and source catalogs at runtime - `describe_fields` exposes effective field capabilities for one configured source - `discover` lists configured logical sources and field hints - `filter` runs an exact-field filter when the field name is already known diff --git a/website/reference/cli.md b/website/reference/cli.md index 448b5a9..b9a6daa 100644 --- a/website/reference/cli.md +++ b/website/reference/cli.md @@ -28,7 +28,7 @@ Starts the MCP server, over stdio by default. ## `bootstrap` -Verifies the Kibana connection, creates an empty managed catalog, saves the credentials and optionally registers the server with a client, without prompts. +Verifies the Kibana connection, creates an empty managed catalog, saves the credentials and optionally registers the server with a client. It never inspects or configures indexes. Supply connection values through flags or `KIBANA_BASE_URL`, `KIBANA_USERNAME` and `KIBANA_PASSWORD` for prompt-free operation; otherwise it prompts only for missing connection values. | Option | Description | | --- | --- | @@ -46,4 +46,4 @@ Verifies the Kibana connection, creates an empty managed catalog, saves the cred ## `setup` -Runs the guided, interactive version of the same flow, for hand-authored source catalogs. +Runs guided setup for explicitly selected hand-authored source catalogs. This is a separate command, not an alias for `bootstrap`, and accepts no bootstrap flags. diff --git a/website/reference/config-file.md b/website/reference/config-file.md index e684922..10861e5 100644 --- a/website/reference/config-file.md +++ b/website/reference/config-file.md @@ -41,6 +41,7 @@ Command-line flag, then environment variable, then configuration file, then buil - `store` requires `url`, `user` and `password`, from the file or from the environment. - `store` cannot be combined with `KIBANA_SOURCE_CATALOG_PATH`. +- Without `store`, a direct connection requires an explicit `KIBANA_SOURCE_CATALOG_PATH`. Saved-profile startup uses the path stored with the profile. There is no implicit catalog path. - A `.ts` file needs Node.js 22.18 or later. A `.mjs` file works on every supported version. - The package must be resolvable from the directory of the file, since the file imports it. diff --git a/website/reference/environment.md b/website/reference/environment.md index 77acf08..b48239f 100644 --- a/website/reference/environment.md +++ b/website/reference/environment.md @@ -13,7 +13,7 @@ The server reads its connection from environment variables when they are set. A - `KIBANA_PASSWORD` - `KIBANA_PROFILE` optional, selects a saved non-default machine profile - `KIBANA_TIMEOUT_MS` optional, default `10000` -- `KIBANA_SOURCE_CATALOG_PATH` optional, default `config/sources.runtime.json` +- `KIBANA_SOURCE_CATALOG_PATH` required for direct environment-based startup unless a configuration-file store supplies the catalog; saved profiles carry their own path. No implicit default. `KIBANA_TIMEOUT_MS` bounds each Kibana HTTP request end to end: it includes waiting for a request slot, connecting, and reading the response body. The server allows at most eight concurrent Kibana requests and 32 queued requests per configured client; excess work fails immediately with `KIBANA_OVERLOADED`. Cancelled MCP tool calls abort their queued or in-flight backend work, except shared schema discovery: cancelling one caller stops only its wait while other callers remain; cancelling the last caller aborts the shared backend request. @@ -33,8 +33,9 @@ Do not use: The source definition already carries endpoint paths such as `/internal/search/es`, so including a full API path in `KIBANA_BASE_URL` usually produces a bad combined URL and a `404`. -For the env-bootstrap path, start from `config/sources.example.json` and copy it to `config/sources.json`. -If no explicit `KIBANA_SOURCE_CATALOG_PATH` is set, env bootstrap will try `config/sources.runtime.json` first and then fall back to `config/sources.json`. +Direct environment-based startup requires all three connection values together and an explicit `KIBANA_SOURCE_CATALOG_PATH`, or a [configuration-file store](../deployment/catalog-stores). The server reads only the named catalog; it never searches the checkout for one. Hand-authored catalogs may use `config/sources.example.json` as a deliberate template, not as an automatic setup input. + +For saved-profile startup, leave the connection variables unset and select the profile with `KIBANA_PROFILE` if needed. Its catalog path and credentials are loaded from saved state. Without a saved connection, startup fails with `No saved Kibana connection is available. Run connection bootstrap first.` ## Configuration file diff --git a/website/reference/errors.md b/website/reference/errors.md index dc2bd8f..7c2f7cf 100644 --- a/website/reference/errors.md +++ b/website/reference/errors.md @@ -25,13 +25,21 @@ A tool call that fails while talking to Kibana returns an error result whose `_m Each Kibana connection allows eight concurrent requests and thirty-two queued ones. Beyond that, calls fail immediately with `KIBANA_OVERLOADED`. Profiles that share a connection share that budget. +## Startup errors + +| Message or condition | Action | +| --- | --- | +| `No saved Kibana connection is available. Run connection bootstrap first.` | Run connection-only `bootstrap --client codex` (or `--client none` for an already registered server), then reconnect the client. | +| `KIBANA_SOURCE_CATALOG_PATH is required for an environment connection.` | Name the catalog explicitly, declare a store, or unset the connection variables to use a saved profile. | +| `Incomplete Kibana bootstrap environment. Provide KIBANA_BASE_URL, KIBANA_USERNAME, and KIBANA_PASSWORD together.` | Supply all three together for direct startup, or unset them to use saved credentials. | + ## Catalog errors | Message | Cause | | --- | --- | | `Source catalog '…' changed concurrently. Retry the operation.` | Another caller changed the catalog while `configure_index` was validating. Retry. | | `configure_index can update only a managed generated catalog.` | The catalog was written or edited by hand. The server will not overwrite it. | -| `Source catalog '…' does not exist although sources are configured.` | The sources were loaded from another location, such as the static fallback catalog. | +| `Source catalog '…' does not exist although sources are configured.` | The loaded catalog was removed while the server was running. Restore the configured file before retrying. | | `Source id '…' already refers to a different index.` | Pass `replace: true` after the user approved the replacement. | ## HTTP transport diff --git a/website/reference/tools/configure.md b/website/reference/tools/configure.md deleted file mode 100644 index 056c866..0000000 --- a/website/reference/tools/configure.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -description: "Reference of the legacy configure tool, only exposed over stdio on an unconfigured server." ---- - -# `configure` - -Legacy tool that replaces the Kibana connection and the whole source catalog. Only exposed over stdio, on a server started without any configuration. - -Input: - -```json -{ - "kibana": { - "baseUrl": "https://kibana.example.com", - "username": "elastic", - "password": "secret", - "timeoutMs": 10000 - }, - "sources": [ - { - "id": "app_logs", - "name": "Application Logs", - "tags": ["app", "logs", "production"], - "timeField": "@timestamp", - "backend": { - "kind": "kibana_internal_search_es", - "path": "/internal/search/es", - "index": ["app-logs-*"] - }, - "schema": { - "kind": "kibana_data_views_fields", - "index": ["app-logs-*"] - }, - "fieldHints": [ - { - "name": "traceId", - "aliases": ["trace_id"] - } - ], - "defaultTextFields": ["message"], - "evidenceFields": ["traceId"] - } - ] -} -``` - -If you do not bootstrap via env vars and do not have a saved default profile, call `configure` before `discover` or `query`. -Calling `configure` also persists the provided sources to the runtime source-catalog file so later restarts can reload them automatically. -If you want `describe_fields`, exact-field auto-resolution in `query`, or nested-filter validation, configure `schema` for each relevant source. -In deployments where schema endpoints are routed indirectly or return `404`, prefer `schema.kind = "kibana_data_views_fields"` with `schema.index` set and omit `schema.path`. From a0f6a075996fd8e8a02aa03b1b3da6bddb0819c3 Mon Sep 17 00:00:00 2001 From: Zacaria Chtatar <5860751+Zacaria@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:29:24 +0000 Subject: [PATCH 2/3] fix: preserve supported contracts during cleanup Restore the HTTP session and handshake-free contracts added in PR #47, and retain setup connection flags through one Commander parser. Remove stale build artifacts before compilation and require package/source parity. Isolate stdio tests from ambient profiles, correct onboarding and schema docs, and retain unique requirements and historical backend evidence. Fresh full verification: 371 tests across 44 files, types, lint, format, build, installed-plugin handshake, release and package contracts, and docs build. Independent exact-tree correction review passed 127 focused tests. Retirement of configure and its unconfigured setup flow remains an explicit open owner decision; this correction is not approval to merge or release that removal. --- INSTALL.md | 14 +- README.md | 13 +- ...-install-and-durable-setup-requirements.md | 30 + docs/project/release-checklist.md | 1 + docs/project/support-policy.md | 2 +- ...endpoints-may-be-unavailable-2026-04-03.md | 4 +- docs/sources/README.md | 1 + docs/sources/feature-v1-review.md | 18 + package.json | 2 +- .../skills/install-and-configure/SKILL.md | 2 +- scripts/verify-packlist.mjs | 29 +- src/commands/bootstrap.ts | 138 ++- src/commands/setup.ts | 47 +- src/transports/http.ts | 113 +- test/cli.test.ts | 201 +++- test/transports/helpers.ts | 35 +- test/transports/http.test.ts | 1052 ++++++++++++----- test/transports/stdio.test.ts | 2 + website/deployment/http.md | 4 +- website/reference/cli.md | 10 +- website/reference/source-catalog.md | 2 +- 21 files changed, 1306 insertions(+), 414 deletions(-) create mode 100644 docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md create mode 100644 docs/sources/feature-v1-review.md diff --git a/INSTALL.md b/INSTALL.md index 7bc2766..5a928be 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,7 +2,7 @@ This is the canonical install and handoff guide for `@havesomecode/kibana-mcp-server`. -The supported newcomer path is a prompt-free, connection-only bootstrap. Bootstrap never lists, scans, infers, or configures Kibana indexes. Agents must use the CLI contract below instead of editing Codex or MCP configuration files. +The supported newcomer path is connection-only bootstrap. It prompts for missing connection values and is prompt-free when all values are supplied. Bootstrap never lists, scans, infers, or configures Kibana indexes. Agents must use the CLI contract below instead of editing Codex or MCP configuration files. ## Version-Matched README Handoff @@ -32,15 +32,15 @@ Pass the verified URL and resolved commit to the receiving agent. A master previ - Node.js 22 or newer - `codex` on `PATH` when `--client codex` is used - a Kibana base URL reachable with basic authentication -- credentials supplied through a trusted environment or stdin +- credentials supplied through a trusted environment, stdin, or a secret prompt -Provision these machine values before installation: +For prompt-free automation, provision these values before installation: - `KIBANA_BASE_URL` - `KIBANA_USERNAME` - `KIBANA_PASSWORD` -No index is required or accepted during bootstrap. +Without these values, bootstrap prompts for the missing connection inputs. No index is required or accepted during bootstrap. ## Install The Agent Skill @@ -73,7 +73,7 @@ npx -y @havesomecode/kibana-mcp-server bootstrap \ --client codex ``` -Bootstrap is non-interactive and idempotent for profiles it generated. It: +With all connection values supplied, bootstrap is non-interactive. It is idempotent for profiles it generated and: 1. validates the connection inputs; 2. checks for a missing or conflicting Codex registration; @@ -89,6 +89,8 @@ Bootstrap is non-interactive and idempotent for profiles it generated. It: A non-zero exit means installation did not complete. A successful exit verifies the connection, not any index. +After bootstrap, remove `KIBANA_BASE_URL`, `KIBANA_USERNAME`, and `KIBANA_PASSWORD` from the MCP server environment before reconnecting so startup uses the saved profile. In Bash, run `unset KIBANA_BASE_URL KIBANA_USERNAME KIBANA_PASSWORD`; in PowerShell, run `Remove-Item Env:KIBANA_BASE_URL, Env:KIBANA_USERNAME, Env:KIBANA_PASSWORD -ErrorAction SilentlyContinue`. Leaving direct connection values set overrides profile selection and requires an explicit catalog path or store. + ## Explicit Index Selection Start a fresh agent session after bootstrap and call `discover`. An empty catalog responds with instructions to ask the user which exact Kibana index or index pattern they want to use. @@ -157,7 +159,7 @@ If a profile points to a hand-authored or operator-modified catalog, bootstrap f ## Example Catalog Policy -`config/sources.example.json` is documentation data. It never participates in bootstrap precedence. `bootstrap` is always connection-only, including without flags. Guided `setup` is a separate command with no bootstrap flags, for operators who explicitly want a hand-authored catalog: +`config/sources.example.json` is documentation data. It never participates in bootstrap precedence. `bootstrap` is always connection-only, including without flags. Bare `setup` guides operators who explicitly want a hand-authored catalog; with connection options, `setup` uses the same connection-only bootstrap flow: ```bash npx -y @havesomecode/kibana-mcp-server setup diff --git a/README.md b/README.md index d39176f..49535ad 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,13 @@ The server never writes to Elasticsearch or Kibana, and never guesses an index. ## Quick start -Pre-provision the connection values in the environment, then bootstrap: +Bootstrap the connection; missing values are prompted for: + +```bash +npx -y @havesomecode/kibana-mcp-server bootstrap --client codex +``` + +For prompt-free automation, provision the connection values in the environment: ```bash export KIBANA_BASE_URL='https://kibana.example.com' @@ -32,11 +38,14 @@ export KIBANA_USERNAME='elastic' export KIBANA_PASSWORD='use-a-secret-provider' npx -y @havesomecode/kibana-mcp-server bootstrap --client codex +unset KIBANA_BASE_URL KIBANA_USERNAME KIBANA_PASSWORD ``` Bootstrap verifies only the Kibana connection, creates an empty managed catalog, stores the credentials securely and registers the server. It does not list, scan, infer, or configure indexes: the agent asks which exact index or pattern to use, then calls `configure_index`. -The server requires a connection at startup. Use bootstrap, a configuration file, or connection environment variables with an explicit `KIBANA_SOURCE_CATALOG_PATH`. It does not accept credentials through MCP tools or search the checkout for a catalog. `bootstrap` always saves only the connection; `setup` is a separate guided command for importing hand-authored catalogs. +Clear the connection variables from the MCP server environment after bootstrap so startup uses the saved profile. Direct environment connections take precedence over profiles and require an explicit catalog path or store. + +The server requires a connection at startup. Use bootstrap, a configuration file, or connection environment variables with an explicit `KIBANA_SOURCE_CATALOG_PATH`. It does not accept credentials through MCP tools or search the checkout for a catalog. `bootstrap` always saves only the connection. Bare `setup` guides hand-authored catalog import; `setup` with connection options uses the same bootstrap flow. Never pass passwords as command-line arguments. Use `KIBANA_PASSWORD`, `--password-env NAME`, or `--password-stdin`. diff --git a/docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md b/docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md new file mode 100644 index 0000000..9cf255c --- /dev/null +++ b/docs/brainstorms/2026-04-09-ergonomic-install-and-durable-setup-requirements.md @@ -0,0 +1,30 @@ +--- +date: 2026-04-09 +topic: ergonomic-install-and-durable-setup +--- + +# Installation and Durable Setup Requirements + +These are the retained product decisions from the original requirements discussion. Completed implementation sequencing, speculative architecture and migration instructions are omitted. The current operator contract is in [INSTALL.md](../../INSTALL.md). + +## Installation + +- Provide copyable human commands and agent instructions, with both published-package and repository-plugin paths. Document actual distribution availability rather than implying an unpublished package is usable. +- A human can start connection bootstrap without manually exporting variables; prompt only for missing connection values. Fully supplied inputs must support prompt-free automation. +- Verify the base URL and connection before saving. Bootstrap must not guess, enumerate, scan or select indexes; configure an index only after the operator explicitly names it. + +## Durable Use + +- Save one machine-level default profile and support additional named environments. Later threads must reuse saved configuration without repeating setup. +- Explicit connection overrides take precedence over saved profiles. Clear bootstrap-only connection variables before saved-profile startup. +- Preserve operator-authored catalogs and isolate named environments; replacement requires an explicit operator choice. + +## Credentials and Local State + +- Keep secrets separate from catalog and profile metadata, using the supported platform credential store on macOS, Windows and Linux. +- Explain unavailable or locked secret stores without silently storing plaintext credentials or making manual secret exports the normal human onboarding flow. +- Never put passwords in command arguments, client configuration, source catalogs or summaries. + +## Boundaries + +Human and agent installation must use the same setup behavior. OAuth/SSO, machine-to-machine profile synchronization, fleet management and a graphical setup application are outside this scope. Preserve advanced automation options without retaining a second argument parser or owner-only migration machinery. diff --git a/docs/project/release-checklist.md b/docs/project/release-checklist.md index 076977a..d93d7cf 100644 --- a/docs/project/release-checklist.md +++ b/docs/project/release-checklist.md @@ -10,6 +10,7 @@ updated: 2026-04-09 - CI is green on `master`. - `npm run verify` passes locally. +- Build output must correspond to current source: `npm run build` replaces root `dist/`, and every packed `dist/src/**/*.js` or `dist/src/**/*.d.ts` must have its corresponding `src/**/*.ts` file. `npm run verify:packlist` rejects orphaned outputs from removed modules. - The compatibility matrix is up to date (`docs/project/compatibility-matrix.md`). - Any new env or config requirements are documented in `README.md` and `INSTALL.md`. - Startup requires a connection and an explicit catalog path or store; saved profiles use their stored path. Docs must not advertise a credential-taking MCP setup tool or implicit checkout catalog. diff --git a/docs/project/support-policy.md b/docs/project/support-policy.md index 3f37b3d..40b5dca 100644 --- a/docs/project/support-policy.md +++ b/docs/project/support-policy.md @@ -34,7 +34,7 @@ Startup without a saved connection fails with `No saved Kibana connection is ava ## Known Environment Constraints - Some Codex model variants are less reliable at completing repo-local plugin installation without a manual Codex UI step. The supported fallback is to install the plugin manually from the local marketplace, then continue with MCP configuration. -- Some Kibana deployments proxy or block schema metadata endpoints. On `404`, the server can sample fields through the configured search backend. This fallback is heuristic, not authoritative; schema-dependent features can still be unavailable. +- Some Kibana deployments proxy or block schema metadata endpoints. When all known Kibana data-view or index-pattern metadata endpoints return `404`, the server can sample fields through the configured search backend. Direct Elasticsearch field-capability errors do not use this fallback. Sampling is heuristic, not authoritative; schema-dependent features can still be unavailable. - `KIBANA_BASE_URL` must be the Kibana base prefix, not a full search endpoint such as `/internal/search/es`. - Linux secure storage depends on a Secret Service-compatible keyring being available and unlocked. - If you run staging and production side by side, prefer saved machine profiles plus `KIBANA_PROFILE` selection. For direct environment startup, name `KIBANA_SOURCE_CATALOG_PATH` explicitly and keep it distinct per entry so managed sources from different environments do not mix. diff --git a/docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md b/docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md index 42574ae..9097b52 100644 --- a/docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md +++ b/docs/solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md @@ -46,7 +46,7 @@ So source discovery and log querying remain usable even when schema metadata is For deployments with this shape: - configure the appropriate supported schema backend; Kibana data-view and index-pattern backends try their known metadata endpoints -- if all metadata endpoints return `404`, the server falls back to sampling fields through the configured search backend +- for a Kibana data-view or index-pattern schema backend, if all known metadata endpoints return `404`, the server falls back to sampling fields through the configured search backend; direct `elasticsearch_field_caps` errors do not use that fallback - treat sampled capabilities as heuristic, not authoritative; they can recover `.keyword` promotion and object paths, but cannot prove nested mappings - if schema loading and sampling both fail, use configured field hints and non-schema-dependent `discover`, `filter`, and `query` operations @@ -58,4 +58,4 @@ Before re-enabling schema-dependent behavior in a deployment, confirm one of the - Kibana legacy index patterns field metadata endpoint - direct Elasticsearch field capabilities endpoint -If none respond, verify the search-backend sampling fallback and inspect its provenance and advisories. Do not equate sampled fields with authoritative mapping metadata. +If none respond, select a Kibana data-view or index-pattern schema backend and verify its search-backend sampling fallback. Inspect the returned field capabilities and query advisories; the response does not expose a sampling-provenance field. Do not equate sampled fields with authoritative mapping metadata. diff --git a/docs/sources/README.md b/docs/sources/README.md index 0fa9442..69612aa 100644 --- a/docs/sources/README.md +++ b/docs/sources/README.md @@ -6,3 +6,4 @@ Current sources: - `handoff.md` for the original MCP handoff - `features-v1.md` for the v1 capability gap list +- `feature-v1-review.md` for historical live-backend observations, not current verification diff --git a/docs/sources/feature-v1-review.md b/docs/sources/feature-v1-review.md new file mode 100644 index 0000000..dde4147 --- /dev/null +++ b/docs/sources/feature-v1-review.md @@ -0,0 +1,18 @@ +# Feature v1 Review — Historical Backend Evidence + +This preserves the original live review's observations against a real Kibana deployment and a high-volume structured-log source. They have not been repeated against the current server or deployment and are not a statement of current failures. The obsolete checkout-catalog setup instructions are omitted. + +## Observed successes + +- A `filter` request in `hits` mode returned `next_cursor`; using it in the next request returned the next page. +- Filtering `event.keyword = WORKFLOW_RELOAD_STATS` and sorting by `total_duration_ms` produced the expected descending ranking. + +## Observed failures + +- `describe_fields` for the `event` and `steps` fields of `workflow_metrics` returned Kibana `404 Not Found`. +- Exact filtering `event = CACHE_REFRESH_PHASES` returned zero hits, while the equivalent `event.keyword` request returned the expected results. +- A nested request for path `steps`, field `name`, value `CACHE_REFRESH`, with `extract_nested = true`, returned Kibana `404 Not Found`. + +These observations motivated field introspection, exact-match resolution and nested evidence extraction. Current schema sampling can recover some field capabilities but cannot establish actual nested mappings; see the [schema-endpoint investigation](../solutions/integration-issues/kibana-mcp-schema-endpoints-may-be-unavailable-2026-04-03.md). + +Current supported request shapes are documented in the [tool reference](../../website/reference/tools/query.md). Local regression tests establish behavior against controlled responses, not a fresh successful replay of this historical deployment. diff --git a/package.json b/package.json index dcdb47c..db62a86 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "node": ">=22.0.0" }, "scripts": { - "build": "tsc -p tsconfig.build.json && node scripts/build-plugin.mjs", + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node scripts/build-plugin.mjs", "check:types": "tsc --noEmit -p tsconfig.json", "lint": "biome lint .", "format": "biome format . --write", diff --git a/plugins/kibana-log-investigation/skills/install-and-configure/SKILL.md b/plugins/kibana-log-investigation/skills/install-and-configure/SKILL.md index ed0a335..6ea3e38 100644 --- a/plugins/kibana-log-investigation/skills/install-and-configure/SKILL.md +++ b/plugins/kibana-log-investigation/skills/install-and-configure/SKILL.md @@ -38,7 +38,7 @@ npm run build node dist/src/bin/kibana-mcp-server.js bootstrap --client none ``` -7. Confirm bootstrap reports an empty source catalog. It must not inspect or configure indexes. +7. Confirm first-time bootstrap reports an empty source catalog. It must not inspect or configure indexes. Remove `KIBANA_BASE_URL`, `KIBANA_USERNAME`, and `KIBANA_PASSWORD` from the plugin process environment before reconnecting so startup uses the saved profile. In Bash: `unset KIBANA_BASE_URL KIBANA_USERNAME KIBANA_PASSWORD`; in PowerShell: `Remove-Item Env:KIBANA_BASE_URL, Env:KIBANA_USERNAME, Env:KIBANA_PASSWORD -ErrorAction SilentlyContinue`. 8. In a fresh Codex session, call `discover`. When it reports no sources, ask the operator which exact index or index pattern to configure. 9. Only after the operator answers, call `configure_index` with exactly that value. Then run `discover` again. diff --git a/scripts/verify-packlist.mjs b/scripts/verify-packlist.mjs index cf51bbd..5812fee 100644 --- a/scripts/verify-packlist.mjs +++ b/scripts/verify-packlist.mjs @@ -1,9 +1,14 @@ import { execFile } from "node:child_process"; +import { statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +const npmExecPath = process.env.npm_execpath; +if (!npmExecPath) { + throw new Error("npm_execpath is unavailable; run this verifier through npm."); +} const repoRoot = process.cwd(); const required = [ @@ -23,13 +28,17 @@ const required = [ const forbiddenPrefixes = ["test/", "test\\", "config/sources.json", "config/sources.runtime.json"]; -const { stdout } = await execFileAsync("npm", ["pack", "--json", "--dry-run"], { - encoding: "utf8", - env: { - ...process.env, - NPM_CONFIG_CACHE: resolve(repoRoot, ".npm-cache"), +const { stdout } = await execFileAsync( + process.execPath, + [npmExecPath, "pack", "--json", "--dry-run"], + { + encoding: "utf8", + env: { + ...process.env, + NPM_CONFIG_CACHE: resolve(repoRoot, ".npm-cache"), + }, }, -}); +); const result = JSON.parse(stdout); const entries = result?.[0]?.files ?? []; @@ -45,6 +54,14 @@ for (const file of files) { if (forbiddenPrefixes.some((prefix) => file.startsWith(prefix))) { throw new Error(`Packlist includes forbidden file: ${file}`); } + if (/^dist\/src\/.*(?:\.js|\.d\.ts)$/.test(file)) { + const source = file.slice("dist/".length).replace(/(?:\.js|\.d\.ts)$/, ".ts"); + if (!statSync(resolve(repoRoot, source), { throwIfNoEntry: false })?.isFile()) { + throw new Error( + `Packlist includes generated file without source: ${file} (expected ${source})`, + ); + } + } } const pluginBundle = entries.find( diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index 927416c..98cd51b 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -4,7 +4,7 @@ import { type BootstrapResult, runBootstrap } from "../setup/bootstrap.js"; import { type PromptIo, createPromptIo } from "../utils/cli_prompts.js"; import type { CliCommandContext } from "./context.js"; -interface BootstrapOptions { +export interface BootstrapOptions { profile?: string; url?: string; username?: string; @@ -19,13 +19,19 @@ interface BootstrapOptions { } export default function registerBootstrap(program: Command, context: CliCommandContext): void { - program - .command("bootstrap") - .description("Verify and save only the Kibana connection") - .addHelpText( - "after", - "\nBootstrap does not inspect or configure indexes. Missing connection values are prompted for.\n\nConnection options use KIBANA_BASE_URL, KIBANA_USERNAME, and KIBANA_PASSWORD as fallbacks. Passwords are accepted only through --password-stdin, --password-env , KIBANA_PASSWORD, or a secret prompt. Profile defaults to KIBANA_PROFILE, then default.", - ) + addBootstrapOptions( + program + .command("bootstrap") + .description("Verify and save only the Kibana connection") + .addHelpText( + "after", + "\nBootstrap does not inspect or configure indexes. Missing connection values are prompted for.\n\nConnection options use KIBANA_BASE_URL, KIBANA_USERNAME, and KIBANA_PASSWORD as fallbacks. Passwords are accepted only through --password-stdin, --password-env , KIBANA_PASSWORD, or a secret prompt. Profile defaults to KIBANA_PROFILE, then default.", + ), + ).action((options: BootstrapOptions) => runBootstrapCommand(options, context)); +} + +export function addBootstrapOptions(command: Command): Command { + return command .option("--profile ", "Saved profile name") .option("--url ", "Kibana base URL") .option("--username ", "Kibana basic-auth username") @@ -42,63 +48,67 @@ export default function registerBootstrap(program: Command, context: CliCommandC .option("--package ", "Package specifier for client registration") .option("--mcp-name ", "MCP registration name") .option("--replace", "Replace an existing catalog") - .option("--no-default", "Do not make this profile the default") - .action(async (options: BootstrapOptions) => { - const { env, stdin } = context.io; - let promptIo: PromptIo | undefined; - try { - let baseUrl = options.url?.trim() || env.KIBANA_BASE_URL?.trim(); - let username = options.username?.trim() || env.KIBANA_USERNAME?.trim(); - let password = options.passwordStdin - ? (await readAllInput(stdin)).replace(/\r?\n$/, "") - : options.passwordEnv !== undefined - ? env[options.passwordEnv] - : env.KIBANA_PASSWORD; - const promptForPassword = - !password && !options.passwordStdin && options.passwordEnv === undefined; - if (!baseUrl || !username || promptForPassword) { - promptIo = await createPromptIo(context.io); - } - baseUrl ||= (await promptIo?.prompt("Kibana base URL"))?.trim(); - if (!baseUrl) { - throw new Error( - "Kibana base URL is required via --url, KIBANA_BASE_URL, or the associated prompt.", - ); - } - username ||= (await promptIo?.prompt("Kibana username"))?.trim(); - if (!username) { - throw new Error( - "Kibana username is required via --username, KIBANA_USERNAME, or the associated prompt.", - ); - } - if (promptForPassword) { - password = (await promptIo?.prompt("Kibana password", { secret: true }))?.trim(); - } - if (!password) { - throw new Error( - "Kibana password is required via an option, KIBANA_PASSWORD, or the associated prompt.", - ); - } - const result = await (context.dependencies.runBootstrapFn ?? runBootstrap)({ - profileName: options.profile?.trim() || env.KIBANA_PROFILE?.trim() || "default", - baseUrl, - username, - password, - client: options.client ?? "codex", - packageSpecifier: options.package, - mcpName: options.mcpName, - makeDefault: options.default, - replaceExisting: options.replace ?? false, - timeoutMs: options.timeout, - }); - context.ui.success(renderBootstrapResult(result)); - } catch (error) { - context.ui.error(error instanceof Error ? error.message : String(error)); - context.setExitCode(1); - } finally { - await promptIo?.close(); - } + .option("--no-default", "Do not make this profile the default"); +} + +export async function runBootstrapCommand( + options: BootstrapOptions, + context: CliCommandContext, +): Promise { + const { env, stdin } = context.io; + let promptIo: PromptIo | undefined; + try { + let baseUrl = options.url?.trim() || env.KIBANA_BASE_URL?.trim(); + let username = options.username?.trim() || env.KIBANA_USERNAME?.trim(); + let password = options.passwordStdin + ? (await readAllInput(stdin)).replace(/\r?\n$/, "") + : options.passwordEnv !== undefined + ? env[options.passwordEnv] + : env.KIBANA_PASSWORD; + const promptForPassword = + !password && !options.passwordStdin && options.passwordEnv === undefined; + if (!baseUrl || !username || promptForPassword) { + promptIo = await createPromptIo(context.io); + } + baseUrl ||= (await promptIo?.prompt("Kibana base URL"))?.trim(); + if (!baseUrl) { + throw new Error( + "Kibana base URL is required via --url, KIBANA_BASE_URL, or the associated prompt.", + ); + } + username ||= (await promptIo?.prompt("Kibana username"))?.trim(); + if (!username) { + throw new Error( + "Kibana username is required via --username, KIBANA_USERNAME, or the associated prompt.", + ); + } + if (promptForPassword) { + password = (await promptIo?.prompt("Kibana password", { secret: true }))?.trim(); + } + if (!password) { + throw new Error( + "Kibana password is required via an option, KIBANA_PASSWORD, or the associated prompt.", + ); + } + const result = await (context.dependencies.runBootstrapFn ?? runBootstrap)({ + profileName: options.profile?.trim() || env.KIBANA_PROFILE?.trim() || "default", + baseUrl, + username, + password, + client: options.client ?? "codex", + packageSpecifier: options.package, + mcpName: options.mcpName, + makeDefault: options.default, + replaceExisting: options.replace ?? false, + timeoutMs: options.timeout, }); + context.ui.success(renderBootstrapResult(result)); + } catch (error) { + context.ui.error(error instanceof Error ? error.message : String(error)); + context.setExitCode(1); + } finally { + await promptIo?.close(); + } } function parseTimeout(value: string): number { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 2e8d237..61995f8 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -2,24 +2,37 @@ import type { Command } from "commander"; import { runSetupFlow } from "../setup/setup_flow.js"; import { PromptCancelledError, createPromptIo } from "../utils/cli_prompts.js"; +import { type BootstrapOptions, addBootstrapOptions, runBootstrapCommand } from "./bootstrap.js"; import type { CliCommandContext } from "./context.js"; export default function registerSetup(program: Command, context: CliCommandContext): void { - program - .command("setup") - .description("Run guided catalog and profile setup") - .action(async () => { - const promptIo = await createPromptIo(context.io); - try { - const result = await (context.dependencies.runSetupFlowFn ?? runSetupFlow)(promptIo); - context.ui.success( - `Saved ${result.profiles.length} environment${result.profiles.length === 1 ? "" : "s"}. Default environment: ${result.defaultProfileName}.`, - ); - } catch (error) { - if (error instanceof PromptCancelledError) context.setExitCode(130); - else throw error; - } finally { - await promptIo.close(); - } - }); + addBootstrapOptions( + program + .command("setup") + .description("Run guided setup, or connection-only bootstrap with options") + .addHelpText( + "after", + "\nWithout connection options, setup is guided even when connection environment values exist. With any connection option, setup uses bootstrap: it does not inspect or configure indexes and prompts only for missing connection values.", + ), + ).action(async (options: BootstrapOptions, command: Command) => { + if ( + command.options.some( + (option) => command.getOptionValueSource(option.attributeName()) === "cli", + ) + ) { + return runBootstrapCommand(options, context); + } + const promptIo = await createPromptIo(context.io); + try { + const result = await (context.dependencies.runSetupFlowFn ?? runSetupFlow)(promptIo); + context.ui.success( + `Saved ${result.profiles.length} environment${result.profiles.length === 1 ? "" : "s"}. Default environment: ${result.defaultProfileName}.`, + ); + } catch (error) { + if (error instanceof PromptCancelledError) context.setExitCode(130); + else throw error; + } finally { + await promptIo.close(); + } + }); } diff --git a/src/transports/http.ts b/src/transports/http.ts index 80d2e1b..e40816a 100644 --- a/src/transports/http.ts +++ b/src/transports/http.ts @@ -1,4 +1,4 @@ -import { createHash, timingSafeEqual } from "node:crypto"; +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { type IncomingMessage, type Server as NodeHttpServer, @@ -9,7 +9,10 @@ import type { AddressInfo } from "node:net"; import { Readable } from "node:stream"; import { + type McpServer, + WebStandardStreamableHTTPServerTransport, createMcpHandler, + isLegacyRequest, localhostAllowedHostnames, localhostAllowedOrigins, validateHostHeader, @@ -33,6 +36,9 @@ const DEFAULT_PORT = 3000; const DEFAULT_PATH = "/mcp"; const HEALTH_PATH = "/healthz"; const MAX_BODY_BYTES = 4 * 1024 * 1024; +const MAX_LEGACY_SESSIONS = 128; +const MAX_LEGACY_SESSION_REQUESTS = 1024; +const LEGACY_SESSION_IDLE_MS = 30 * 60 * 1000; const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]); export interface HttpServerOptions { @@ -99,18 +105,104 @@ async function listen( ); } - const handler = createMcpHandler( - (context) => { - const requestProfile = resolved.profileHeader - ? readProfileHeader(context.requestInfo?.headers, resolved.profileHeader) + // Modern and handshake-free legacy calls stay per-request. + const handler = createMcpHandler((context) => { + const requestProfile = resolved.profileHeader + ? readProfileHeader(context.requestInfo?.headers, resolved.profileHeader) + : undefined; + return createApplication(undefined, { profileRegistry: registry, requestProfile }).server; + }); + + const legacySessions = new Map< + string, + { + server: McpServer; + transport: WebStandardStreamableHTTPServerTransport; + profile: string | undefined; + lastUsed: number; + requests: number; + } + >(); + let initializingSessions = 0; + const fetchHandler = async (request: Request): Promise => { + if (!(await isLegacyRequest(request))) return handler.fetch(request); + for (const session of legacySessions.values()) { + if (Date.now() - session.lastUsed >= LEGACY_SESSION_IDLE_MS) await session.server.close(); + } + const profile = resolved.profileHeader + ? readProfileHeader(request.headers, resolved.profileHeader) + : undefined; + const sessionId = request.headers.get("mcp-session-id"); + if (sessionId) { + let session = legacySessions.get(sessionId); + // ponytail: recycle bounded sessions until the SDK releases cancelled request metadata. + if ( + session && + session.profile === profile && + session.requests >= MAX_LEGACY_SESSION_REQUESTS + ) { + await session.server.close(); + session = undefined; + } + if (!session || session.profile !== profile) { + return Response.json( + { + jsonrpc: "2.0", + id: null, + error: { code: -32001, message: "Session not found" }, + }, + { status: 404 }, + ); + } + session.lastUsed = Date.now(); + session.requests += 1; + return session.transport.handleRequest(request); + } + const body = + request.method === "POST" + ? await request + .clone() + .json() + .catch(() => undefined) : undefined; - return createApplication(undefined, { profileRegistry: registry, requestProfile }).server; - }, - { legacy: "reject" }, - ); + if (body?.method !== "initialize") return handler.fetch(request); + if (legacySessions.size + initializingSessions >= MAX_LEGACY_SESSIONS) { + return Response.json( + { error: "Too many MCP sessions. Close an existing session and retry." }, + { status: 503 }, + ); + } + + // The SDK owns request IDs and cancellation inside each unguessable client session. + initializingSessions += 1; + const server = createApplication(undefined, { + profileRegistry: registry, + requestProfile: profile, + }).server; + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: (id) => { + legacySessions.set(id, { server, transport, profile, lastUsed: Date.now(), requests: 0 }); + }, + }); + transport.onclose = () => { + legacySessions.delete(transport.sessionId ?? ""); + }; + try { + await server.connect(transport); + const response = await transport.handleRequest(request, { parsedBody: body }); + if (!transport.sessionId || response.status >= 400) await server.close(); + return response; + } catch (error) { + await server.close().catch(() => {}); + throw error; + } finally { + initializingSessions -= 1; + } + }; const httpServer = createServer((request, response) => { - void handleRequest(request, response, resolved, handler.fetch).catch(() => { + void handleRequest(request, response, resolved, fetchHandler).catch(() => { if (!response.headersSent) { writeJson(response, 500, { error: "internal_error" }); return; @@ -133,6 +225,7 @@ async function listen( url: `http://${urlHost}:${address.port}${resolved.path}`, close: async () => { await handler.close(); + await Promise.all([...legacySessions.values()].map((session) => session.server.close())); await closeHttpServer(httpServer); await startupConfiguration.close(); }, diff --git a/test/cli.test.ts b/test/cli.test.ts index 2c7055f..ead0d7a 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,6 +1,7 @@ import { Readable } from "node:stream"; import { describe, expect, it, vi } from "vitest"; import { runCli } from "../src/bin/run_cli.js"; +import { PromptCancelledError } from "../src/utils/cli_prompts.js"; const connectionEnv = { KIBANA_BASE_URL: "https://kibana.example.com", @@ -227,7 +228,7 @@ describe("runCli", () => { expect(stdout.join("\n")).not.toContain("prompt-secret"); }); - it("registers setup and bootstrap separately and keeps connection flags off setup", async () => { + it("registers setup and bootstrap separately with shared connection options", async () => { const stdout: string[] = []; expect( await runCli(["node", "kibana-mcp-server", "--help"], { @@ -246,8 +247,9 @@ describe("runCli", () => { }), ).toBe(0); expect(stdout.join("\n")).toContain("guided"); - expect(stdout.join("\n")).not.toContain("--url"); - expect(stdout.join("\n")).not.toContain("--password"); + expect(stdout.join("\n")).toContain("--url"); + expect(stdout.join("\n")).toContain("--password-stdin"); + expect(stdout.join("\n")).not.toContain("--index"); }); it("passes Commander-parsed equals, repeated, numeric and negated bootstrap options", async () => { @@ -364,20 +366,197 @@ describe("runCli", () => { expect(stdout.join("\n")).not.toContain("--time-field"); }); - it("rejects bootstrap flags on guided setup", async () => { - const runBootstrapFn = vi.fn(); + it("accepts deterministic setup flags as a bootstrap-compatible alias", async () => { + let called = false; + const exitCode = await runCli( + [ + "node", + "kibana-mcp-server", + "setup", + "--profile", + "default", + "--url", + "https://kibana.example.com", + "--username", + "elastic", + "--password-env", + "KIBANA_PASSWORD", + "--client", + "none", + ], + { + stdout: () => {}, + stderr: () => {}, + env: { + KIBANA_BASE_URL: "https://kibana.example.com", + KIBANA_USERNAME: "elastic", + KIBANA_PASSWORD: "secret", + }, + }, + { + async runBootstrapFn() { + called = true; + return { + profileName: "default", + profileId: "default", + sourceCount: 0, + sourceCatalogPath: "/tmp/consumer.json", + client: "none" as const, + registered: false, + verified: true as const, + }; + }, + }, + ); + + expect(exitCode).toBe(0); + expect(called).toBe(true); + }); + + it.each([ + [["--profile=prod"], { profileName: "prod" }], + [["--url=https://override.example.com"], { baseUrl: "https://override.example.com" }], + [["--username=first", "--username=last"], { username: "last" }], + [["--password-stdin"], { password: " stdin-secret " }], + [["--password-env=OTHER_SECRET"], { password: " alternate-secret " }], + [["--timeout=120000"], { timeoutMs: 120000 }], + [["--client=none"], { client: "none" }], + [["--package=example@1.0.0"], { packageSpecifier: "example@1.0.0" }], + [["--mcp-name=custom"], { mcpName: "custom" }], + [["--replace"], { replaceExisting: true }], + [["--no-default"], { makeDefault: false }], + ] as const)( + "uses connection-only setup for explicitly supplied options %j", + async (flags, expected) => { + const runBootstrapFn = vi.fn().mockResolvedValue(bootstrapResult); + const runSetupFlowFn = vi.fn(); + const stdout: string[] = []; + const stderr: string[] = []; + const exitCode = await runCli( + ["node", "kibana-mcp-server", "setup", ...flags], + { + env: { + ...connectionEnv, + KIBANA_PROFILE: " staging ", + OTHER_SECRET: " alternate-secret ", + }, + stdin: flags.some((flag) => flag === "--password-stdin") + ? Readable.from(" stdin-secret \r\n") + : new Readable({ + read() { + throw new Error("Unexpected stdin read"); + }, + }), + stdout: (text) => stdout.push(text), + stderr: (text) => stderr.push(text), + }, + { runBootstrapFn, runSetupFlowFn }, + ); + + expect(exitCode).toBe(0); + expect(stderr).toEqual([]); + expect(runSetupFlowFn).not.toHaveBeenCalled(); + expect(runBootstrapFn).toHaveBeenCalledExactlyOnceWith({ + profileName: "staging", + baseUrl: connectionEnv.KIBANA_BASE_URL, + username: connectionEnv.KIBANA_USERNAME, + password: connectionEnv.KIBANA_PASSWORD, + client: "codex", + makeDefault: true, + replaceExisting: false, + packageSpecifier: undefined, + mcpName: undefined, + timeoutMs: undefined, + ...expected, + }); + expect(stdout.join("\n")).toContain("No Kibana indexes were inspected or configured"); + expect(stdout.join("\n")).not.toContain("secret"); + }, + ); + + it.each([ + [["--timeout=0"], "--timeout"], + [["--timeout=1.5"], "--timeout"], + [["--client=invalid"], "Allowed choices are codex, none"], + [["--password-stdin", "--password-env=OTHER_SECRET"], "cannot be used with"], + [["--password-env=OTHER_SECRET", "--password-stdin"], "cannot be used with"], + [["--password", "argument-secret"], "unknown option '--password'"], + [["--index", "logs-*"], "unknown option '--index'"], + [["--password-env=MISSING_SECRET"], "Kibana password is required"], + ] as const)( + "validates setup connection options %j before running bootstrap", + async (flags, message) => { + const runBootstrapFn = vi.fn(); + const runSetupFlowFn = vi.fn(); + const stderr: string[] = []; + const exitCode = await runCli( + ["node", "kibana-mcp-server", "setup", ...flags], + { + env: connectionEnv, + stdin: Readable.from(""), + stdout: () => {}, + stderr: (text) => stderr.push(text), + }, + { runBootstrapFn, runSetupFlowFn }, + ); + + expect(exitCode).toBe(1); + expect(runBootstrapFn).not.toHaveBeenCalled(); + expect(runSetupFlowFn).not.toHaveBeenCalled(); + expect(stderr.join("\n")).toContain(message); + expect(stderr.join("\n")).not.toContain(connectionEnv.KIBANA_PASSWORD); + }, + ); + + it("prompts only for missing connection values with setup options", async () => { + const runBootstrapFn = vi.fn().mockResolvedValue(bootstrapResult); const runSetupFlowFn = vi.fn(); - const stderr: string[] = []; + const stdout: string[] = []; const exitCode = await runCli( - ["node", "kibana-mcp-server", "setup", "--profile", "default"], - { env: connectionEnv, stdout: () => {}, stderr: (text) => stderr.push(text) }, + ["node", "kibana-mcp-server", "setup", "--url=https://override.example.com"], + { + env: {}, + stdin: Readable.from("elastic\nprompt-secret\n"), + stdinIsTTY: false, + stdout: (text) => stdout.push(text), + stderr: () => {}, + }, { runBootstrapFn, runSetupFlowFn }, ); - expect(exitCode).toBe(1); - expect(stderr.join("\n")).toContain("unknown option '--profile'"); - expect(runBootstrapFn).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); expect(runSetupFlowFn).not.toHaveBeenCalled(); + expect(runBootstrapFn).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://override.example.com", + username: "elastic", + password: "prompt-secret", + }), + ); + expect(stdout.slice(0, 2)).toEqual(["Kibana username:", "Kibana password:"]); + expect(stdout.join("\n")).not.toContain("prompt-secret"); + }); + + it("preserves guided setup cancellation with connection environment values", async () => { + const runBootstrapFn = vi.fn(); + const runSetupFlowFn = vi.fn().mockRejectedValue(new PromptCancelledError()); + const stderr: string[] = []; + expect( + await runCli( + ["node", "kibana-mcp-server", "setup"], + { + env: connectionEnv, + stdin: Readable.from(""), + stdinIsTTY: false, + stdout: () => {}, + stderr: (text) => stderr.push(text), + }, + { runBootstrapFn, runSetupFlowFn }, + ), + ).toBe(130); + expect(runSetupFlowFn).toHaveBeenCalledOnce(); + expect(runBootstrapFn).not.toHaveBeenCalled(); + expect(stderr).toEqual([]); }); it("reads a password from stdin without exposing it as a command argument", async () => { diff --git a/test/transports/helpers.ts b/test/transports/helpers.ts index 40f296b..a5ae98f 100644 --- a/test/transports/helpers.ts +++ b/test/transports/helpers.ts @@ -60,13 +60,34 @@ export async function callTool( headers: Record = {}, toolArguments: Record = {}, ): Promise<{ status: number; result?: Record }> { - const response = await sendRpc( - url, - { id: 1, method: "tools/call", params: { name, arguments: toolArguments } }, - headers, - ); - const payload = await response.json(); - return { status: response.status, result: payload.result }; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...headers, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: toolArguments }, + }), + }); + const text = await response.text(); + if (response.status !== 200) { + return { status: response.status }; + } + const payload = text.startsWith("{") + ? text + : (text + .split("\n") + .find((line) => line.startsWith("data:")) + ?.slice("data:".length) ?? "{}"); + return { + status: response.status, + result: (JSON.parse(payload) as { result?: Record }).result, + }; } export function sourceIds(result: Record | undefined): string[] { diff --git a/test/transports/http.test.ts b/test/transports/http.test.ts index f333265..dbbe75c 100644 --- a/test/transports/http.test.ts +++ b/test/transports/http.test.ts @@ -3,7 +3,10 @@ import { type ServerResponse, createServer, request as httpRequest } from "node: import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { PerRequestHTTPServerTransport } from "@modelcontextprotocol/server"; +import { + PerRequestHTTPServerTransport, + WebStandardStreamableHTTPServerTransport, +} from "@modelcontextprotocol/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { toProfileKey } from "../../src/profiles/names.js"; @@ -25,6 +28,21 @@ const queryMessage = { }, }; +async function callModernTool( + url: string, + name: string, + headers: Record = {}, + toolArguments: Record = {}, +): Promise<{ status: number; result?: Record }> { + const response = await sendRpc( + url, + { id: 1, method: "tools/call", params: { name, arguments: toolArguments } }, + headers, + ); + const payload = await response.json(); + return { status: response.status, result: payload.result }; +} + // Answers the two Kibana calls configure_index makes: field discovery, then a probe search. async function startFakeKibana( onSearch?: (response: ServerResponse) => void, @@ -62,7 +80,8 @@ async function startFakeKibana( }; } -describe("startHttpMcpServer", () => { +describe.each([false, true])("startHttpMcpServer (modern=%s)", (modern) => { + const callToolForProtocol = modern ? callModernTool : callTool; let directory: string; let handle: HttpServerHandle | undefined; let env: NodeJS.ProcessEnv; @@ -96,6 +115,25 @@ describe("startHttpMcpServer", () => { message: Record, headers: Record = {}, signal?: AbortSignal, + ) { + return fetch(handle?.url ?? "", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Authorization: "Bearer gateway-secret", + "X-Profile": "alice", + ...headers, + }, + body: JSON.stringify({ jsonrpc: "2.0", ...message }), + signal, + }); + } + + function postModernRpc( + message: Record, + headers: Record = {}, + signal?: AbortSignal, ) { return sendRpc( handle?.url ?? "", @@ -105,21 +143,39 @@ describe("startHttpMcpServer", () => { ); } + async function initialize(headers: Record = {}): Promise { + const response = await postRpc( + { + id: 0, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "http-test", version: "1.0.0" }, + }, + }, + headers, + ); + expect(response.status).toBe(200); + await response.text(); + return response.headers.get("mcp-session-id"); + } + it("serves each caller the catalog of the profile carried by the trusted header", async () => { await writeCatalog("alice", ["orders"]); await writeCatalog("bob@example.com", ["payments"]); handle = await startHttpMcpServer(env, { port: 0, profileHeader: "X-Profile" }); const authorization = { Authorization: "Bearer gateway-secret" }; - const alice = await callTool(handle.url, "discover", { + const alice = await callToolForProtocol(handle.url, "discover", { ...authorization, "X-Profile": "alice", }); - const bob = await callTool(handle.url, "discover", { + const bob = await callToolForProtocol(handle.url, "discover", { ...authorization, "X-Profile": "bob@example.com", }); - const newcomer = await callTool(handle.url, "discover", { + const newcomer = await callToolForProtocol(handle.url, "discover", { ...authorization, "X-Profile": "carol", }); @@ -136,7 +192,7 @@ describe("startHttpMcpServer", () => { const discover = async (profile: string) => sourceIds( ( - await callTool(handle?.url ?? "", "discover", { + await callToolForProtocol(handle?.url ?? "", "discover", { Authorization: "Bearer gateway-secret", "X-Profile": profile, }) @@ -152,17 +208,23 @@ describe("startHttpMcpServer", () => { it("rejects calls without the shared token or without the profile header", async () => { handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); - expect((await callTool(handle.url, "discover", { "X-Profile": "alice" })).status).toBe(401); + expect( + (await callToolForProtocol(handle.url, "discover", { "X-Profile": "alice" })).status, + ).toBe(401); expect( ( - await callTool(handle.url, "discover", { + await callToolForProtocol(handle.url, "discover", { Authorization: "Bearer wrong", "X-Profile": "alice", }) ).status, ).toBe(401); expect( - (await callTool(handle.url, "discover", { Authorization: "Bearer gateway-secret" })).status, + ( + await callToolForProtocol(handle.url, "discover", { + Authorization: "Bearer gateway-secret", + }) + ).status, ).toBe(401); }); @@ -185,9 +247,13 @@ describe("startHttpMcpServer", () => { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "X-Profile": "alice", - "MCP-Protocol-Version": PROTOCOL_VERSION, - "Mcp-Method": "tools/call", - "Mcp-Name": "discover", + ...(modern + ? { + "MCP-Protocol-Version": PROTOCOL_VERSION, + "Mcp-Method": "tools/call", + "Mcp-Name": "discover", + } + : {}), ...headers, }, }, @@ -202,7 +268,7 @@ describe("startHttpMcpServer", () => { jsonrpc: "2.0", id: 1, method: "tools/call", - params: { name: "discover", arguments: {}, _meta: requestMeta }, + params: { name: "discover", arguments: {}, ...(modern ? { _meta: requestMeta } : {}) }, }), ); }); @@ -271,7 +337,7 @@ describe("startHttpMcpServer", () => { }); expect(handle.url).toMatch(/^http:\/\/\[::1\]:\d+\/kibana$/); - const result = await callTool(handle.url, "discover", { + const result = await callToolForProtocol(handle.url, "discover", { Authorization: "bearer gateway-secret", }); expect(sourceIds(result.result)).toEqual(["orders"]); @@ -304,7 +370,7 @@ describe("startHttpMcpServer", () => { }); expect(oversized.status).toBe(413); - const longProfile = await callTool(handle.url, "discover", { + const longProfile = await callToolForProtocol(handle.url, "discover", { ...headers, "X-Profile": "a".repeat(300), }); @@ -324,7 +390,7 @@ describe("startHttpMcpServer", () => { ); const headers = { Authorization: "Bearer gateway-secret", "X-Profile": "alice" }; - const configured = await callTool(handle.url, "configure_index", headers, { + const configured = await callToolForProtocol(handle.url, "configure_index", headers, { index: "orders-*", }); expect(configured.result?.isError).toBeFalsy(); @@ -333,12 +399,13 @@ describe("startHttpMcpServer", () => { source_catalog_path: join(directory, "alice", "sources.json"), }); - expect(sourceIds((await callTool(handle.url, "discover", headers)).result)).toEqual([ - "orders", - ]); + expect( + sourceIds((await callToolForProtocol(handle.url, "discover", headers)).result), + ).toEqual(["orders"]); expect( sourceIds( - (await callTool(handle.url, "discover", { ...headers, "X-Profile": "bob" })).result, + (await callToolForProtocol(handle.url, "discover", { ...headers, "X-Profile": "bob" })) + .result, ), ).toEqual([]); } finally { @@ -346,297 +413,712 @@ describe("startHttpMcpServer", () => { } }); - it("rejects the old session flow without allocating sessions and serves modern requests", async () => { - await writeCatalog("alice", ["orders"]); - handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); - const headers = { - Authorization: "Bearer gateway-secret", - "X-Profile": "alice", - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", - }; - for (const message of [ - { - id: 0, - method: "initialize", - params: { - protocolVersion: "2025-06-18", - capabilities: {}, - clientInfo: { name: "http-test", version: "1.0.0" }, - }, - }, - { id: 1, method: "tools/call", params: { name: "discover", arguments: {} } }, - ]) { - const response = await fetch(handle.url, { - method: "POST", - headers, - body: JSON.stringify({ jsonrpc: "2.0", ...message }), - }); - expect(response.status).toBe(400); - expect(response.headers.get("mcp-session-id")).toBeNull(); - expect((await response.json()).error.code).toBe(-32022); - } - for (const method of ["GET", "DELETE"]) { - const response = await fetch(handle.url, { - method, - headers: { ...headers, "Mcp-Session-Id": "unknown" }, + if (!modern) { + it("reconnects a legacy GET stream after disconnect without closing its session", async () => { + const requests = vi.spyOn( + WebStandardStreamableHTTPServerTransport.prototype, + "handleRequest", + ); + const interval = vi.spyOn(globalThis, "setInterval"); + const clear = vi.spyOn(globalThis, "clearInterval"); + const controllers = [new AbortController(), new AbortController()]; + const timers: Array> = []; + try { + await writeCatalog("alice", ["orders"]); + handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); + const sessionId = await initialize(); + expect(sessionId).toBeTruthy(); + const transport = requests.mock.contexts[0] as WebStandardStreamableHTTPServerTransport; + const headers = { + Authorization: "Bearer gateway-secret", + "X-Profile": "alice", + "Mcp-Session-Id": sessionId ?? "", + Accept: "application/json, text/event-stream", + }; + for (const controller of controllers) { + const pending = fetch(handle.url, { headers, signal: controller.signal }); + await vi.waitFor(() => expect(requests.mock.lastCall?.[0].method).toBe("GET")); + // GET has no initial frame: use an actual SDK notification to flush the HTTP headers. + await transport.send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }); + const response = await pending; + expect(response.status).toBe(200); + const request = requests.mock.lastCall?.[0]; + timers.push(interval.mock.results.at(-1)?.value); + controller.abort(); + await vi.waitFor(() => expect(request?.signal.aborted).toBe(true)); + expect(sourceIds((await callTool(handle.url, "discover", headers)).result)).toEqual([ + "orders", + ]); + } + for (const timer of timers) expect(clear).toHaveBeenCalledWith(timer); + } finally { + for (const controller of controllers) controller.abort(); + requests.mockRestore(); + interval.mockRestore(); + clear.mockRestore(); + } + }); + + it("cancels a legacy body when its socket closes before stream attachment", async () => { + const original = WebStandardStreamableHTTPServerTransport.prototype.handleRequest; + const requests = vi.spyOn( + WebStandardStreamableHTTPServerTransport.prototype, + "handleRequest", + ); + const interval = vi.spyOn(globalThis, "setInterval"); + const clear = vi.spyOn(globalThis, "clearInterval"); + const controller = new AbortController(); + let resume: () => void = () => {}; + const paused = new Promise((resolve) => { + resume = resolve; }); - expect(response.status).toBe(405); - expect(response.headers.get("mcp-session-id")).toBeNull(); - await response.text(); - } - const listed = await postRpc({ id: 2, method: "tools/list" }); - expect(listed.status).toBe(200); - expect(listed.headers.get("mcp-session-id")).toBeNull(); - expect((await listed.json()).result.tools.map((tool: { name: string }) => tool.name)).toContain( - "discover", + let arrived = false; + try { + handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); + const sessionId = await initialize(); + const transport = requests.mock.contexts[0]; + const state = transport as unknown as { _streamMapping: Map }; + requests.mockImplementationOnce(async function ( + this: WebStandardStreamableHTTPServerTransport, + request, + options, + ) { + const response = await original.call(this, request, options); + arrived = true; + await paused; + return response; + }); + const pending = fetch(handle.url, { + headers: { + Authorization: "Bearer gateway-secret", + "X-Profile": "alice", + "Mcp-Session-Id": sessionId ?? "", + Accept: "text/event-stream", + }, + signal: controller.signal, + }).catch(() => undefined); + await vi.waitFor(() => expect(arrived).toBe(true)); + expect(state._streamMapping.has("_GET_stream")).toBe(true); + const request = requests.mock.lastCall?.[0]; + const timer = interval.mock.results.at(-1)?.value; + controller.abort(); + await pending; + await vi.waitFor(() => expect(request?.signal.aborted).toBe(true)); + resume(); + await vi.waitFor(() => expect(state._streamMapping.size).toBe(0)); + expect(clear).toHaveBeenCalledWith(timer); + const healthy = await postRpc( + { id: 1, method: "tools/list" }, + { "Mcp-Session-Id": sessionId ?? "" }, + ); + expect(healthy.status).toBe(200); + await healthy.text(); + } finally { + controller.abort(); + resume(); + requests.mockRestore(); + interval.mockRestore(); + clear.mockRestore(); + } + }); + + it.each(["cancelled", "completed"])( + "releases disconnected %s tool streams without disturbing another exchange", + async (outcome) => { + const requests = vi.spyOn( + WebStandardStreamableHTTPServerTransport.prototype, + "handleRequest", + ); + const interval = vi.spyOn(globalThis, "setInterval"); + const clear = vi.spyOn(globalThis, "clearInterval"); + const backends: ServerResponse[] = []; + const controllers: AbortController[] = []; + const kibana = await startFakeKibana((response) => backends.push(response)); + try { + await writeCatalog("alice", ["orders"]); + handle = await startHttpMcpServer( + { ...env, KIBANA_BASE_URL: kibana.url }, + { port: 0, profileHeader: "x-profile" }, + ); + const sessionId = await initialize(); + const headers = { "Mcp-Session-Id": sessionId ?? "" }; + const transport = requests.mock.contexts[0] as WebStandardStreamableHTTPServerTransport; + // Read the installed SDK's actual retained response streams, not a mocked transport. + const state = transport as unknown as { + _streamMapping: Map; + _requestToStreamMapping: Map; + _requestResponseMap: Map; + }; + const query = async (id: number) => { + const controller = new AbortController(); + controllers.push(controller); + const pending = postRpc( + { + id, + method: "tools/call", + params: { + name: "query", + arguments: { + source_ids: ["orders"], + start_time: "2026-04-02T12:00:00Z", + end_time: "2026-04-02T12:05:00Z", + mode: "count", + }, + }, + }, + headers, + controller.signal, + ); + await vi.waitFor(() => expect(backends).toHaveLength(controllers.length)); + const request = requests.mock.lastCall?.[0]; + const timer = interval.mock.results.at(-1)?.value; + await transport.send( + { + jsonrpc: "2.0", + method: "notifications/progress", + params: { progressToken: id, progress: 0 }, + }, + { relatedRequestId: id }, + ); + const response = await pending; + expect(response.status).toBe(200); + return { controller, request, timer, response }; + }; + const sibling = await query(100); + const siblingBody = sibling.response.text().catch(() => ""); + for (const id of [1, 2, 3]) { + const current = await query(id); + expect(state._streamMapping.size).toBe(2); + if (outcome === "cancelled") { + expect( + ( + await postRpc( + { method: "notifications/cancelled", params: { requestId: id } }, + headers, + ) + ).status, + ).toBe(202); + await vi.waitFor(() => expect(backends[id].destroyed).toBe(true)); + } + current.controller.abort(); + await vi.waitFor(() => expect(current.request?.signal.aborted).toBe(true)); + await vi.waitFor(() => expect(state._streamMapping.size).toBe(1)); + expect(clear).toHaveBeenCalledWith(current.timer); + expect(clear).not.toHaveBeenCalledWith(sibling.timer); + expect(backends[0].destroyed).toBe(false); + if (outcome === "completed") { + backends[id].end(JSON.stringify({ rawResponse: { hits: { total: { value: 0 } } } })); + await vi.waitFor(() => expect(state._requestToStreamMapping.has(id)).toBe(false)); + } + } + backends[0].end(JSON.stringify({ rawResponse: { hits: { total: { value: 0 } } } })); + expect(await siblingBody).toContain('"id":100'); + await vi.waitFor(() => expect(state._streamMapping.size).toBe(0)); + expect(clear).toHaveBeenCalledWith(sibling.timer); + // The SDK suppresses final responses for cancelled handlers, retaining request-ID + // metadata; this adapter only owns response streams and their keepalives. + if (outcome === "completed") expect(state._requestToStreamMapping.size).toBe(0); + expect(state._requestResponseMap.size).toBe(0); + } finally { + for (const controller of controllers) controller.abort(); + for (const backend of backends) backend.destroy(); + await kibana.close(); + requests.mockRestore(); + interval.mockRestore(); + clear.mockRestore(); + } + }, ); - expect(sourceIds((await callTool(handle.url, "discover", headers)).result)).toEqual(["orders"]); - }); - it("cancels backend work when a modern request disconnects before a response", async () => { - let backend: ServerResponse | undefined; - const kibana = await startFakeKibana((response) => { - backend = response; + it("routes separate legacy cancellation POSTs to the initialized client's backend work", async () => { + let backend: ServerResponse | undefined; + let cancelled = false; + const kibana = await startFakeKibana((response) => { + backend = response; + response.on("close", () => { + cancelled = true; + }); + }); + const controller = new AbortController(); + let query: Promise | undefined; + try { + await writeCatalog("alice", ["orders"]); + handle = await startHttpMcpServer( + { ...env, KIBANA_BASE_URL: kibana.url }, + { port: 0, profileHeader: "x-profile" }, + ); + const sessionId = await initialize(); + const headers: Record = sessionId ? { "Mcp-Session-Id": sessionId } : {}; + query = postRpc( + { + id: 1, + method: "tools/call", + params: { + name: "query", + arguments: { + source_ids: ["orders"], + start_time: "2026-04-02T12:00:00Z", + end_time: "2026-04-02T12:05:00Z", + mode: "count", + }, + }, + }, + headers, + controller.signal, + ); + await vi.waitFor(() => expect(backend).toBeDefined()); + expect( + ( + await postRpc( + { + method: "notifications/cancelled", + params: { requestId: 1 }, + }, + headers, + ) + ).status, + ).toBe(202); + await vi.waitFor(() => expect(cancelled).toBe(true)); + expect(sessionId).toBeTruthy(); + } finally { + controller.abort(); + await query?.catch(() => undefined); + backend?.destroy(); + await kibana.close(); + } }); - const controller = new AbortController(); - let pending: Promise | undefined; - try { - await writeCatalog("alice", ["orders"]); - handle = await startHttpMcpServer( - { ...env, KIBANA_BASE_URL: kibana.url }, - { port: 0, profileHeader: "x-profile" }, - ); - pending = postRpc(queryMessage, {}, controller.signal).catch(() => undefined); - await vi.waitFor(() => expect(backend).toBeDefined()); - controller.abort(); - await pending; - await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); - expect((await postRpc({ id: 2, method: "tools/list" })).status).toBe(200); - } finally { - controller.abort(); - await pending; - backend?.destroy(); - await kibana.close(); - } - }); - it("cleans up a modern SSE response disconnected before stream attachment", async () => { - const original = PerRequestHTTPServerTransport.prototype.handleMessage; - const requests = vi.spyOn(PerRequestHTTPServerTransport.prototype, "handleMessage"); - const interval = vi.spyOn(globalThis, "setInterval"); - const clear = vi.spyOn(globalThis, "clearInterval"); - const controller = new AbortController(); - let resume: () => void = () => {}; - const paused = new Promise((resolve) => { - resume = resolve; - }); - let arrived = false; - let backend: ServerResponse | undefined; - const kibana = await startFakeKibana((response) => { - backend = response; + it("isolates identical request IDs between legacy sessions and rejects profile changes", async () => { + const backends: ServerResponse[] = []; + const kibana = await startFakeKibana((response) => { + backends.push(response); + }); + const controller = new AbortController(); + const queries: Array> = []; + try { + await writeCatalog("alice", ["orders"]); + handle = await startHttpMcpServer( + { ...env, KIBANA_BASE_URL: kibana.url }, + { port: 0, profileHeader: "x-profile" }, + ); + const first = await initialize(); + const second = await initialize(); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(first).not.toBe(second); + for (const sessionId of [first, second]) { + queries.push( + postRpc( + { + id: 1, + method: "tools/call", + params: { + name: "query", + arguments: { + source_ids: ["orders"], + start_time: "2026-04-02T12:00:00Z", + end_time: "2026-04-02T12:05:00Z", + mode: "count", + }, + }, + }, + { "Mcp-Session-Id": sessionId ?? "" }, + controller.signal, + ), + ); + await vi.waitFor(() => expect(backends).toHaveLength(queries.length)); + } + const cancellation = { method: "notifications/cancelled", params: { requestId: 1 } }; + expect((await postRpc(cancellation, { "Mcp-Session-Id": "unknown" })).status).toBe(404); + expect( + ( + await postRpc(cancellation, { + "Mcp-Session-Id": first ?? "", + "X-Profile": "bob", + }) + ).status, + ).toBe(404); + expect( + ( + await postRpc(cancellation, { + "Mcp-Session-Id": first ?? "", + Authorization: "Bearer wrong", + }) + ).status, + ).toBe(401); + // No session cannot claim an initialized client's request, even under the same profile. + expect((await postRpc(cancellation)).status).toBe(202); + expect(backends.map((response) => response.destroyed)).toEqual([false, false]); + expect((await postRpc(cancellation, { "Mcp-Session-Id": second ?? "" })).status).toBe(202); + await vi.waitFor(() => expect(backends[1].destroyed).toBe(true)); + expect(backends[0].destroyed).toBe(false); + expect((await postRpc(cancellation, { "Mcp-Session-Id": first ?? "" })).status).toBe(202); + await vi.waitFor(() => expect(backends[0].destroyed).toBe(true)); + } finally { + controller.abort(); + await Promise.all(queries.map((query) => query.catch(() => undefined))); + for (const response of backends) response.destroy(); + await kibana.close(); + } }); - let pending: Promise | undefined; - try { + + it("deletes legacy sessions and keeps authentication required on each session request", async () => { await writeCatalog("alice", ["orders"]); - handle = await startHttpMcpServer( - { ...env, KIBANA_BASE_URL: kibana.url }, - { port: 0, profileHeader: "x-profile" }, - ); - requests.mockImplementationOnce(async function ( - this: PerRequestHTTPServerTransport, - message, - extra, - ) { - const response = await original.call(this, message, extra); - arrived = true; - await paused; - return response; + handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); + const sessionId = await initialize(); + expect(sessionId).toBeTruthy(); + const headers = { + Authorization: "Bearer gateway-secret", + "X-Profile": "alice", + "Mcp-Session-Id": sessionId ?? "", + }; + expect(sourceIds((await callTool(handle.url, "discover", headers)).result)).toEqual([ + "orders", + ]); + expect( + ( + await fetch(handle.url, { + method: "DELETE", + headers: { + ...headers, + Authorization: "Bearer wrong", + }, + }) + ).status, + ).toBe(401); + expect((await fetch(handle.url, { method: "DELETE", headers })).status).toBe(200); + expect((await callTool(handle.url, "discover", headers)).status).toBe(404); + expect( + sourceIds( + ( + await callTool(handle.url, "discover", { + Authorization: "Bearer gateway-secret", + "X-Profile": "alice", + }) + ).result, + ), + ).toEqual(["orders"]); + }); + + it("expires idle legacy sessions without expiring recently used sessions", async () => { + handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); + const expired = await initialize(); + const current = await initialize(); + const headers = (id: string | null) => ({ + Authorization: "Bearer gateway-secret", + "X-Profile": "alice", + "Mcp-Session-Id": id ?? "", }); - pending = postRpc(queryMessage, {}, controller.signal).catch(() => undefined); - await vi.waitFor(() => expect(backend).toBeDefined()); - const transport = requests.mock.contexts[0] as PerRequestHTTPServerTransport; - await transport.send( - { - jsonrpc: "2.0", - method: "notifications/progress", - params: { progressToken: 1, progress: 0 }, - }, - { relatedRequestId: 1 }, + const now = Date.now(); + const clock = vi.spyOn(Date, "now"); + try { + clock.mockReturnValue(now + 20 * 60 * 1000); + expect((await callTool(handle.url, "discover", headers(current))).status).toBe(200); + clock.mockReturnValue(now + 31 * 60 * 1000); + expect((await callTool(handle.url, "discover", headers(expired))).status).toBe(404); + expect((await callTool(handle.url, "discover", headers(current))).status).toBe(200); + expect(await initialize()).toBeTruthy(); + } finally { + clock.mockRestore(); + } + }); + + it("bounds concurrent legacy sessions and releases capacity on DELETE", async () => { + handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); + const responses = await Promise.all( + Array.from({ length: 129 }, () => + postRpc({ + id: 0, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "capacity-test", version: "1.0.0" }, + }, + }), + ), ); - await vi.waitFor(() => expect(arrived).toBe(true)); - const timer = interval.mock.results.at(-1)?.value; - expect(timer).toBeDefined(); - const request = requests.mock.lastCall?.[1]?.request; - controller.abort(); - await pending; - await vi.waitFor(() => expect(request?.signal.aborted).toBe(true)); - await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); - resume(); - await vi.waitFor(() => expect(clear).toHaveBeenCalledWith(timer)); - } finally { - controller.abort(); - resume(); - await pending; - backend?.destroy(); - await kibana.close(); - requests.mockRestore(); - interval.mockRestore(); - clear.mockRestore(); - } - }); + await Promise.all(responses.map((response) => response.text())); + expect(responses.filter((response) => response.status === 200)).toHaveLength(128); + expect(responses.filter((response) => response.status === 503)).toHaveLength(1); + const sessionId = responses + .find((response) => response.status === 200) + ?.headers.get("mcp-session-id"); + expect( + ( + await fetch(handle.url, { + method: "DELETE", + headers: { + Authorization: "Bearer gateway-secret", + "X-Profile": "alice", + "Mcp-Session-Id": sessionId ?? "", + }, + }) + ).status, + ).toBe(200); + expect(await initialize()).toBeTruthy(); + }); + + it("retires long-lived sessions to bound SDK cancellation metadata", async () => { + handle = await startHttpMcpServer(env, { port: 0, profileHeader: "x-profile" }); + const sessionId = await initialize(); + const headers = { "Mcp-Session-Id": sessionId ?? "" }; + for (let count = 0; count < 1024; count += 1) { + const response = await postRpc({ method: "notifications/initialized" }, headers); + expect(response.status).toBe(202); + await response.text(); + } + expect((await postRpc({ id: 1, method: "tools/list" }, headers)).status).toBe(404); + expect(await initialize()).toBeTruthy(); + }, 20_000); + } - it.each(["cancelled", "completed"])( - "releases %s modern streams without disturbing identical request IDs in another profile", - async (outcome) => { + if (modern) { + it("cancels backend work when a modern request disconnects before a response", async () => { + let backend: ServerResponse | undefined; + const kibana = await startFakeKibana((response) => { + backend = response; + }); + const controller = new AbortController(); + let pending: Promise | undefined; + try { + await writeCatalog("alice", ["orders"]); + handle = await startHttpMcpServer( + { ...env, KIBANA_BASE_URL: kibana.url }, + { port: 0, profileHeader: "x-profile" }, + ); + pending = postModernRpc(queryMessage, {}, controller.signal).catch(() => undefined); + await vi.waitFor(() => expect(backend).toBeDefined()); + controller.abort(); + await pending; + await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); + expect((await postModernRpc({ id: 2, method: "tools/list" })).status).toBe(200); + } finally { + controller.abort(); + await pending; + backend?.destroy(); + await kibana.close(); + } + }); + + it("cleans up a modern SSE response disconnected before stream attachment", async () => { + const original = PerRequestHTTPServerTransport.prototype.handleMessage; const requests = vi.spyOn(PerRequestHTTPServerTransport.prototype, "handleMessage"); const interval = vi.spyOn(globalThis, "setInterval"); const clear = vi.spyOn(globalThis, "clearInterval"); - const backends: ServerResponse[] = []; - const controllers: AbortController[] = []; - const kibana = await startFakeKibana((response) => backends.push(response)); + const controller = new AbortController(); + let resume: () => void = () => {}; + const paused = new Promise((resolve) => { + resume = resolve; + }); + let arrived = false; + let backend: ServerResponse | undefined; + const kibana = await startFakeKibana((response) => { + backend = response; + }); + let pending: Promise | undefined; try { await writeCatalog("alice", ["orders"]); - await writeCatalog("bob", ["payments"]); handle = await startHttpMcpServer( { ...env, KIBANA_BASE_URL: kibana.url }, { port: 0, profileHeader: "x-profile" }, ); - const query = async (profile: string, sourceId: string) => { - const controller = new AbortController(); - controllers.push(controller); - const pending = postRpc( - { - ...queryMessage, - params: { - ...queryMessage.params, - arguments: { ...queryMessage.params.arguments, source_ids: [sourceId] }, - }, - }, - { "X-Profile": profile }, - controller.signal, - ); - await vi.waitFor(() => expect(backends).toHaveLength(controllers.length)); - const transport = requests.mock.contexts.at(-1) as PerRequestHTTPServerTransport; - const request = requests.mock.lastCall?.[1]?.request; - await transport.send( - { - jsonrpc: "2.0", - method: "notifications/progress", - params: { progressToken: 1, progress: 0 }, - }, - { relatedRequestId: 1 }, - ); - const timer = interval.mock.results.at(-1)?.value; - expect(timer).toBeDefined(); - const response = await pending; - expect(response.status).toBe(200); - return { controller, request, timer, response }; - }; - const sibling = await query("bob", "payments"); - const siblingBody = sibling.response.text().catch(() => ""); - for (let count = 0; count < 3; count += 1) { - const current = await query("alice", "orders"); - const body = current.response.text().catch(() => ""); - if (outcome === "completed") { - backends - .at(-1) - ?.end(JSON.stringify({ rawResponse: { hits: { total: { value: 0 } } } })); - expect(await body).toContain('"id":1'); - } else { - // A separate notification cannot cancel another stateless exchange. - const cancellation = await postRpc({ - method: "notifications/cancelled", - params: { requestId: 1 }, - }); - await cancellation.text(); - expect(backends.at(-1)?.destroyed).toBe(false); - current.controller.abort(); - await body; - await vi.waitFor(() => expect(backends.at(-1)?.destroyed).toBe(true)); - } - await vi.waitFor(() => expect(current.request?.signal.aborted).toBe(true)); - expect(clear).toHaveBeenCalledWith(current.timer); - expect(clear).not.toHaveBeenCalledWith(sibling.timer); - expect(backends[0].destroyed).toBe(false); - } - backends[0].end(JSON.stringify({ rawResponse: { hits: { total: { value: 0 } } } })); - expect(await siblingBody).toContain('"id":1'); - await vi.waitFor(() => expect(clear).toHaveBeenCalledWith(sibling.timer)); - expect( - sourceIds( - ( - await callTool(handle.url, "discover", { - Authorization: "Bearer gateway-secret", - "X-Profile": "alice", - }) - ).result, - ), - ).toEqual(["orders"]); + requests.mockImplementationOnce(async function ( + this: PerRequestHTTPServerTransport, + message, + extra, + ) { + const response = await original.call(this, message, extra); + arrived = true; + await paused; + return response; + }); + pending = postModernRpc(queryMessage, {}, controller.signal).catch(() => undefined); + await vi.waitFor(() => expect(backend).toBeDefined()); + const transport = requests.mock.contexts[0] as PerRequestHTTPServerTransport; + await transport.send( + { + jsonrpc: "2.0", + method: "notifications/progress", + params: { progressToken: 1, progress: 0 }, + }, + { relatedRequestId: 1 }, + ); + await vi.waitFor(() => expect(arrived).toBe(true)); + const timer = interval.mock.results.at(-1)?.value; + expect(timer).toBeDefined(); + const request = requests.mock.lastCall?.[1]?.request; + controller.abort(); + await pending; + await vi.waitFor(() => expect(request?.signal.aborted).toBe(true)); + await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); + resume(); + await vi.waitFor(() => expect(clear).toHaveBeenCalledWith(timer)); } finally { - for (const controller of controllers) controller.abort(); - for (const backend of backends) backend.destroy(); + controller.abort(); + resume(); + await pending; + backend?.destroy(); await kibana.close(); requests.mockRestore(); interval.mockRestore(); clear.mockRestore(); } - }, - ); - - it("keeps backend deadlines on modern tool calls", async () => { - let backend: ServerResponse | undefined; - const kibana = await startFakeKibana((response) => { - backend = response; }); - try { - await writeCatalog("alice", ["orders"]); - handle = await startHttpMcpServer( - { ...env, KIBANA_BASE_URL: kibana.url, KIBANA_TIMEOUT_MS: "100" }, - { port: 0, profileHeader: "x-profile" }, - ); - const response = await postRpc(queryMessage); - expect(response.status).toBe(200); - expect((await response.json()).result).toMatchObject({ - isError: true, - _meta: { kibana_request_error: { code: "KIBANA_TIMEOUT", timeout_ms: 100 } }, + + it.each(["cancelled", "completed"])( + "releases %s modern streams without disturbing identical request IDs in another profile", + async (outcome) => { + const requests = vi.spyOn(PerRequestHTTPServerTransport.prototype, "handleMessage"); + const interval = vi.spyOn(globalThis, "setInterval"); + const clear = vi.spyOn(globalThis, "clearInterval"); + const backends: ServerResponse[] = []; + const controllers: AbortController[] = []; + const kibana = await startFakeKibana((response) => backends.push(response)); + try { + await writeCatalog("alice", ["orders"]); + await writeCatalog("bob", ["payments"]); + handle = await startHttpMcpServer( + { ...env, KIBANA_BASE_URL: kibana.url }, + { port: 0, profileHeader: "x-profile" }, + ); + const query = async (profile: string, sourceId: string) => { + const controller = new AbortController(); + controllers.push(controller); + const pending = postModernRpc( + { + ...queryMessage, + params: { + ...queryMessage.params, + arguments: { ...queryMessage.params.arguments, source_ids: [sourceId] }, + }, + }, + { "X-Profile": profile }, + controller.signal, + ); + await vi.waitFor(() => expect(backends).toHaveLength(controllers.length)); + const transport = requests.mock.contexts.at(-1) as PerRequestHTTPServerTransport; + const request = requests.mock.lastCall?.[1]?.request; + await transport.send( + { + jsonrpc: "2.0", + method: "notifications/progress", + params: { progressToken: 1, progress: 0 }, + }, + { relatedRequestId: 1 }, + ); + const timer = interval.mock.results.at(-1)?.value; + expect(timer).toBeDefined(); + const response = await pending; + expect(response.status).toBe(200); + return { controller, request, timer, response }; + }; + const sibling = await query("bob", "payments"); + const siblingBody = sibling.response.text().catch(() => ""); + for (let count = 0; count < 3; count += 1) { + const current = await query("alice", "orders"); + const body = current.response.text().catch(() => ""); + if (outcome === "completed") { + backends + .at(-1) + ?.end(JSON.stringify({ rawResponse: { hits: { total: { value: 0 } } } })); + expect(await body).toContain('"id":1'); + } else { + // A separate notification cannot cancel another stateless exchange. + const cancellation = await postModernRpc({ + method: "notifications/cancelled", + params: { requestId: 1 }, + }); + await cancellation.text(); + expect(backends.at(-1)?.destroyed).toBe(false); + current.controller.abort(); + await body; + await vi.waitFor(() => expect(backends.at(-1)?.destroyed).toBe(true)); + } + await vi.waitFor(() => expect(current.request?.signal.aborted).toBe(true)); + expect(clear).toHaveBeenCalledWith(current.timer); + expect(clear).not.toHaveBeenCalledWith(sibling.timer); + expect(backends[0].destroyed).toBe(false); + } + backends[0].end(JSON.stringify({ rawResponse: { hits: { total: { value: 0 } } } })); + expect(await siblingBody).toContain('"id":1'); + await vi.waitFor(() => expect(clear).toHaveBeenCalledWith(sibling.timer)); + expect( + sourceIds( + ( + await callModernTool(handle.url, "discover", { + Authorization: "Bearer gateway-secret", + "X-Profile": "alice", + }) + ).result, + ), + ).toEqual(["orders"]); + } finally { + for (const controller of controllers) controller.abort(); + for (const backend of backends) backend.destroy(); + await kibana.close(); + requests.mockRestore(); + interval.mockRestore(); + clear.mockRestore(); + } + }, + ); + + it("keeps backend deadlines on modern tool calls", async () => { + let backend: ServerResponse | undefined; + const kibana = await startFakeKibana((response) => { + backend = response; }); - await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); - } finally { - backend?.destroy(); - await kibana.close(); - } - }); + try { + await writeCatalog("alice", ["orders"]); + handle = await startHttpMcpServer( + { ...env, KIBANA_BASE_URL: kibana.url, KIBANA_TIMEOUT_MS: "100" }, + { port: 0, profileHeader: "x-profile" }, + ); + const response = await postModernRpc(queryMessage); + expect(response.status).toBe(200); + expect((await response.json()).result).toMatchObject({ + isError: true, + _meta: { kibana_request_error: { code: "KIBANA_TIMEOUT", timeout_ms: 100 } }, + }); + await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); + } finally { + backend?.destroy(); + await kibana.close(); + } + }); - it("cancels active modern backend work on server close", async () => { - let backend: ServerResponse | undefined; - const kibana = await startFakeKibana((response) => { - backend = response; + it("cancels active modern backend work on server close", async () => { + let backend: ServerResponse | undefined; + const kibana = await startFakeKibana((response) => { + backend = response; + }); + const controller = new AbortController(); + let pending: Promise | undefined; + try { + await writeCatalog("alice", ["orders"]); + handle = await startHttpMcpServer( + { ...env, KIBANA_BASE_URL: kibana.url }, + { port: 0, profileHeader: "x-profile" }, + ); + pending = postModernRpc(queryMessage, {}, controller.signal).catch(() => undefined); + await vi.waitFor(() => expect(backend).toBeDefined()); + await handle.close(); + handle = undefined; + await pending; + await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); + } finally { + controller.abort(); + await pending; + backend?.destroy(); + await kibana.close(); + } }); - const controller = new AbortController(); - let pending: Promise | undefined; - try { - await writeCatalog("alice", ["orders"]); - handle = await startHttpMcpServer( - { ...env, KIBANA_BASE_URL: kibana.url }, - { port: 0, profileHeader: "x-profile" }, - ); - pending = postRpc(queryMessage, {}, controller.signal).catch(() => undefined); - await vi.waitFor(() => expect(backend).toBeDefined()); - await handle.close(); - handle = undefined; - await pending; - await vi.waitFor(() => expect(backend?.destroyed).toBe(true)); - } finally { - controller.abort(); - await pending; - backend?.destroy(); - await kibana.close(); - } - }); + } }); -describe("configuration file", () => { +describe.each([false, true])("configuration file (modern=%s)", (modern) => { + const callToolForProtocol = modern ? callModernTool : callTool; let directory: string; let handle: HttpServerHandle | undefined; @@ -681,16 +1163,20 @@ describe("configuration file", () => { handle = await startHttpMcpServer({ KIBANA_MCP_CONFIG: configPath }); expect( - sourceIds((await callTool(handle.url, "discover", { "X-Profile": "alice" })).result), + sourceIds( + (await callToolForProtocol(handle.url, "discover", { "X-Profile": "alice" })).result, + ), ).toEqual([]); // Another process sharing the store adds a source: the next call sees it. state().catalogs.set("team:alice", JSON.stringify(createGeneratedCatalog([source("orders")]))); expect( - sourceIds((await callTool(handle.url, "discover", { "X-Profile": "alice" })).result), + sourceIds( + (await callToolForProtocol(handle.url, "discover", { "X-Profile": "alice" })).result, + ), ).toEqual(["orders"]); expect( - sourceIds((await callTool(handle.url, "discover", { "X-Profile": "bob" })).result), + sourceIds((await callToolForProtocol(handle.url, "discover", { "X-Profile": "bob" })).result), ).toEqual([]); await handle.close(); diff --git a/test/transports/stdio.test.ts b/test/transports/stdio.test.ts index 7e8a3e5..9246335 100644 --- a/test/transports/stdio.test.ts +++ b/test/transports/stdio.test.ts @@ -91,11 +91,13 @@ describe("startMcpServer", () => { transports.startError = undefined; transports.instances.length = 0; directory = await mkdtemp(join(tmpdir(), "kibana-mcp-stdio-")); + vi.stubEnv("KIBANA_STATE_DIR", directory); }); afterEach(async () => { for (const transport of transports.instances) transport.onclose?.(); await new Promise((resolveDone) => setImmediate(resolveDone)); + vi.unstubAllEnvs(); await rm(directory, { recursive: true, force: true }); }); diff --git a/website/deployment/http.md b/website/deployment/http.md index 1a6d68d..e4e3ff1 100644 --- a/website/deployment/http.md +++ b/website/deployment/http.md @@ -39,4 +39,6 @@ The profile header is trusted as sent: the server does not authenticate end user Without a token, MCP requests must also have a loopback `Host` and, when present, a loopback `Origin`. Authenticated gateways may forward their own host and origin headers. -HTTP tool calls use the stateless protocol revision `2026-07-28`: send the current protocol's per-request `_meta` envelope and its `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` headers as required by the SDK; no initialization handshake or `Mcp-Session-Id` is needed. The server does not allocate client sessions or provide session-based GET streams, DELETE, or cross-request cancellation. Disconnecting an in-flight request cancels its backend work; backend deadlines still bound every tool call. Authentication and profile selection are checked on every request. +Legacy clients that send `initialize` receive an `Mcp-Session-Id` and must echo it on subsequent requests, including cancellation notifications and `DELETE` when finished. Sessions are bound to the original profile, limited to 128 per process, and expire after 30 minutes without a request (reclaimed on the next legacy request). They also retire after 1,024 requests to bound cancellation metadata retained by the SDK; retirement closes any remaining exchanges in that session. A full session registry returns `503`; an expired, exhausted, unknown, or mismatched session returns `404` and requires reinitialization. Modern requests and handshake-free legacy calls remain supported, but cross-request cancellation requires a session. + +The `2025-06-18` initialization flow supports session-based GET/SSE streams and authenticated DELETE. Disconnecting a GET stream releases that stream without deleting its session. Modern `2026-07-28` tool calls remain stateless: send the per-request `_meta` envelope and `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` headers as required by the SDK. Disconnecting an in-flight modern request cancels its backend work; backend deadlines still bound tool calls. Authentication and profile selection are checked on every request. diff --git a/website/reference/cli.md b/website/reference/cli.md index b9a6daa..44a16e7 100644 --- a/website/reference/cli.md +++ b/website/reference/cli.md @@ -46,4 +46,12 @@ Verifies the Kibana connection, creates an empty managed catalog, saves the cred ## `setup` -Runs guided setup for explicitly selected hand-authored source catalogs. This is a separate command, not an alias for `bootstrap`, and accepts no bootstrap flags. +Without connection options, runs guided setup for explicitly selected hand-authored source catalogs, even when connection environment variables are set. + +With any explicitly supplied option from the `bootstrap` table above, `setup` uses the same connection-only bootstrap flow and options. It prompts only for missing connection values and never inspects or configures indexes. Flags override environment fallbacks; passwords are accepted only through stdin, an environment variable, or a secret prompt, never a password argument. `--password-stdin` and `--password-env` are mutually exclusive, and `--timeout` must be an integer from 1 to 120000 milliseconds. + +```bash +npx -y @havesomecode/kibana-mcp-server setup --profile prod --client none +``` + +Bare `bootstrap` always remains connection-only; only bare `setup` starts guided catalog setup. diff --git a/website/reference/source-catalog.md b/website/reference/source-catalog.md index 3a7d7ba..6620992 100644 --- a/website/reference/source-catalog.md +++ b/website/reference/source-catalog.md @@ -46,7 +46,7 @@ Supported schema backend kinds: Deployment note: - some Kibana deployments expose search transport but not field-metadata endpoints -- when direct schema endpoints such as `/api/data_views/fields_for_wildcard`, `/api/index_patterns/_fields_for_wildcard`, or `//_field_caps` return `404`, the server falls back to sampling fields through the configured search backend +- when all known metadata endpoints for a Kibana data-view or index-pattern schema backend return `404`, the server falls back to sampling fields through the configured search backend; direct `elasticsearch_field_caps` failures are returned without that fallback - this fallback is heuristic rather than authoritative, but it is often sufficient to recover `.keyword` promotion and object-path discovery in search-only environments Example source shape for an application log stream: From 3b5466085e399dd20dd515d9449c8a771e757fa2 Mon Sep 17 00:00:00 2001 From: Zacaria Chtatar <5860751+Zacaria@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:01:50 +0000 Subject: [PATCH 3/3] docs: record configure retirement decision Record the owner-approved retirement of MCP-only connection setup in favor of bootstrap and configure_index. Existing catalog schemas and files are preserved. Runtime, tests, packaging and schemas remain byte-identical to the independently reviewed correction. Full verification passed 371 tests and all local gates; final documentation-only review approved the exact tree. --- docs/project/support-policy.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/project/support-policy.md b/docs/project/support-policy.md index 40b5dca..2bff95d 100644 --- a/docs/project/support-policy.md +++ b/docs/project/support-policy.md @@ -1,7 +1,7 @@ --- title: Support Policy status: active -updated: 2026-04-09 +updated: 2026-10-14 --- # Support Policy @@ -20,6 +20,10 @@ This MCP is designed for operators and AI agents that need read-only access to K - Startup with a saved profile, or a complete explicit connection plus `KIBANA_SOURCE_CATALOG_PATH` or a configuration-file catalog store - Explicit user-approved source configuration through `configure_index`; no MCP tool accepts connection credentials +## Connection Setup Boundary + +Connection setup is an operator step, not an MCP capability. The credential-taking `configure` tool and unconfigured startup are intentionally retired in favor of `bootstrap` plus `configure_index`. Direct environment startup must select a catalog path or store explicitly; the server no longer searches the working directory for one. This does not change the catalog JSON schema or delete existing catalog or profile files. + ## Best-Effort Support - Fully autonomous repo-local plugin installation by prompting alone across all Codex model variants