Compare commits

...

47 Commits

Author SHA1 Message Date
6dcc5ce412 Merge pull request 'chore: bump version 0.3.1 → 0.3.2' (#112) from chore/bump-v0.3.2 into main
Reviewed-on: #112
2026-05-07 10:01:08 +00:00
th-kim0823
751377cae8 chore: bump version 0.3.1 → 0.3.2
fb-31 single-file/stdin ingest + MCP ingest tools + docs/mcp-usage.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:59:03 +09:00
4922f9fc64 Merge pull request 'feat(fb-31): single-file / stdin ingest — agent on-demand 저장' (#111) from feat/p9-fb-31-single-file-stdin-ingest into main
Reviewed-on: #111
2026-05-07 09:56:09 +00:00
th-kim0823
47bfd518c8 📝 docs: comprehensive MCP usage guide (fb-31)
신규 docs/mcp-usage.md (~280 line) — agent integration 의 종합 가이드:

- Quick start + `--config` thread 예시
- Host config 예시 (Claude Code / Cursor / OpenAI Agents / Copilot CLI)
- 6 tool catalog (search / ask / schema / doctor / ingest_file / ingest_stdin)
  각 tool 의 input shape, defaults, output 예시, "언제 사용", mutation
  주의사항.
- Troubleshooting — error.v1 의 7 code 별 조치 표 + grounded:false +
  doctor !ok + empty search + tool-not-found 시나리오.
- Multi-turn ask + session 관리 — session_id 명명, 새 session 시작
  시점, lifetime, single-shot vs session 비교.
- Performance / Security 절.

README.md 의 기존 MCP 절은 quick start 만 유지하고 docs/mcp-usage.md
링크. integrations/claude-code/kebab/SKILL.md 도 동일 cross-link.

agent 사용자 도그푸딩 후속 의견 — host-agnostic 가이드 + 명시적
troubleshooting 표 + multi-turn session 명명 컨벤션 부재 해소.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:53:59 +09:00
th-kim0823
7f5739d8fb 🏗️ refactor(fb-31): apply round 1 review nits
- ingest_file_with_config: lowercase normalize ext (caller-side) +
  early error on unsupported extension (`.docx` etc. now Err with
  helpful message instead of silent skipped_by_extension counter).
  New test ingest_file_errors_on_unsupported_extension.
- ingest_stdin_with_config: doc comment explaining intentional
  double-call of ensure helpers (idempotent + ~ms negligible).
- external::inject_frontmatter: simplify precheck via single
  trim_start binding + add CR-only line ending edge case.
- external::inject_frontmatter: doc note on yaml_quote escape
  contract (agent-supplied titles with special chars are safe).

Round 1 review summary: #111 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:46:55 +09:00
th-kim0823
dc24cb34b1 🚑 fix(fb-31): apply final review nits
- kebab-app: add #[doc(hidden)] to ingest_stdin_with_config (CLAUDE.md
  convention — all *_with_config functions should have this attribute;
  fb-31's first impl missed it on the second facade fn).
- SKILL.md: "Since v0.4.0" → "Since v0.3.1" (MCP shipped in fb-30
  release v0.3.1; the wrong version claim was introduced in fb-30 doc
  sync and carried forward into fb-31).
- tools_call_ingest_file: add idempotency test (second call with same
  content → unchanged=1, new=0). Spec called for two tests; first impl
  shipped only the happy path.

Version bump 0.3.1 → 0.3.2 deferred to separate `chore/bump-v0.3.2` PR
mirroring fb-27 + fb-30 precedent (commits 73f5d73 / 5495d96).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:38:19 +09:00
th-kim0823
ccee30037d 🧪 test(kebab-cli): update cli_mcp_smoke tools/list assertion 4 → 6 (fb-31)
fb-31 added ingest_file + ingest_stdin MCP tools (Task 9) but the
spawn-based smoke test in cli_mcp_smoke.rs still asserted the fb-30
count of 4. Bump to 6 to match the live tools/list response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:26:51 +09:00
th-kim0823
e041173e8e 📝 docs(tasks): HOTFIXES entry + p9-fb-31 status → completed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:18:50 +09:00
th-kim0823
345a4f363a 📝 docs: sync README / HANDOFF / CLAUDE / skill / design for fb-31
- README 명령 표 에 `kebab ingest-file` + `kebab ingest-stdin` 두 row + MCP tool list 4 → 6.
- HANDOFF post-도그푸딩 항목 한 줄.
- CLAUDE.md `_external/` 디렉토리 + naming convention 한 줄.
- integrations skill — Recipe D (agent fetched web doc) + MCP tool list 갱신.
- design §6.7 `_external/` subdirectory 절 신설.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:16:45 +09:00
th-kim0823
71c2bbdc97 🧪 test(kebab-mcp): ingest_file + ingest_stdin integration (fb-31)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:14:07 +09:00
th-kim0823
ecd77290cd feat(kebab-mcp): ingest_file + ingest_stdin tools (fb-31)
5th + 6th MCP tools — first mutation surface (fb-30 v1 was read-only).
Both wrap the new kebab-app facade fns + use spawn_blocking via the
existing spawn_tool helper. tools/list now returns 6 tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:12:18 +09:00
th-kim0823
fbc01eda50 🧪 test(kebab-cli): cli_ingest_file + cli_ingest_stdin integration (fb-31)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:10:09 +09:00
th-kim0823
0386adcb5e feat(kebab-cli): kebab ingest-stdin subcommand (fb-31)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:07:35 +09:00
th-kim0823
9cc7deca11 feat(kebab-cli): kebab ingest-file subcommand (fb-31)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:06:25 +09:00
th-kim0823
a42f907640 🧪 test(kebab-app): ingest_stdin_with_config integration (fb-31)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:04:52 +09:00
th-kim0823
67050016cc feat(kebab-app): ingest_stdin_with_config facade (fb-31)
Wraps body with YAML frontmatter (title + source_uri) via
crate::external::inject_frontmatter, writes to
_external/<hash12>.md, delegates to ingest_file_with_config. Markdown
only in v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:04:01 +09:00
th-kim0823
73ee64c73f 🧪 test(kebab-app): ingest_file_with_config integration (fb-31)
Three scenarios — copies external md + reports new=1, idempotent on
second call (unchanged=1), errors on missing path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:02:52 +09:00
th-kim0823
9b53dcb94f feat(kebab-app): ingest_file_with_config facade (fb-31)
Single-file ingest entry. Copies bytes to _external/<hash12>.<ext>
via crate::external::copy_to_external, runs the per-medium pipeline on
that single asset (reuses ingest_with_config_opts via a SourceScope
{ root: _external/, include: [<filename>], exclude:
config.workspace.exclude }).

`.kebabignore` matches log a stderr warn line and proceed (explicit
ingest is bypass intent). Internal helper `check_kebabignore_match`
uses the `ignore` crate's GitignoreBuilder.

Returns the standard IngestReport (incremental ingest from fb-23
handles re-ingest as `unchanged`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:01:18 +09:00
th-kim0823
41061a38ac 🏗️ feat(kebab-app): external module — _external dir + frontmatter inject (fb-31)
Pure-fn helpers for the `_external/` workspace subdirectory:
- `ensure_external_dir(workspace_root)` — mkdir if absent
- `ensure_kebabignore_entry(workspace_root)` — append `_external/` line
  to .kebabignore if missing (idempotent)
- `copy_to_external(ext_dir, bytes, ext)` — write to
  `<ext_dir>/<blake3-12>.<ext>`, idempotent on same content
- `inject_frontmatter(body, title, source_uri?)` — prepend YAML block
  with strict double-quote escaping; errors if body already starts
  with `---`
- `yaml_quote(s)` — defensive escaping for agent-supplied strings

14 unit tests cover happy + idempotency + edge (CRLF frontmatter
detection, YAML escape).

ingest_file / ingest_stdin facades (Tasks 2-5) compose these.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:58:31 +09:00
177ce21f88 Merge pull request 'docs(fb-31): single-file / stdin ingest spec + implementation plan' (#110) from spec/p9-fb-31-single-file-stdin-ingest into main
Reviewed-on: #110
2026-05-07 08:54:56 +00:00
th-kim0823
b7c85e8887 📝 docs(plan): p9-fb-31 single-file / stdin ingest implementation plan
12-task plan covering:
- kebab-app::external module (4 helpers + 12 unit tests) — Task 1
- kebab-app::ingest_file_with_config facade — Task 2
- kebab-app integration test — Task 3
- kebab-app::ingest_stdin_with_config facade — Task 4
- kebab-app integration test — Task 5
- kebab-cli Cmd::IngestFile + Cmd::IngestStdin arms — Tasks 6 + 7
- kebab-cli spawn-based integration tests — Task 8
- kebab-mcp ingest_file + ingest_stdin tools (4 → 6) — Task 9
- kebab-mcp integration tests — Task 10
- doc sync (README + HANDOFF + CLAUDE + skill + design §6.3) — Task 11
- HOTFIXES + status flip + final verification — Task 12

Implementation strategy: ingest_file_with_config copies bytes to
_external/<hash>.<ext> then delegates to existing
ingest_with_config_opts via SourceScope { root: _external/, include:
[<filename>], ... } — minimal change to existing walk pipeline.
ingest_stdin_with_config = frontmatter inject + ingest_file delegation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:49:04 +09:00
th-kim0823
7772fbc00f 📝 docs(spec): p9-fb-31 single-file / stdin ingest 설계 문서
신규 명령 `kebab ingest-file` + `kebab ingest-stdin` + MCP tool
`ingest_file` + `ingest_stdin` 도입 brainstorm 산출물. agent fetch 한
web markdown / 단일 외부 file 을 KB 에 즉시 저장.

핵심 결정:
- 외부 file 저장: copy in (`<workspace.root>/_external/<hash12>.<ext>`).
  blake3 content hash 기반 deterministic 명명 → idempotent.
- CLI: 신규 subcommand 2개 (기존 `kebab ingest` 무영향).
- MCP: 4 → 6 tool. fb-30 v1 read-only 정책 변경 — 첫 mutation tool
  surface (의도된 진화).
- .kebabignore: explicit ingest 가 default bypass + stderr warn.
- stdin v1: markdown 전용 + flag (--title, --source-uri) → frontmatter
  자동 prepend. 이미 frontmatter 있으면 error (use ingest-file).
- `_external/` 디렉토리 첫 생성 시 .kebabignore 자동 append (walk
  re-ingestion 무한 루프 방지).
- source_uri 는 frontmatter → Document.metadata 자동 흐름. wire
  schema 변경 없음 (ingest_report.v1 / search_hit.v1 의 metadata
  free-form map 재사용).

릴리스: 0.3.1 → 0.3.2 patch — additive only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:29:30 +09:00
138f31d661 Merge pull request 'chore: bump version 0.3.0 → 0.3.1 (fb-30 release)' (#109) from chore/bump-v0.3.1 into main
Reviewed-on: #109
2026-05-07 08:07:47 +00:00
th-kim0823
5495d96275 chore: bump workspace version 0.3.0 → 0.3.1 — fb-30 release
agent integration MVP (fb-30 MCP server stdio) 머지 후 patch bump.

trigger:
- 신규 CLI surface `kebab mcp` (additive, 기존 명령 동작 무영향)
- new crate `kebab-mcp` (lib only, transitive 만)
- capability flag `mcp_server: false → true` (additive on schema.v1)
- design §10.2 MCP transport 절 추가 (additive subsection)

CLAUDE.md release 규약상 wire schema additive / surface 추가는
minor bump 영역이지만, pre-1.0 (0.x.y) 단계에서는 fb-26~31 group
누적 시까지 0.3.x patch 로 묶고, 큰 jump 시 0.4.0 cut 으로 운용.
fb-30 단독으로 break 없는 additive 라 0.3.1 patch 적정.

후속 fb-26 / 28 / 31 머지 시점에 추가 0.3.x patch 누적, breaking
schema 변경 시 0.4.0 minor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:06:45 +09:00
eb4f594dda Merge pull request 'feat(fb-30): MCP server (stdio) — agent integration MVP' (#108) from feat/p9-fb-30-mcp-server into main
Reviewed-on: #108
2026-05-07 08:04:53 +00:00
th-kim0823
4e2090e54d 🏗️ refactor(fb-30): apply round 1 review nits
- error_wire.rs: extract `pub const ERROR_V1_ID = "error.v1"` + replace
  9 inline literals (parallel to schema.rs::SCHEMA_V1_ID pattern).
  Re-export via kebab-app::lib.rs.
- kebab-mcp/src/lib.rs: extract `KebabHandler::spawn_tool<I, F>` helper —
  search + ask arms reduce from ~17 lines each to a one-line dispatch.
  Future tool 추가 시 boilerplate 안 늘림.
- ask.rs: defensive `to_value(&answer)` — silent Null 위험 제거, 실패
  시 to_tool_error fallthrough.
- HOTFIXES: note AskOpts Default 미도입 limitation.
- ARCHITECTURE.md: directory tree 의 kebab-mcp 항목에 `schema` 추가
  (4 tool 모두 명시).

Round 1 review summary: #108 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:59:40 +09:00
th-kim0823
2387c6cd11 🚑 docs + cleanup: ARCHITECTURE.md kebab-mcp + state.rs stale comment + schema test cap-flag (fb-30)
Final review fixes:

- docs/ARCHITECTURE.md: add kebab-mcp to UI subgraph + directory tree
  (CLAUDE.md "add new crate" rule required this; missed in Task 12 doc sync).
- state.rs: replace forward-reference Task 10 comment with current-state
  doc (config_path now wired by Task 10 commit 4a30959).
- tools_call_schema.rs: assert capabilities.mcp_server == true (already
  pinned in schema_report + cli_schema, this closes the gap in mcp's own
  test).

Version bump 0.3.0 → 0.4.0 deferred to separate `chore/bump-v0.4.0` PR
mirroring fb-27 precedent (commit 73f5d73 / PR #105).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:34:46 +09:00
th-kim0823
ee4f198308 📝 docs(tasks): HOTFIXES entry + p9-fb-30 status → completed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:17:37 +09:00
th-kim0823
f758d51a01 📝 docs: sync README / HANDOFF / CLAUDE / skill / design for fb-30
- README 명령 표 에 `kebab mcp` 추가 + Claude Code MCP config 예시
- HANDOFF post-도그푸딩 항목 한 줄 (rmcp 1.6 + manual dispatch + error_wire promotion + ask/search spawn_blocking + capability flag flip 명시)
- CLAUDE.md facade 룰 의 UI crate 카테고리 에 `kebab-mcp` 추가
- integrations skill — MCP 사용 안내 (recommended over subprocess)
- design §10.2 MCP transport 절 신설

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:15:19 +09:00
th-kim0823
366b647a1a feat(kebab-app): capability flag mcp_server: false → true (fb-30)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:12:23 +09:00
th-kim0823
4a30959fdd feat(kebab-cli): kebab mcp subcommand (fb-30)
Wires kebab_mcp::serve_stdio into kebab-cli. `--config <path>` honored
via the established Config::load pattern.

Updated serve_stdio signature to (Config, Option<PathBuf>) so the doctor
tool's path-aware behavior works correctly via KebabAppState.

Smoke test spawns the binary + sends initialize + initialized +
tools/list over stdin, asserts 4 tools returned. Confirms the MCP
server boots end-to-end via the real binary (rmcp 1.6 has no
in-memory test transport, so this is the only end-to-end assertion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:10:17 +09:00
th-kim0823
61eef9bc82 🧪 test(kebab-mcp): tools/list returns 4 tools (fb-30)
Approach: extracted `pub fn build_tools_vec() -> Vec<Tool>` from the
inline `list_tools` trait impl body — `RequestContext<RoleServer>` is
non-constructible from outside rmcp (Peer::new is pub(crate)), so a
direct trait-method call was not viable without an in-memory transport
rmcp 1.6 does not expose. The helper is the single source of truth;
`list_tools` now delegates to it.

Three test cases in tests/tools_list.rs:
- 4 tools present with correct names
- search inputSchema has "required": ["query"]
- schema/doctor tools accept empty input (type=object, no required)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:04:42 +09:00
th-kim0823
bc16dbf12a 🚑 fix(kebab-cli): add schema_version field to wire.rs ErrorV1 test literal
Task 8 commit f9a1548 added `schema_version: String` as required field on
ErrorV1 (so kebab-mcp's direct serialize-then-emit path produces correct
error.v1 wire). The wire.rs ErrorV1 literal in the
error_wrapper_tags_schema_version_and_emits_code test was missed —
breaks kebab-cli build. Add the field to the test fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:02:09 +09:00
th-kim0823
f9a1548b53 🧪 test(kebab-mcp): error mapping — bad config → error.v1 (fb-30)
Adds integration test schema_tool_emits_error_v1_when_db_missing that
verifies NotIndexed errors are emitted as error.v1 JSON with isError=true.
Also fixes ErrorV1 struct to include required schema_version field per
error.v1 wire contract (docs/wire-schema/v1/error.schema.json).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:58:52 +09:00
th-kim0823
c8e04c65e0 🏗️ refactor(kebab-mcp): fix ask tool production panic + mode default (fb-30)
Two issues from Task 7 review:

1. CRITICAL — call_tool "ask" arm called blocking ask handle from async
   context. OllamaLanguageModel::new builds reqwest::blocking::Client
   which creates+drops a tokio runtime → panic inside async. Fix:
   tokio::task::spawn_blocking wrap. Also applied preemptively to
   "search" arm (SqliteStore + Lance open are blocking IO too).

2. IMPORTANT — ask tool's retrieval mode hardcoded to Lexical (test
   workaround for provider="none"); CLI default is Hybrid. Fix: add
   `mode: Option<String>` field to AskInput, default Hybrid in handle,
   test passes mode=Some("lexical") explicitly to keep test functional
   on provider="none".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:55:02 +09:00
th-kim0823
4b1b8a15bf feat(kebab-mcp): ask tool (fb-30)
Fourth (and final v1) tool — `ask` (input: query / optional session_id).
Multi-turn via optional session_id (kebab_app::ask_with_session_with_config),
single-shot via ask_with_config when None. Refusal (grounded:false) NOT
mapped to isError — agent branches on the wire payload's grounded flag.

AskOpts has no Default impl (must construct manually). Answer carries no
schema_version field (tagged inline via entry().or_insert_with, idempotent).
Mode defaulted to Lexical: reqwest::blocking::Client::build creates and
drops a tokio runtime, panicking inside async context — the empty-corpus
refusal test avoids this via spawn_blocking; the tool itself uses Lexical
as the default mode since MCP callers typically run without an embedding
provider configured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:50:57 +09:00
th-kim0823
52782fdf72 feat(kebab-mcp): search tool (fb-30)
Third tool — `search` (input: query / mode / k). First tool with
non-empty input — establishes the pattern: SearchInput struct with
JsonSchema derive + Tool::new uses
rmcp::handler::server::common::schema_for_type::<SearchInput>() for
inputSchema + call_tool match arm parses request.arguments via
serde_json::from_value.

search_with_config takes owned Config, so state.config (Arc<Config>)
is cloned via (*state.config).clone(). Output: search_hit.v1 array —
SearchHit (kebab-core) does not carry schema_version field, so each
element is tagged inline before serialising.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:44:56 +09:00
th-kim0823
360fa53b02 feat(kebab-mcp): doctor tool (fb-30)
Second tool — `doctor` (no input args, returns doctor.v1 JSON via
kebab_app::doctor_with_config_path). Mirrors schema tool's manual-dispatch
pattern: Tool::new entry in list_tools, match arm in call_tool, per-tool
module in tools/doctor.rs.

doctor_with_config_path takes Option<&Path> (not &Config), so KebabAppState
is extended with config_path: Option<PathBuf>. All existing callers
(initialize.rs, tools_call_schema.rs, serve_stdio_async) pass None for now;
Plan Task 10 (Cmd::Mcp wiring) will thread the actual --config path through.
doctor_with_config falls back to XDG default when config_path is None —
same behavior as bare `kebab doctor`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:41:13 +09:00
th-kim0823
8ca8e18d12 feat(kebab-mcp): schema tool (fb-30)
First tool wired — `schema` (no input args, returns schema.v1 JSON
mirroring `kebab schema --json`). Establishes the per-tool module
pattern (crates/kebab-mcp/src/tools/<name>.rs) + error helper that maps
anyhow::Error to MCP CallToolResult.error with error.v1 content.

Dispatch pattern: manual dispatch — explicit `list_tools` + `call_tool`
overrides on `impl ServerHandler for KebabHandler` with a
`match request.name.as_ref()` arm per tool. No proc-macro magic.
Tasks 5-7 should add a new arm + new tools/<name>.rs following the same
pattern; also add a `Tool::new(...)` entry in `list_tools`.

API shapes confirmed from rmcp 1.6 source:
- Content = Annotated<RawContent>; text via `Content::text(s)`; pattern
  match via `&content.raw` → `RawContent::Text(t)` → `t.text`
- CallToolResult::success(Vec<Content>) / ::error(Vec<Content>)
- ListToolsResult::with_all_items(Vec<Tool>)
- schema_for_empty_input() from rmcp::handler::server::common

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:38:00 +09:00
th-kim0823
8f6e6bc01a feat(kebab-mcp): handler skeleton + initialize handshake (fb-30)
KebabHandler implements rmcp::ServerHandler::get_info — returns
serverInfo (name="kebab", version from CARGO_PKG_VERSION) and
capabilities.tools. KebabAppState wraps Config in Arc for cheap clone
into per-request task scope. serve_stdio entry builds a multi-thread
tokio runtime and runs the server until client closes the stream.

rmcp 1.6 API used:
- rmcp::ServerHandler trait (re-exported from handler::server)
- ServerInfo::new(caps).with_server_info(impl) builder (not struct-init:
  InitializeResult/Implementation are #[non_exhaustive])
- ServerCapabilities::builder().enable_tools().build() — builder macro
  generated, confirms the plan-literal pattern works
- Implementation::new(name, version) — non-exhaustive constructor
- rmcp::transport::stdio() returns (tokio::io::Stdin, tokio::io::Stdout)
  tuple; tuple impls IntoTransport via AsyncRead+AsyncWrite blanket
- handler.serve(transport).await → RunningService<RoleServer, H>
  (ServiceExt::serve, returns Result<_, ServerInitializeError>)
- service.waiting().await → Result<QuitReason, JoinError>
- serve_stdio is plain fn wrapping a manually-built tokio runtime
  (avoids nested-runtime hazard if kebab-cli ever gains its own rt)

Tools wire-up lands in subsequent tasks (one tool per task).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:31:13 +09:00
th-kim0823
2c09ed6af4 🏗️ chore(kebab-mcp): scaffold new crate (fb-30)
Empty lib + serve_stdio entry that bails until Task 3 wires rmcp. Adds
rmcp 1.6 to workspace dependencies (server + macros + transport-io +
schemars features) + tokio multi-thread/io-util/io-std local extensions.

schemars declared as "1" (resolved to 1.2.1) — matches rmcp 1.6's ^1.0
requirement (verified via crates.io /dependencies; plan literal was 0.9
which would conflict). Path-style refs for kebab-app / kebab-config /
kebab-core follow workspace convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:25:57 +09:00
th-kim0823
1f53930234 🏗️ refactor(kebab-app): promote error_classify → kebab-app::error_wire (fb-30 prep)
fb-30 의 새 crate `kebab-mcp` 가 동일 classify 모듈 사용 — UI crate 끼리
import 는 facade rule 위반이므로 kebab-app 으로 promotion. fb-27 commit
c91228e 의 코드 그대로 이전 (struct + classify + classify_llm + 7 unit
test). reqwest dev-dep 도 함께 이동.

kebab-cli 는 `kebab_app::ErrorV1` / `kebab_app::classify` 로 import 경로
1줄 변경 + wire.rs 의 `&crate::error_classify::ErrorV1` 1줄 교체. 동작
무영향.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:13:28 +09:00
65cfaf7c75 Merge pull request 'docs(fb-30): MCP server (stdio) spec + implementation plan' (#107) from spec/p9-fb-30-mcp-server into main
Reviewed-on: #107
2026-05-07 06:07:41 +00:00
th-kim0823
72855df98b 📝 docs(plan): p9-fb-30 MCP server implementation plan
14-task plan covering:
- error_classify → kebab-app::error_wire promotion (Task 1)
- new crate kebab-mcp scaffold (Task 2)
- KebabHandler skeleton + initialize (Task 3)
- 4 tool wire-up: schema / doctor / search / ask (Tasks 4-7)
- error mapping test (Task 8)
- tools/list integration (Task 9)
- kebab-cli Cmd::Mcp + spawn smoke (Task 10)
- capability flag flip (Task 11)
- doc sync (Task 12)
- HOTFIXES + status flip (Task 13)
- final workspace verification (Task 14)

rmcp 1.6 SDK 채택. plan 의 macro / extractor 시그니처는 best-effort —
실제 rmcp 1.6 API 와 다르면 Task 3 부터 hand-roll fallback 명시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:54:43 +09:00
th-kim0823
58ec4578b9 📝 docs(spec): p9-fb-30 MCP server (stdio) 설계 문서
`kebab mcp` 신규 subcommand + new crate `kebab-mcp` 도입을 위한
brainstorm 산출물. agent integration "MVP" 완성 (Claude Code / Cursor /
OpenAI Agents 등 host-agnostic 사용 가능).

핵심 결정:
- `kebab mcp` subcommand (kebab-cli 내, 신규 binary 아님)
- 4 read-only tool (`search` / `ask` / `schema` / `doctor`) — ingest /
  fetch / list_docs / inspect_chunk 는 fb-31 / fb-35 / 후속에서 추가
- Resources / Prompts 모두 skip (tools only)
- Rust MCP SDK 사용 — rmcp 채택 우선, plan 단계 verify
- stdio 단일 transport — fb-29 deferral 따라 HTTP-SSE P+
- error mapping: tool dispatch 실패만 isError=true + error.v1 content,
  refusal / no-hit / unhealthy 는 정상 응답 (semantic flag 으로 분기)
- classify 모듈 이전: kebab-cli::error_classify → kebab-app::error_wire
  (kebab-cli + kebab-mcp 둘 다 동일 모듈 사용, facade 룰 준수)
- capability flag `mcp_server` false → true

릴리스: 0.3.0 → 0.4.0 minor — 신규 surface + new crate + 디자인 §10.1
변경 + capability flip 모두 trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:44:09 +09:00
769139996b Merge pull request 'docs: defer fb-29 HTTP daemon + fb-30 stdio-only' (#106) from chore/fb-29-defer into main
Reviewed-on: #106
2026-05-07 04:59:36 +00:00
th-kim0823
2e8de14434 📝 docs: defer fb-29 HTTP daemon + fb-30 stdio-only
2026-05-07 brainstorm 결정 — fb-29 HTTP daemon defer.

근거:
- single-user local-first 환경에서 daemon 복잡도 (PID file / port lock /
  single-instance / lifecycle UX / loopback security) 가 비대.
- fb-30 stdio MCP 가 동일 사용자 가치 (agent integration + session 동안
  hot cache) 를 daemon 없이 제공 — agent host (Claude Code, Cursor 등)
  가 subprocess 띄우면 session 동안 process 유지 = 자연스러운 hot state.
- ask 의 dominant cost 는 Ollama LLM 추론 (수 초) 라 daemon 효과 제한적.
  search 만 의미 있는데 그 비용도 fastembed model load (~1s) 가 dominant —
  stdio MCP subprocess 가 동일하게 회피.

후속 fb-30 의 transport 는 stdio 단일. HTTP-SSE 옵션은 future task —
재개 trigger:
- browser agent / remote multi-host 시나리오 등장.
- TUI ↔ CLI 다중 인스턴스 state sharing 요구.
- fb-30 MCP HTTP-SSE 변형 도입 검토.

영향:
- fb-29 spec frontmatter `status: open` → `deferred`,
  `target_version: 0.3.0` → `P+`, `unblocks: [p9-fb-30]` → `[]`.
- fb-30 `depends_on: [p9-fb-27, p9-fb-29]` → `[p9-fb-27]`.
- INDEX.md 의 0.3.0 group + HANDOFF 의 release 그룹 표 갱신.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:58:02 +09:00
53 changed files with 6700 additions and 56 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
Single-user local-first knowledge base + RAG. Rust 2024 workspace, ~20 crates, single binary (`kebab`). All inference is local (Ollama + fastembed + whisper.cpp).
Single-user local-first knowledge base + RAG. Rust 2024 workspace, ~21 crates, single binary (`kebab`). All inference is local (Ollama + fastembed + whisper.cpp).
The repo's documentation is split by audience — don't duplicate across them:
@@ -54,7 +54,7 @@ Each task spec lists `Allowed dependencies` and `Forbidden dependencies` per des
- `kebab-core` MUST NOT depend on any other `kebab-*` crate. Domain types only.
- `kebab-eval`'s `metrics` and `compare` modules MUST NOT import retrieval / embedding / LLM crates directly. The runner is allowed to use `kebab-app`'s facade (P5-1 inheritance — see deviations in that task spec).
- UI crates (`kebab-cli`, future `kebab-tui`, `kebab-desktop`) MUST NOT import `kebab-store-*` / `kebab-llm-*` / `kebab-parse-*` directly — only `kebab-app`.
- UI crates (`kebab-cli`, `kebab-mcp`, `kebab-tui`, future `kebab-desktop`) MUST NOT import `kebab-store-*` / `kebab-llm-*` / `kebab-parse-*` directly — only `kebab-app`.
Read the relevant task spec's deps section before adding an import. New crates inherit the same boundary rules.
@@ -94,6 +94,7 @@ Release 절차:
- XDG paths: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.
- SQLite filename: `kebab.sqlite` (under `data_dir`).
- Workspace ignore: `.kebabignore` (per directory).
- `_external/` (under `workspace.root`): single-file / stdin ingest 가 외부 file 을 deterministic 명명 (`<blake3-12>.<ext>`) 으로 copy. 첫 생성 시 `.kebabignore` 자동 append.
The migration from the old `kb` name lives in commits `911fb49 / f1a448d / f9714aa`. If you spot a leftover `kb` reference, treat it as a leftover and fix it (the rename PR sweep covered crates/, docs/, tasks/, README, design doc, fixtures — but workspace root `Cargo.toml` comments needed a follow-up; assume similar misses are possible).

164
Cargo.lock generated
View File

@@ -549,7 +549,7 @@ dependencies = [
"log",
"num-rational",
"num-traits",
"pastey",
"pastey 0.1.1",
"rayon",
"thiserror 2.0.18",
"v_frame",
@@ -1229,6 +1229,16 @@ dependencies = [
"darling_macro 0.21.3",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core 0.23.0",
"darling_macro 0.23.0",
]
[[package]]
name = "darling_core"
version = "0.20.11"
@@ -1257,6 +1267,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
@@ -1279,6 +1302,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"quote",
"syn 2.0.117",
]
[[package]]
name = "dary_heap"
version = "0.3.9"
@@ -3491,11 +3525,12 @@ dependencies = [
[[package]]
name = "kebab-app"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"blake3",
"dirs 5.0.1",
"ignore",
"image",
"kebab-chunk",
"kebab-config",
@@ -3516,6 +3551,7 @@ dependencies = [
"kebab-store-vector",
"lopdf",
"lru",
"reqwest",
"rusqlite",
"serde",
"serde_json",
@@ -3532,7 +3568,7 @@ dependencies = [
[[package]]
name = "kebab-chunk"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"blake3",
@@ -3547,7 +3583,7 @@ dependencies = [
[[package]]
name = "kebab-cli"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"clap",
@@ -3557,8 +3593,8 @@ dependencies = [
"kebab-config",
"kebab-core",
"kebab-eval",
"kebab-mcp",
"kebab-tui",
"reqwest",
"serde",
"serde_json",
"tempfile",
@@ -3567,7 +3603,7 @@ dependencies = [
[[package]]
name = "kebab-config"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"dirs 5.0.1",
@@ -3582,7 +3618,7 @@ dependencies = [
[[package]]
name = "kebab-core"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"blake3",
@@ -3596,7 +3632,7 @@ dependencies = [
[[package]]
name = "kebab-embed"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"blake3",
@@ -3610,7 +3646,7 @@ dependencies = [
[[package]]
name = "kebab-embed-local"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"fastembed",
@@ -3623,7 +3659,7 @@ dependencies = [
[[package]]
name = "kebab-eval"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"kebab-app",
@@ -3642,7 +3678,7 @@ dependencies = [
[[package]]
name = "kebab-llm"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"kebab-core",
@@ -3651,7 +3687,7 @@ dependencies = [
[[package]]
name = "kebab-llm-local"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"kebab-config",
@@ -3666,9 +3702,26 @@ dependencies = [
"wiremock",
]
[[package]]
name = "kebab-mcp"
version = "0.3.2"
dependencies = [
"anyhow",
"kebab-app",
"kebab-config",
"kebab-core",
"rmcp",
"schemars 1.2.1",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "kebab-normalize"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"kebab-core",
@@ -3683,7 +3736,7 @@ dependencies = [
[[package]]
name = "kebab-parse-image"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"ab_glyph",
"anyhow",
@@ -3707,7 +3760,7 @@ dependencies = [
[[package]]
name = "kebab-parse-md"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"kebab-core",
@@ -3724,7 +3777,7 @@ dependencies = [
[[package]]
name = "kebab-parse-pdf"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"blake3",
@@ -3737,7 +3790,7 @@ dependencies = [
[[package]]
name = "kebab-parse-types"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"kebab-core",
"serde",
@@ -3745,7 +3798,7 @@ dependencies = [
[[package]]
name = "kebab-rag"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"blake3",
@@ -3766,7 +3819,7 @@ dependencies = [
[[package]]
name = "kebab-search"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"globset",
@@ -3784,7 +3837,7 @@ dependencies = [
[[package]]
name = "kebab-source-fs"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"blake3",
@@ -3801,7 +3854,7 @@ dependencies = [
[[package]]
name = "kebab-store-sqlite"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"blake3",
@@ -3822,7 +3875,7 @@ dependencies = [
[[package]]
name = "kebab-store-vector"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"arrow",
@@ -3846,7 +3899,7 @@ dependencies = [
[[package]]
name = "kebab-tui"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"anyhow",
"crossterm",
@@ -5457,6 +5510,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pastey"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a"
[[package]]
name = "path_abs"
version = "0.5.1"
@@ -6306,6 +6365,40 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmcp"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad"
dependencies = [
"async-trait",
"chrono",
"futures",
"pastey 0.2.2",
"pin-project-lite",
"rmcp-macros",
"schemars 1.2.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "rmcp-macros"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"serde_json",
"syn 2.0.117",
]
[[package]]
name = "roaring"
version = "0.10.12"
@@ -6512,12 +6605,26 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.117",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -6606,6 +6713,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "serde_json"
version = "1.0.149"

View File

@@ -22,6 +22,7 @@ members = [
"crates/kebab-parse-image",
"crates/kebab-parse-pdf",
"crates/kebab-tui",
"crates/kebab-mcp",
]
[workspace.package]
@@ -29,7 +30,7 @@ edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
repository = "https://github.com/altair823/kebab"
version = "0.3.0"
version = "0.3.2"
[workspace.dependencies]
anyhow = "1"
@@ -71,6 +72,10 @@ futures = "0.3"
# pass; pulled into the workspace deps so future crates can share the
# same major.
regex = "1"
# MCP (Model Context Protocol) SDK. server + macros + transport-io provide
# stdio JSON-RPC transport for `kebab-mcp` (p9-fb-30). schemars feature
# exposes the derive macro used by tool input schemas.
rmcp = { version = "1.6", default-features = false, features = ["server", "macros", "transport-io", "schemars"] }
# Dev-only HTTP mock server for kebab-llm-local Ollama adapter tests. Requires
# a tokio runtime to host its mock server (the runtime adapter crate stays
# sync via reqwest::blocking — wiremock is dev-only there).

View File

@@ -31,6 +31,8 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만:
- **2026-05-07 P9 post-도그푸딩 (p9-fb-31)** — `kebab ingest-file <path>` + `kebab ingest-stdin --title <T>` 두 신규 subcommand + MCP tool `ingest_file` / `ingest_stdin` (4 → 6 tool). agent 가 fetch 한 web markdown / 외부 file 을 KB 에 즉시 저장. workspace 외부 file 은 `<workspace.root>/_external/<blake3-12>.<ext>` 로 copy (deterministic 명명 → idempotent). `_external/` 디렉토리 첫 생성 시 `.kebabignore` 자동 append (walk 무한 루프 방지). stdin 은 markdown 전용 + flag (`--title`, `--source-uri`) → frontmatter 자동 prepend. .kebabignore 매치 시 stderr warn 후 진행 (explicit ingest = bypass intent). fb-30 의 v1 read-only MCP 정책 변경 — 첫 mutation tool 도입. spec: `tasks/p9/p9-fb-31-single-file-stdin-ingest.md`. design: `docs/superpowers/specs/2026-05-07-p9-fb-31-single-file-stdin-ingest-design.md`.
- **2026-05-07 P9 post-도그푸딩 (p9-fb-30)** — `kebab mcp` 신규 subcommand + new crate `kebab-mcp` (lib only) — stdio JSON-RPC server. 4 read-only tool (`search` / `ask` / `schema` / `doctor`) 가 `kebab-app` facade 위에 build. rmcp 1.6 SDK 채택, manual `tools/list` + `tools/call` dispatch (rmcp 의 `#[tool_router]` 매크로 대신). `error_classify` 모듈을 `kebab-cli``kebab-app::error_wire` 로 promotion (UI crate 끼리 import 회피, facade 룰 준수). `ErrorV1``schema_version: String` 필드 추가 — kebab-mcp 의 직접 serialize 경로에서도 wire 정합. `KebabAppState``(Config, Option<PathBuf>)` carry — doctor tool 의 path-aware behavior 위해. ask + search arm 의 `tokio::task::spawn_blocking` wrap — `OllamaLanguageModel` 의 reqwest blocking client 가 async 안에서 panic 회피. capability flag `mcp_server` `false``true`. agent integration MVP 완성 — Claude Code / Cursor / OpenAI Agents 등 host-agnostic 사용 가능. spec: `tasks/p9/p9-fb-30-mcp-server.md`. design: `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`.
- **P3-5 / P4-3 `--config` 누락** — `kebab-cli``--config <path>` 를 honor 하려면 `kebab_app::*_with_config` companion 을 호출해야 함. 두 번 같은 모양으로 회귀했음.
- **P6-2 OCR 기본 엔진** — spec literal 의 Tesseract 가 시스템 dep 부담으로 거부됨, Ollama vision LM 으로 대체. `OcrEngine` trait 그대로라 future swap 가능.
- **P6-3 caption** — `GenerateRequest.images` 필드를 `kebab-core::LanguageModel` trait 에 신설. 기존 caller 모두 `images: Vec::new()` 로 마이그레이션.
@@ -84,7 +86,7 @@ P9-2/3/4 는 P9-1 의 parallel-safety contract (sub-state slot 패턴) 덕에
2026-05-06 도그푸딩 누적 피드백 + "AI agent 가 kebab 을 쓰게 한다" 궁극 목표용 surface 확장. 17 항목 모두 **status: open + brainstorm 선행 필요**. 각 spec 상단 banner 명시. cascade 영향 / 분량 고려해 한 minor 에 묶지 않고 4 분할. 2026-05-06 renumber — **번호 = release 순서**:
- **0.3.0 — agent foundation**: fb-26 (log), fb-27 (introspection/error wire), fb-28 (readonly/quiet), fb-29 (daemon), fb-30 (MCP), fb-31 (single-file ingest). agent 통합 MVP.
- **0.3.0+ — agent foundation**: fb-26 (log), fb-27 (introspection/error wire) ✅ 머지 + v0.3.0 cut (2026-05-07), fb-28 (readonly/quiet), ~~fb-29 (daemon)~~ → 🚫 **deferred (2026-05-07 brainstorm)** — fb-30 stdio MCP 가 동일 가치 (agent integration + session 동안 hot cache) 를 daemon 복잡도 (PID file / port lock / loopback security / lifecycle UX) 없이 제공, single-user local-first 환경에 비대. fb-30 (MCP, stdio-only — fb-29 의존 제거 → depends_on `[p9-fb-27]` 만), fb-31 (single-file ingest). 후속 fb 들은 0.3.x patch / 0.4.0 minor 로 누적.
- **0.4.0 — agent surface refinement (additive)**: fb-32 (stale), fb-33 (streaming), fb-34 (budget), fb-35 (verbatim fetch), fb-36 (filters), fb-37 (trace/stats).
- **0.5.0 — RAG quality (cascade 동반)**: fb-38 (score semantics), fb-39 (precision tuning, embedding_version cascade + V00X), fb-40 (fact-grounded, prompt_template_version cascade).
- **0.6.0 또는 P+**: fb-41 (multi-hop, XL), fb-42 (bulk/rerank, Nice).

View File

@@ -80,6 +80,9 @@ kebab doctor
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) |
| `kebab eval run / compare` | golden query 회귀 측정 |
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json``schema.v1` wire; 사람 모드는 서식 출력. |
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능). 바이트는 `<workspace.root>/_external/<hash12>.<ext>` 로 copy. `.kebabignore` 매치 시 stderr warn 후 진행 (explicit ingest 가 bypass intent). |
| `kebab ingest-stdin --title <T> [--source-uri <URI>]` | stdin 의 markdown 본문 ingest. frontmatter (title + source_uri) 자동 prepend. v1 markdown only. |
| `kebab mcp` | MCP (Model Context Protocol) stdio server. agent host (Claude Code / Cursor / OpenAI Agents) 가 spawn 하여 tool 호출 (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`). `--config` honor. |
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `schema.v1`). `--json` 모드에서 fatal error 는 stderr 에 `error.v1` ndjson 으로 emit (exit code 0/1/2/3 unchanged).
@@ -160,9 +163,28 @@ config 예시는 [docs/SMOKE.md](docs/SMOKE.md) 의 `/tmp/kebab-smoke/config.tom
- **Claude Code skill** — repo 의 [`integrations/claude-code/`](integrations/claude-code/) 가 ship-ready skill. `cp -r integrations/claude-code/kebab ~/.claude/skills/` 한 번이면 새 Claude Code 세션부터 자동 trigger (내부 시스템 / 위키 lookup / 사내 runbook 질문). multi-turn 은 `kebab ask --session <id> --json` 으로 영속 — skill 이 conversation id 관리하면 외부 agent 도 `--repl` 없이 stateful 대화 가능 (p9-fb-18).
- **Codex / 기타 agent host** — `--json` + frozen wire schema v1 가 stable contract. 동일 패턴으로 ~50줄 wrapper 작성 가능. `integrations/<host>/` 에 추가 PR 환영.
- **MCP server** — stdio JSON-RPC 로 `kebab-app` facade 1:1 노출.
- **MCP server** — stdio JSON-RPC 로 `kebab-app` facade 1:1 노출. `kebab mcp` 참조.
- **HTTP wrapper** — `kebab serve --bind 127.0.0.1:7711` (P+, local-only 가치 신중).
## MCP 사용
`kebab mcp` 가 stdio MCP server. 6 tool: `search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`.
Claude Code 빠른 등록 (`~/.claude/mcp.json` 또는 host 동등 위치):
```json
{
"mcpServers": {
"kebab": {
"command": "kebab",
"args": ["mcp"]
}
}
}
```
자세한 사용법 (Cursor / OpenAI Agents / Copilot CLI config, per-tool 입출력 예시, troubleshooting, multi-turn ask + session 관리, performance / security) — **[docs/mcp-usage.md](docs/mcp-usage.md)** 참조.
## 비-목표
다중 사용자 SaaS / K8s / 원격 vector DB / enterprise RBAC / 실시간 협업 / 모든 파일 포맷의 완벽한 parsing / agent 임의 파일 수정 / multi-workspace / LLM-as-judge eval / CLIP 시각 embedding / `kebab://` protocol handler — frozen 설계 §11 / §0 참조.

View File

@@ -49,6 +49,9 @@ lru = { workspace = true }
# `" foo "` collapse to one entry. Same crate kebab-normalize +
# kebab-core already use, no version drift.
unicode-normalization = "0.1"
# p9-fb-31: GitignoreBuilder for .kebabignore matching in ingest_file_with_config.
# Same version as kebab-source-fs (0.4) to avoid duplicate dep versions.
ignore = "0.4"
[dev-dependencies]
rusqlite = { workspace = true }
@@ -64,3 +67,6 @@ image = { version = "0.25", default-features = false, features =
# to the same major (0.32) so byte output is identical between the two
# fixture surfaces.
lopdf = "0.32"
# error_wire::tests::llm_unreachable_classifies_to_model_unreachable needs a real
# reqwest::Error (private constructor) — built from a connect-refused call.
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }

View File

@@ -9,10 +9,15 @@
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use kebab_app::error_signal::{ConfigInvalid, LlmError, NotIndexed};
use crate::error_signal::{ConfigInvalid, LlmError, NotIndexed};
/// Wire schema id for [`ErrorV1`]. Single source of truth — kebab-cli
/// + kebab-mcp use this via `kebab_app::ERROR_V1_ID`.
pub const ERROR_V1_ID: &str = "error.v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorV1 {
pub schema_version: String,
pub code: String,
pub message: String,
pub details: Value,
@@ -22,6 +27,7 @@ pub struct ErrorV1 {
pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
if let Some(s) = err.downcast_ref::<ConfigInvalid>() {
return ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),
code: "config_invalid".to_string(),
message: s.to_string(),
details: json!({
@@ -33,6 +39,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
}
if let Some(s) = err.downcast_ref::<NotIndexed>() {
return ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),
code: "not_indexed".to_string(),
message: s.to_string(),
details: json!({
@@ -47,6 +54,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
}
if let Some(io) = err.downcast_ref::<std::io::Error>() {
return ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),
code: "io_error".to_string(),
message: io.to_string(),
details: json!({"kind": format!("{:?}", io.kind())}),
@@ -59,6 +67,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
details = json!({"chain": chain});
}
ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),
code: "generic".to_string(),
message: err.to_string(),
details,
@@ -69,6 +78,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
fn classify_llm(s: &LlmError) -> ErrorV1 {
match s {
LlmError::Unreachable { endpoint, source } => ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),
code: "model_unreachable".to_string(),
message: format!("ollama unreachable at {endpoint}"),
details: json!({
@@ -78,24 +88,28 @@ fn classify_llm(s: &LlmError) -> ErrorV1 {
hint: Some(format!("ensure `ollama serve` is reachable at {endpoint}")),
},
LlmError::ModelNotPulled(model) => ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),
code: "model_not_pulled".to_string(),
message: format!("ollama model `{model}` is not pulled"),
details: json!({"model": model}),
hint: Some(format!("run `ollama pull {model}`")),
},
LlmError::Timeout(e) => ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),
code: "timeout".to_string(),
message: format!("ollama timeout: {e}"),
details: json!({"source": e.to_string()}),
hint: Some("increase timeout or check Ollama load".to_string()),
},
LlmError::Stream(body) => ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),
code: "generic".to_string(),
message: format!("ollama HTTP error: {body}"),
details: json!({"body": body}),
hint: None,
},
LlmError::Malformed(line) => ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),
code: "generic".to_string(),
message: format!("malformed response line: {line}"),
details: json!({"line": line}),

View File

@@ -0,0 +1,253 @@
//! Helpers for the `_external/` workspace subdirectory used by
//! `ingest_file_with_config` and `ingest_stdin_with_config` (p9-fb-31).
//!
//! - `ensure_external_dir`: create `<workspace.root>/_external/` if absent.
//! - `ensure_kebabignore_entry`: append `_external/` to `<workspace.root>/.kebabignore`
//! if missing — prevents subsequent `kebab ingest` workspace walks from
//! re-walking files that were imported via single-file ingest.
//! - `copy_to_external`: write bytes to `_external/<blake3-12>.<ext>`, idempotent.
//! - `inject_frontmatter`: prepend a YAML frontmatter block to a markdown body
//! string (used by `ingest_stdin_with_config`).
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub const EXTERNAL_DIR: &str = "_external";
const KEBABIGNORE_LINE: &str = "_external/";
/// Ensure `<workspace_root>/_external/` exists. Returns the directory path.
pub fn ensure_external_dir(workspace_root: &Path) -> Result<PathBuf> {
let dir = workspace_root.join(EXTERNAL_DIR);
fs::create_dir_all(&dir)
.with_context(|| format!("create _external dir at {}", dir.display()))?;
Ok(dir)
}
/// Append `_external/` line to `<workspace_root>/.kebabignore` if not already
/// present. Idempotent — checks for the exact line before appending.
pub fn ensure_kebabignore_entry(workspace_root: &Path) -> Result<()> {
let path = workspace_root.join(".kebabignore");
let existing = if path.exists() {
fs::read_to_string(&path)
.with_context(|| format!("read existing .kebabignore at {}", path.display()))?
} else {
String::new()
};
let already = existing
.lines()
.any(|line| line.trim() == KEBABIGNORE_LINE);
if already {
return Ok(());
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("open .kebabignore for append at {}", path.display()))?;
if !existing.is_empty() && !existing.ends_with('\n') {
file.write_all(b"\n")?;
}
writeln!(file, "{}", KEBABIGNORE_LINE)?;
Ok(())
}
/// Copy bytes to `<external_dir>/<blake3-12>.<ext>`. Idempotent — if the
/// destination file already exists with the expected hash, the existing
/// file is reused (no second write). Returns the destination path.
pub fn copy_to_external(
external_dir: &Path,
bytes: &[u8],
ext: &str,
) -> Result<PathBuf> {
let hash = blake3::hash(bytes);
let hex = hash.to_hex();
let prefix = &hex.as_str()[..12];
let filename = format!("{prefix}.{ext}");
let dest = external_dir.join(&filename);
if !dest.exists() {
fs::write(&dest, bytes)
.with_context(|| format!("write external file at {}", dest.display()))?;
}
Ok(dest)
}
/// Prepend a YAML frontmatter block to a markdown body. Returns the wrapped
/// markdown string. Errors if `body` already starts with `---` (the user
/// should use `ingest_file_with_config` for files that already carry
/// frontmatter).
///
/// Internal `yaml_quote` always uses double-quoted YAML form with backslash
/// escapes for `"` / `\` / control chars — agent-supplied titles with
/// special characters are safe.
pub fn inject_frontmatter(
body: &str,
title: &str,
source_uri: Option<&str>,
) -> Result<String> {
let head = body.trim_start();
if head.starts_with("---\n") || head.starts_with("---\r\n") || head.starts_with("---\r") {
anyhow::bail!(
"stdin already has frontmatter; use `kebab ingest-file` for files with metadata"
);
}
let title_yaml = yaml_quote(title);
let mut header = String::new();
header.push_str("---\n");
header.push_str(&format!("title: {title_yaml}\n"));
if let Some(uri) = source_uri {
let uri_yaml = yaml_quote(uri);
header.push_str(&format!("source_uri: {uri_yaml}\n"));
}
header.push_str("---\n\n");
header.push_str(body);
Ok(header)
}
/// YAML-quote a string. Always uses double-quoted form with backslash-escape
/// for `"` and `\`. Defensive against agent-supplied titles that contain
/// quotes / control chars.
fn yaml_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn ensure_external_dir_creates_dir() {
let dir = tempdir().unwrap();
let result = ensure_external_dir(dir.path()).unwrap();
assert_eq!(result, dir.path().join("_external"));
assert!(result.is_dir());
}
#[test]
fn ensure_external_dir_is_idempotent() {
let dir = tempdir().unwrap();
let _ = ensure_external_dir(dir.path()).unwrap();
let result = ensure_external_dir(dir.path()).unwrap();
assert!(result.is_dir());
}
#[test]
fn ensure_kebabignore_entry_creates_file_with_line() {
let dir = tempdir().unwrap();
ensure_kebabignore_entry(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
assert!(content.lines().any(|l| l.trim() == "_external/"));
}
#[test]
fn ensure_kebabignore_entry_appends_to_existing() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".kebabignore"), "*.tmp\n").unwrap();
ensure_kebabignore_entry(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert!(lines.contains(&"*.tmp"));
assert!(lines.contains(&"_external/"));
}
#[test]
fn ensure_kebabignore_entry_idempotent() {
let dir = tempdir().unwrap();
ensure_kebabignore_entry(dir.path()).unwrap();
ensure_kebabignore_entry(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
let count = content.lines().filter(|l| l.trim() == "_external/").count();
assert_eq!(count, 1, "should not duplicate");
}
#[test]
fn ensure_kebabignore_entry_handles_missing_trailing_newline() {
let dir = tempdir().unwrap();
fs::write(dir.path().join(".kebabignore"), "*.tmp").unwrap(); // no \n
ensure_kebabignore_entry(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert!(lines.contains(&"*.tmp"));
assert!(lines.contains(&"_external/"));
}
#[test]
fn copy_to_external_writes_with_hash_prefix_filename() {
let dir = tempdir().unwrap();
let ext_dir = ensure_external_dir(dir.path()).unwrap();
let path = copy_to_external(&ext_dir, b"hello", "md").unwrap();
assert!(path.exists());
assert!(path.file_name().unwrap().to_string_lossy().ends_with(".md"));
let stem = path.file_stem().unwrap().to_string_lossy();
assert_eq!(stem.len(), 12);
}
#[test]
fn copy_to_external_is_idempotent_for_same_bytes() {
let dir = tempdir().unwrap();
let ext_dir = ensure_external_dir(dir.path()).unwrap();
let p1 = copy_to_external(&ext_dir, b"hello", "md").unwrap();
let p2 = copy_to_external(&ext_dir, b"hello", "md").unwrap();
assert_eq!(p1, p2);
}
#[test]
fn copy_to_external_different_bytes_produce_different_filenames() {
let dir = tempdir().unwrap();
let ext_dir = ensure_external_dir(dir.path()).unwrap();
let p1 = copy_to_external(&ext_dir, b"hello", "md").unwrap();
let p2 = copy_to_external(&ext_dir, b"world", "md").unwrap();
assert_ne!(p1, p2);
}
#[test]
fn inject_frontmatter_basic() {
let out = inject_frontmatter("## Body", "Article X", None).unwrap();
assert!(out.starts_with("---\ntitle: \"Article X\"\n---\n\n## Body"));
}
#[test]
fn inject_frontmatter_with_source_uri() {
let out = inject_frontmatter("## Body", "X", Some("https://example.com/x")).unwrap();
assert!(out.contains("title: \"X\""));
assert!(out.contains("source_uri: \"https://example.com/x\""));
assert!(out.contains("\n## Body"));
}
#[test]
fn inject_frontmatter_errors_on_existing_frontmatter() {
let body = "---\ntitle: Existing\n---\n\n## Body";
let err = inject_frontmatter(body, "New", None).unwrap_err();
assert!(err.to_string().contains("already has frontmatter"));
}
#[test]
fn inject_frontmatter_errors_on_existing_frontmatter_crlf() {
let body = "---\r\ntitle: Existing\r\n---\r\n\r\n## Body";
let err = inject_frontmatter(body, "New", None).unwrap_err();
assert!(err.to_string().contains("already has frontmatter"));
}
#[test]
fn yaml_quote_escapes_quotes_and_backslashes() {
assert_eq!(yaml_quote("hello \"world\""), "\"hello \\\"world\\\"\"");
assert_eq!(yaml_quote("path\\to"), "\"path\\\\to\"");
assert_eq!(yaml_quote("line\nbreak"), "\"line\\nbreak\"");
}
}

View File

@@ -57,6 +57,8 @@ use kebab_source_fs::FsSourceConnector;
mod app;
pub mod doctor_signal;
pub mod error_signal;
pub mod error_wire;
pub mod external;
pub mod ingest_progress;
pub mod logging;
pub mod reset;
@@ -65,6 +67,7 @@ pub mod schema;
pub use app::App;
pub use ingest_progress::{AggregateCounts, IngestEvent, render_skipped_breakdown};
pub use reset::{ResetReport, ResetScope};
pub use error_wire::{ERROR_V1_ID, ErrorV1, classify};
pub use schema::{Capabilities, Models, SCHEMA_V1_ID, SchemaV1, Stats, WireBlock, schema_with_config};
/// p9-fb-25: sentinel for files without an extension in
@@ -1872,3 +1875,143 @@ pub fn doctor_with_config_path(config_path: Option<&std::path::Path>) -> anyhow:
pub fn doctor() -> anyhow::Result<DoctorReport> {
doctor_with_config_path(None)
}
/// Single-file ingest (p9-fb-31). Copies the file to
/// `<workspace.root>/_external/<blake3-12>.<ext>` and runs the
/// per-medium ingest pipeline on that single asset. Returns an
/// `IngestReport` with `scanned: 1` (and either `new: 1` or
/// `unchanged: 1` depending on whether the content hash + version
/// cascade match an existing doc — incremental ingest from p9-fb-23).
///
/// `path` may point inside or outside the workspace.
///
/// `.kebabignore` patterns matching `path` are bypassed with a stderr
/// `warn:` line — explicit ingest is intent.
#[doc(hidden)]
pub fn ingest_file_with_config(
config: kebab_config::Config,
path: &std::path::Path,
) -> anyhow::Result<IngestReport> {
if !path.exists() {
anyhow::bail!("ingest-file: source path does not exist: {}", path.display());
}
if !path.is_file() {
anyhow::bail!("ingest-file: not a regular file: {}", path.display());
}
let ext_raw = path
.extension()
.and_then(|e| e.to_str())
.ok_or_else(|| anyhow::anyhow!("ingest-file: source has no extension: {}", path.display()))?;
let ext = ext_raw.to_lowercase();
const SUPPORTED_EXTS: &[&str] = &["md", "pdf", "png", "jpg", "jpeg"];
if !SUPPORTED_EXTS.contains(&ext.as_str()) {
anyhow::bail!(
"ingest-file: unsupported extension `.{}` (supported: {:?})",
ext, SUPPORTED_EXTS
);
}
let bytes = std::fs::read(path)
.with_context(|| format!("ingest-file: read source {}", path.display()))?;
let workspace_root = config.resolve_workspace_root();
// .kebabignore check — warn but continue.
let ignore_match = check_kebabignore_match(&workspace_root, path);
if ignore_match {
eprintln!(
"warn: {} matches .kebabignore patterns; proceeding (explicit ingest bypasses ignore)",
path.display()
);
}
// Set up _external/ dir + auto-ignore line.
let external_dir = crate::external::ensure_external_dir(&workspace_root)
.context("ingest-file: ensure _external/ dir")?;
crate::external::ensure_kebabignore_entry(&workspace_root)
.context("ingest-file: append _external/ to .kebabignore")?;
// Copy bytes to _external/<hash>.<ext>.
let dest = crate::external::copy_to_external(&external_dir, &bytes, &ext)
.context("ingest-file: copy to _external")?;
// Build a SourceScope that targets _external/ with include filter
// restricting walk to the single dest filename.
let filename = dest
.file_name()
.ok_or_else(|| anyhow::anyhow!("ingest-file: dest has no filename"))?
.to_string_lossy()
.into_owned();
let scope = kebab_core::SourceScope {
root: external_dir.clone(),
include: vec![filename],
exclude: config.workspace.exclude.clone(),
};
let opts = IngestOpts::default();
ingest_with_config_opts(config, scope, /* summary_only = */ false, opts)
}
/// Stdin ingest (p9-fb-31, v1 markdown only). Prepends a YAML
/// frontmatter block (`title` + optional `source_uri`) to `body`,
/// writes the wrapped markdown to `_external/<hash12>.md`, and runs
/// `ingest_file_with_config` on the resulting file.
///
/// Errors if `body` already starts with `---` (the user should call
/// `ingest_file_with_config` directly for files that already carry
/// frontmatter).
#[doc(hidden)]
pub fn ingest_stdin_with_config(
config: kebab_config::Config,
body: &str,
title: &str,
source_uri: Option<&str>,
) -> anyhow::Result<IngestReport> {
let wrapped = crate::external::inject_frontmatter(body, title, source_uri)?;
let workspace_root = config.resolve_workspace_root();
// Note: ensure_external_dir + ensure_kebabignore_entry + copy_to_external
// are called here AND inside ingest_file_with_config. All three are
// idempotent; the redundancy is intentional — keeping stdin's wrapped
// bytes accessible by `ingest_file_with_config` requires the dest path
// to exist. The ~ms double-stat overhead is negligible at v1 scale.
let external_dir = crate::external::ensure_external_dir(&workspace_root)?;
crate::external::ensure_kebabignore_entry(&workspace_root)?;
let dest = crate::external::copy_to_external(
&external_dir,
wrapped.as_bytes(),
"md",
)?;
ingest_file_with_config(config, &dest)
}
/// Returns true if `source_path` matches any `.kebabignore` pattern
/// rooted at `workspace_root`. Used by `ingest_file_with_config` to
/// emit a stderr warn before bypassing the ignore.
fn check_kebabignore_match(workspace_root: &std::path::Path, source_path: &std::path::Path) -> bool {
let kebabignore = workspace_root.join(".kebabignore");
if !kebabignore.exists() {
return false;
}
let text = match std::fs::read_to_string(&kebabignore) {
Ok(s) => s,
Err(_) => return false,
};
let mut builder = ignore::gitignore::GitignoreBuilder::new(workspace_root);
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let _ = builder.add_line(None, line);
}
let matcher = match builder.build() {
Ok(m) => m,
Err(_) => return false,
};
matcher.matched(source_path, source_path.is_dir()).is_ignore()
}

View File

@@ -108,7 +108,7 @@ fn capabilities_snapshot() -> Capabilities {
incremental_ingest: true,
streaming_ask: false,
http_daemon: false,
mcp_server: false,
mcp_server: true,
single_file_ingest: false,
}
}

View File

@@ -0,0 +1,111 @@
//! Integration: kebab_app::ingest_file_with_config copies external file
//! to _external/, ingests as single asset, idempotent on second call.
use std::fs;
use kebab_config::Config;
#[test]
fn ingest_file_copies_external_md_and_reports_new() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("notes");
let data = dir.path().join("data");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&data).unwrap();
let mut cfg = Config::defaults();
cfg.workspace.root = workspace.to_string_lossy().into_owned();
cfg.storage.data_dir = data.to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
// Source file outside the workspace.
let external_src = dir.path().join("source.md");
fs::write(&external_src, "# Hello\n\nbody.").unwrap();
let report = kebab_app::ingest_file_with_config(cfg.clone(), &external_src).unwrap();
assert_eq!(report.scanned, 1, "{report:?}");
assert_eq!(report.new, 1, "{report:?}");
assert_eq!(report.unchanged, 0, "{report:?}");
// _external/ dir created, file copied with hash prefix.
let ext_dir = workspace.join("_external");
assert!(ext_dir.is_dir());
let entries: Vec<_> = fs::read_dir(&ext_dir)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(entries.len(), 1, "exactly one file in _external/");
let name = entries[0].file_name().to_string_lossy().into_owned();
assert!(name.ends_with(".md"));
// .kebabignore has _external/ line.
let ki = fs::read_to_string(workspace.join(".kebabignore")).unwrap();
assert!(ki.lines().any(|l| l.trim() == "_external/"));
}
#[test]
fn ingest_file_idempotent_on_second_call() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("notes");
let data = dir.path().join("data");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&data).unwrap();
let mut cfg = Config::defaults();
cfg.workspace.root = workspace.to_string_lossy().into_owned();
cfg.storage.data_dir = data.to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
let src = dir.path().join("doc.md");
fs::write(&src, "# A\n\nbody.").unwrap();
let r1 = kebab_app::ingest_file_with_config(cfg.clone(), &src).unwrap();
assert_eq!(r1.new, 1);
let r2 = kebab_app::ingest_file_with_config(cfg.clone(), &src).unwrap();
assert_eq!(r2.new, 0, "{r2:?}");
assert_eq!(r2.unchanged, 1, "{r2:?}");
}
#[test]
fn ingest_file_errors_on_missing_path() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("notes");
let data = dir.path().join("data");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&data).unwrap();
let mut cfg = Config::defaults();
cfg.workspace.root = workspace.to_string_lossy().into_owned();
cfg.storage.data_dir = data.to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
let nonexistent = dir.path().join("nope.md");
let err = kebab_app::ingest_file_with_config(cfg, &nonexistent).unwrap_err();
assert!(err.to_string().contains("does not exist"), "{err}");
}
#[test]
fn ingest_file_errors_on_unsupported_extension() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("notes");
let data = dir.path().join("data");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&data).unwrap();
let mut cfg = Config::defaults();
cfg.workspace.root = workspace.to_string_lossy().into_owned();
cfg.storage.data_dir = data.to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
let docx = dir.path().join("doc.docx");
fs::write(&docx, b"fake docx bytes").unwrap();
let err = kebab_app::ingest_file_with_config(cfg, &docx).unwrap_err();
assert!(err.to_string().contains("unsupported extension"), "{err}");
assert!(err.to_string().contains(".docx") || err.to_string().contains("docx"), "{err}");
}

View File

@@ -0,0 +1,78 @@
//! Integration: kebab_app::ingest_stdin_with_config injects frontmatter,
//! writes to _external/, ingests as single asset.
use std::fs;
use kebab_config::Config;
fn fresh_cfg(dir: &std::path::Path) -> Config {
let workspace = dir.join("notes");
let data = dir.join("data");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&data).unwrap();
let mut cfg = Config::defaults();
cfg.workspace.root = workspace.to_string_lossy().into_owned();
cfg.storage.data_dir = data.to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
cfg
}
#[test]
fn ingest_stdin_writes_frontmatter_and_reports_new() {
let dir = tempfile::tempdir().unwrap();
let cfg = fresh_cfg(dir.path());
let report = kebab_app::ingest_stdin_with_config(
cfg.clone(),
"## Body content\n\nMore.",
"Article X",
Some("https://example.com/x"),
).unwrap();
assert_eq!(report.new, 1, "{report:?}");
// _external/ contains exactly one .md file with frontmatter.
let ext_dir = std::path::PathBuf::from(&cfg.workspace.root).join("_external");
let entries: Vec<_> = fs::read_dir(&ext_dir).unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(entries.len(), 1);
let content = fs::read_to_string(entries[0].path()).unwrap();
assert!(content.starts_with("---\n"));
assert!(content.contains("title: \"Article X\""));
assert!(content.contains("source_uri: \"https://example.com/x\""));
assert!(content.contains("## Body content"));
}
#[test]
fn ingest_stdin_without_source_uri() {
let dir = tempfile::tempdir().unwrap();
let cfg = fresh_cfg(dir.path());
let report = kebab_app::ingest_stdin_with_config(
cfg.clone(),
"## Body",
"Title",
None,
).unwrap();
assert_eq!(report.new, 1);
let ext_dir = std::path::PathBuf::from(&cfg.workspace.root).join("_external");
let entries: Vec<_> = fs::read_dir(&ext_dir).unwrap()
.filter_map(|e| e.ok())
.collect();
let content = fs::read_to_string(entries[0].path()).unwrap();
assert!(content.contains("title: \"Title\""));
assert!(!content.contains("source_uri"));
}
#[test]
fn ingest_stdin_errors_on_existing_frontmatter() {
let dir = tempfile::tempdir().unwrap();
let cfg = fresh_cfg(dir.path());
let body = "---\ntitle: Already\n---\n\n## Body";
let err = kebab_app::ingest_stdin_with_config(cfg, body, "New", None).unwrap_err();
assert!(err.to_string().contains("already has frontmatter"), "{err}");
}

View File

@@ -58,6 +58,10 @@ fn schema_report_reflects_freshly_ingested_kb() {
);
assert!(schema.capabilities.json_mode);
assert!(!schema.capabilities.streaming_ask);
assert!(
schema.capabilities.mcp_server,
"mcp_server should be true after fb-30",
);
assert_eq!(
schema.stats.doc_count, 2,
"expected 2 docs (a.md + b.md): {:?}",

View File

@@ -27,6 +27,8 @@ kebab-eval = { path = "../kebab-eval" }
# enforces the §8 boundary in its own Cargo.toml; kb-cli just
# launches it.
kebab-tui = { path = "../kebab-tui" }
# p9-fb-30: MCP stdio server. `Cmd::Mcp` delegates entirely to this crate.
kebab-mcp = { path = "../kebab-mcp" }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -44,6 +46,3 @@ ctrlc = "3"
[dev-dependencies]
tempfile = { workspace = true }
# llm_unreachable_classifies_to_model_unreachable test needs a real
# reqwest::Error (private constructor) — built from a connect-refused call.
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }

View File

@@ -4,12 +4,12 @@
use std::path::PathBuf;
use std::process::ExitCode;
use anyhow::Context;
use clap::{Parser, Subcommand};
use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
mod cancel;
mod error_classify;
mod progress;
mod wire;
@@ -189,6 +189,29 @@ enum Cmd {
#[command(subcommand)]
what: EvalWhat,
},
/// Run the MCP (Model Context Protocol) stdio server. Used by
/// agent hosts (Claude Code / Cursor / OpenAI Agents) to call kebab
/// tools (search / ask / schema / doctor).
Mcp,
/// Ingest a single file (workspace external paths allowed).
/// Bytes are copied into `<workspace.root>/_external/<hash>.<ext>`.
IngestFile {
/// File path to ingest.
path: std::path::PathBuf,
},
/// Ingest markdown content from stdin. v1 markdown only.
/// Frontmatter (title + source_uri) is auto-injected.
IngestStdin {
/// Title — required, written to frontmatter.
#[arg(long)]
title: String,
/// Source URI — optional, written to frontmatter when present.
#[arg(long)]
source_uri: Option<String>,
},
}
#[derive(Subcommand, Debug)]
@@ -282,7 +305,7 @@ fn main() -> ExitCode {
// caller); errors go to stderr.
if code != 1 {
if cli.json {
let v1 = error_classify::classify(&e, cli.verbose);
let v1 = kebab_app::classify(&e, cli.verbose);
let v = wire::wire_error_v1(&v1);
eprintln!("{}", serde_json::to_string(&v).unwrap_or_else(|_| {
"{\"schema_version\":\"error.v1\",\"code\":\"generic\",\"message\":\"serialize failed\"}".to_string()
@@ -740,6 +763,53 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
Ok(())
}
},
Cmd::IngestFile { path } => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
let report = kebab_app::ingest_file_with_config(cfg, path)?;
if cli.json {
let v = wire::wire_ingest(&report);
println!("{}", serde_json::to_string(&v)?);
} else {
println!(
"ingest-file: scanned={} new={} updated={} unchanged={} skipped={} errors={}",
report.scanned, report.new, report.updated,
report.unchanged, report.skipped, report.errors
);
}
Ok(())
}
Cmd::IngestStdin { title, source_uri } => {
use std::io::Read;
let mut body = String::new();
std::io::stdin()
.read_to_string(&mut body)
.context("kebab ingest-stdin: read stdin")?;
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
let report = kebab_app::ingest_stdin_with_config(
cfg,
&body,
title,
source_uri.as_deref(),
)?;
if cli.json {
let v = wire::wire_ingest(&report);
println!("{}", serde_json::to_string(&v)?);
} else {
println!(
"ingest-stdin: scanned={} new={} updated={} unchanged={} skipped={} errors={}",
report.scanned, report.new, report.updated,
report.unchanged, report.skipped, report.errors
);
}
Ok(())
}
Cmd::Mcp => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
kebab_mcp::serve_stdio(cfg, cli.config.clone())
}
}
}

View File

@@ -152,12 +152,12 @@ pub fn wire_schema(s: &kebab_app::SchemaV1) -> Value {
tag_object(v, kebab_app::SCHEMA_V1_ID)
}
/// Wrap an [`crate::error_classify::ErrorV1`] as `error.v1`.
/// Wrap an [`kebab_app::ErrorV1`] as `error.v1`.
///
/// Uses the simple `tag_object` pattern because `ErrorV1` is a
/// kebab-cli-local type that does NOT carry `schema_version` itself
/// type that does NOT carry `schema_version` itself
/// (kebab-core convention).
pub fn wire_error_v1(e: &crate::error_classify::ErrorV1) -> Value {
pub fn wire_error_v1(e: &kebab_app::ErrorV1) -> Value {
let v = serde_json::to_value(e).expect("ErrorV1 serializes");
tag_object(v, "error.v1")
}
@@ -262,8 +262,9 @@ mod tests {
#[test]
fn error_wrapper_tags_schema_version_and_emits_code() {
use crate::error_classify::ErrorV1;
use kebab_app::ErrorV1;
let err = ErrorV1 {
schema_version: "error.v1".to_string(),
code: "config_invalid".to_string(),
message: "bad config".to_string(),
details: serde_json::json!({"path": "/tmp/x"}),

View File

@@ -0,0 +1,92 @@
//! Integration: spawn `kebab ingest-file <path>` and verify ingest_report.v1.
use std::fs;
use std::process::Command;
#[test]
fn cli_ingest_file_emits_ingest_report_v1() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("notes");
let data = dir.path().join("data");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&data).unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(
&cfg_path,
format!(
r#"schema_version = 1
[workspace]
root = "{workspace}"
exclude = [".git/**"]
[storage]
data_dir = "{data}"
sqlite = "{{data_dir}}/kebab.sqlite"
vector_dir = "{{data_dir}}/lancedb"
asset_dir = "{{data_dir}}/assets"
artifact_dir = "{{data_dir}}/artifacts"
model_dir = "{{data_dir}}/models"
runs_dir = "{{data_dir}}/runs"
copy_threshold_mb = 100
[indexing]
max_parallel_extractors = 2
max_parallel_embeddings = 1
watch_filesystem = false
[chunking]
target_tokens = 500
overlap_tokens = 80
respect_markdown_headings = true
chunker_version = "md-heading-v1"
[models.embedding]
provider = "none"
model = "none"
version = "v0"
dimensions = 0
batch_size = 1
[models.llm]
provider = "ollama"
model = "none"
context_tokens = 4096
endpoint = "http://127.0.0.1:11434"
temperature = 0.0
seed = 0
[search]
default_k = 10
hybrid_fusion = "rrf"
rrf_k = 60
snippet_chars = 220
[rag]
prompt_template_version = "rag-v1"
score_gate = 0.30
explain_default = false
max_context_tokens = 8000
"#,
workspace = workspace.display(),
data = data.display(),
),
).unwrap();
let src = dir.path().join("doc.md");
fs::write(&src, "# A\n\nbody.").unwrap();
let bin = env!("CARGO_BIN_EXE_kebab");
let out = Command::new(bin)
.args(["--json", "--config", cfg_path.to_str().unwrap(), "ingest-file"])
.arg(&src)
.output()
.unwrap();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("ingest_report.v1"));
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
}

View File

@@ -0,0 +1,100 @@
//! Integration: spawn `kebab ingest-stdin --title X` with stdin pipe.
use std::fs;
use std::io::Write;
use std::process::{Command, Stdio};
#[test]
fn cli_ingest_stdin_emits_ingest_report_v1() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("notes");
let data = dir.path().join("data");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&data).unwrap();
let cfg_path = dir.path().join("config.toml");
fs::write(
&cfg_path,
format!(
r#"schema_version = 1
[workspace]
root = "{workspace}"
exclude = [".git/**"]
[storage]
data_dir = "{data}"
sqlite = "{{data_dir}}/kebab.sqlite"
vector_dir = "{{data_dir}}/lancedb"
asset_dir = "{{data_dir}}/assets"
artifact_dir = "{{data_dir}}/artifacts"
model_dir = "{{data_dir}}/models"
runs_dir = "{{data_dir}}/runs"
copy_threshold_mb = 100
[indexing]
max_parallel_extractors = 2
max_parallel_embeddings = 1
watch_filesystem = false
[chunking]
target_tokens = 500
overlap_tokens = 80
respect_markdown_headings = true
chunker_version = "md-heading-v1"
[models.embedding]
provider = "none"
model = "none"
version = "v0"
dimensions = 0
batch_size = 1
[models.llm]
provider = "ollama"
model = "none"
context_tokens = 4096
endpoint = "http://127.0.0.1:11434"
temperature = 0.0
seed = 0
[search]
default_k = 10
hybrid_fusion = "rrf"
rrf_k = 60
snippet_chars = 220
[rag]
prompt_template_version = "rag-v1"
score_gate = 0.30
explain_default = false
max_context_tokens = 8000
"#,
workspace = workspace.display(),
data = data.display(),
),
).unwrap();
let bin = env!("CARGO_BIN_EXE_kebab");
let mut child = Command::new(bin)
.args([
"--json", "--config", cfg_path.to_str().unwrap(),
"ingest-stdin", "--title", "X",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(b"## Body\n\nbody text.\n").unwrap();
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("ingest_report.v1"));
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
}

View File

@@ -0,0 +1,77 @@
//! Spawn `target/debug/kebab mcp` and exercise initialize → tools/list.
//!
//! rmcp 1.6 has no public in-memory test transport, so this is the only
//! end-to-end MCP assertion in the suite. The binary is located via
//! `CARGO_BIN_EXE_kebab` which cargo injects at test compile time.
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
#[test]
fn cli_mcp_initialize_then_tools_list() {
let bin = env!("CARGO_BIN_EXE_kebab");
let mut child = Command::new(bin)
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
// rmcp 1.6 defaults to protocol version "2025-03-26" (confirmed by
// manual smoke in Task 10). The server echoes whatever version the
// client sends during the handshake, so this literal must match.
let init_req = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#;
writeln!(stdin, "{init_req}").unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#
)
.unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{{}}}}"#
)
.unwrap();
// Read initialize response.
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let init: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(
init.get("id").and_then(|i| i.as_i64()),
Some(1),
"unexpected id in initialize response: {init}"
);
assert!(
init.get("result").is_some(),
"initialize result missing: {init}"
);
// Read tools/list response.
line.clear();
reader.read_line(&mut line).unwrap();
let list: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(
list.get("id").and_then(|i| i.as_i64()),
Some(2),
"unexpected id in tools/list response: {list}"
);
let tools = list["result"]["tools"]
.as_array()
.expect("tools/list result.tools must be an array");
assert_eq!(
tools.len(),
6,
"expected 6 tools (schema, doctor, search, ask, ingest_file, ingest_stdin), got {}: {list}",
tools.len()
);
// Gracefully close stdin so the server shuts down cleanly.
drop(stdin);
let _ = child.wait().unwrap();
}

View File

@@ -92,8 +92,8 @@ fn cli_schema_json_emits_schema_v1() {
);
assert_eq!(
caps.get("mcp_server").and_then(|b| b.as_bool()),
Some(false),
"capabilities.mcp_server must be false (not yet shipped)"
Some(true),
"capabilities.mcp_server must be true (fb-30)"
);
}

View File

@@ -0,0 +1,27 @@
[package]
name = "kebab-mcp"
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
version = { workspace = true }
[dependencies]
rmcp = { workspace = true }
# rt-multi-thread + io-util + io-std extend the workspace tokio entry
# (which only declares rt + macros) for the blocking stdio MCP transport.
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "io-std"] }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
# schemars 1.x matches rmcp 1.6's ^1.0 requirement (verified via crates.io
# /dependencies endpoint — rmcp declares optional schemars = "^1.0").
schemars = "1"
kebab-app = { path = "../kebab-app" }
kebab-config = { path = "../kebab-config" }
kebab-core = { path = "../kebab-core" }
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -0,0 +1,22 @@
//! Map `anyhow::Error` returned by kebab-app facades to MCP
//! `CallToolResult` with `isError: true` + error.v1 JSON content.
use rmcp::model::{CallToolResult, Content};
use kebab_app::classify;
/// Convert an `anyhow::Error` to a `CallToolResult` with `isError: true`
/// and the serialised `error.v1` envelope as the text content.
pub fn to_tool_error(err: &anyhow::Error) -> CallToolResult {
let v1 = classify(err, false);
let body = serde_json::to_string(&v1).unwrap_or_else(|_| {
r#"{"schema_version":"error.v1","code":"generic","message":"serialize failed"}"#
.to_string()
});
CallToolResult::error(vec![Content::text(body)])
}
/// Wrap a successful wire-schema JSON string as a `CallToolResult`.
pub fn to_tool_success(json: String) -> CallToolResult {
CallToolResult::success(vec![Content::text(json)])
}

188
crates/kebab-mcp/src/lib.rs Normal file
View File

@@ -0,0 +1,188 @@
//! MCP (Model Context Protocol) server over stdio. Exposes 6 tools
//! (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`)
//! backed by `kebab-app` facade methods. Used by `kebab-cli`'s `Cmd::Mcp` arm.
//!
//! See spec `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`.
use std::path::PathBuf;
use anyhow::Result;
use rmcp::ServerHandler;
use rmcp::handler::server::common::{schema_for_empty_input, schema_for_type};
use rmcp::model::{
CallToolRequestParams, CallToolResult, Implementation, ListToolsResult, ServerCapabilities,
ServerInfo, Tool,
};
use rmcp::service::{RequestContext, ServiceExt};
use rmcp::transport::stdio;
use rmcp::{ErrorData, RoleServer};
use kebab_config::Config;
pub mod error;
pub mod state;
pub mod tools;
pub use state::KebabAppState;
/// Build the canonical list of tools exposed by the MCP server.
///
/// Extracted from [`ServerHandler::list_tools`] so it can be called
/// directly in tests without constructing a `RequestContext`.
pub fn build_tools_vec() -> Vec<Tool> {
vec![
Tool::new(
"schema",
"Introspection — wire schemas, capabilities, model versions, index stats.",
schema_for_empty_input(),
),
Tool::new(
"doctor",
"Health check — verifies config, storage, models, and Ollama connectivity.",
schema_for_empty_input(),
),
Tool::new(
"search",
"Full-text / vector / hybrid search over the knowledge base. Returns search_hit.v1 array.",
schema_for_type::<tools::search::SearchInput>(),
),
Tool::new(
"ask",
"RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.",
schema_for_type::<tools::ask::AskInput>(),
),
Tool::new(
"ingest_file",
"Ingest a single file (path) into the knowledge base. Workspace external paths allowed — bytes are copied into _external/.",
schema_for_type::<tools::ingest_file::IngestFileInput>(),
),
Tool::new(
"ingest_stdin",
"Ingest markdown content into the knowledge base. v1 markdown only. Frontmatter (title + source_uri) auto-injected.",
schema_for_type::<tools::ingest_stdin::IngestStdinInput>(),
),
]
}
#[derive(Clone)]
pub struct KebabHandler {
state: KebabAppState,
}
impl KebabHandler {
pub fn new(state: KebabAppState) -> Self {
Self { state }
}
pub fn state(&self) -> &KebabAppState {
&self.state
}
/// Spawn a tool handler on the blocking pool. Used by tools that
/// transitively touch reqwest::blocking::Client (search, ask) — calling
/// from the async dispatch directly panics inside the runtime.
async fn spawn_tool<I, F>(
&self,
args: serde_json::Map<String, serde_json::Value>,
handle: F,
) -> Result<CallToolResult, ErrorData>
where
I: serde::de::DeserializeOwned + Send + 'static,
F: FnOnce(KebabAppState, I) -> CallToolResult + Send + 'static,
{
let input: I = match serde_json::from_value(serde_json::Value::Object(args)) {
Ok(i) => i,
Err(e) => return Ok(error::to_tool_error(&anyhow::Error::from(e))),
};
let state = self.state.clone();
tokio::task::spawn_blocking(move || handle(state, input))
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))
}
}
impl ServerHandler for KebabHandler {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("kebab", env!("CARGO_PKG_VERSION")))
}
async fn list_tools(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
Ok(ListToolsResult::with_all_items(build_tools_vec()))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
match request.name.as_ref() {
"schema" => {
let input = tools::schema::SchemaInput::default();
Ok(tools::schema::handle(&self.state, input))
}
"doctor" => {
let input = tools::doctor::DoctorInput::default();
Ok(tools::doctor::handle(&self.state, input))
}
"search" => {
let args = request.arguments.unwrap_or_default();
self.spawn_tool(args, |state, input| {
tools::search::handle(&state, input)
})
.await
}
"ask" => {
let args = request.arguments.unwrap_or_default();
self.spawn_tool(args, |state, input| {
tools::ask::handle(&state, input)
})
.await
}
"ingest_file" => {
let args = request.arguments.unwrap_or_default();
self.spawn_tool(args, |state, input| {
tools::ingest_file::handle(&state, input)
})
.await
}
"ingest_stdin" => {
let args = request.arguments.unwrap_or_default();
self.spawn_tool(args, |state, input| {
tools::ingest_stdin::handle(&state, input)
})
.await
}
_other => Err(ErrorData::method_not_found::<
rmcp::model::CallToolRequestMethod,
>()),
}
}
}
/// Run the MCP server on stdio JSON-RPC. Blocks until the client closes
/// the stream (typically when the agent host exits).
///
/// `config_path` is the path passed via `--config <path>`, if any.
/// It is forwarded to `KebabAppState` so the doctor tool can honour the
/// same config file the server was started with (falls back to XDG default
/// when `None`).
pub fn serve_stdio(cfg: Config, config_path: Option<PathBuf>) -> Result<()> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
runtime.block_on(serve_stdio_async(cfg, config_path))
}
async fn serve_stdio_async(cfg: Config, config_path: Option<PathBuf>) -> Result<()> {
tracing::info!("kebab-mcp: starting stdio server");
let state = KebabAppState::new(cfg, config_path);
let handler = KebabHandler::new(state);
let service = handler.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}

View File

@@ -0,0 +1,26 @@
//! Long-lived server state — holds Config so per-request handlers don't
//! reload from disk. Future: cache opened SqliteStore / Lance handles
//! here so first tool call pays the cost, subsequent calls hit warm
//! state.
use std::path::PathBuf;
use std::sync::Arc;
use kebab_config::Config;
#[derive(Clone)]
pub struct KebabAppState {
pub config: Arc<Config>,
/// `--config <path>` from CLI when present, else `None` (XDG default
/// fallback applies in `doctor_with_config_path`).
pub config_path: Option<PathBuf>,
}
impl KebabAppState {
pub fn new(config: Config, config_path: Option<PathBuf>) -> Self {
Self {
config: Arc::new(config),
config_path,
}
}
}

View File

@@ -0,0 +1,68 @@
//! `ask` tool — wraps `kebab_app::ask_with_config` (single-shot) or
//! `kebab_app::ask_with_session_with_config` when `session_id` is provided.
//! Input: { query, session_id?, mode? }. Output: answer.v1 JSON.
//!
//! `Answer` (kebab-core) does NOT carry a `schema_version` field; we tag
//! it inline here, matching the pattern from `search.rs`.
use rmcp::model::CallToolResult;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::{to_tool_error, to_tool_success};
use crate::state::KebabAppState;
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct AskInput {
/// The user question.
pub query: String,
/// Optional session id for multi-turn RAG context.
pub session_id: Option<String>,
/// Optional retrieval mode override ("lexical" / "vector" / "hybrid"). Default "hybrid".
pub mode: Option<String>,
}
pub fn handle(state: &KebabAppState, input: AskInput) -> CallToolResult {
let mode = match input.mode.as_deref() {
Some("lexical") => kebab_core::SearchMode::Lexical,
Some("vector") => kebab_core::SearchMode::Vector,
_ => kebab_core::SearchMode::Hybrid, // default + "hybrid" + unknown
};
let opts = kebab_app::AskOpts {
k: 10,
explain: false,
mode,
temperature: None,
seed: None,
stream_sink: None,
history: Vec::new(),
conversation_id: None,
turn_index: None,
};
let cfg_clone = (*state.config).clone();
let result = match input.session_id {
Some(sid) => {
kebab_app::ask_with_session_with_config(cfg_clone, &sid, &input.query, opts)
}
None => kebab_app::ask_with_config(cfg_clone, &input.query, opts),
};
match result {
Ok(answer) => {
// `Answer` does not carry `schema_version`; tag inline (idempotent
// via entry().or_insert_with in case a future version adds it).
let mut v = match serde_json::to_value(&answer) {
Ok(v) => v,
Err(e) => return to_tool_error(&anyhow::anyhow!("answer serialize failed: {e}")),
};
if let serde_json::Value::Object(ref mut map) = v {
map.entry("schema_version".to_string())
.or_insert_with(|| serde_json::Value::String("answer.v1".to_string()));
}
match serde_json::to_string(&v) {
Ok(json) => to_tool_success(json),
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
}
}
Err(e) => to_tool_error(&e),
}
}

View File

@@ -0,0 +1,28 @@
//! `doctor` tool — wraps `kebab_app::doctor_with_config_path`.
//! Input: {} (no args). Output: doctor.v1 JSON.
//!
//! `doctor_with_config_path(Option<&Path>)` re-reads config from disk so
//! the report reflects the live file state. We forward `config_path` from
//! `KebabAppState` so `--config <path>` users see results for their file;
//! callers that pass `None` fall back to the XDG default (same as the CLI
//! bare `kebab doctor`).
use rmcp::model::CallToolResult;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::{to_tool_error, to_tool_success};
use crate::state::KebabAppState;
#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
pub struct DoctorInput {}
pub fn handle(state: &KebabAppState, _input: DoctorInput) -> CallToolResult {
match kebab_app::doctor_with_config_path(state.config_path.as_deref()) {
Ok(report) => match serde_json::to_string(&report) {
Ok(json) => to_tool_success(json),
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
},
Err(e) => to_tool_error(&e),
}
}

View File

@@ -0,0 +1,39 @@
//! `ingest_file` tool — wraps `kebab_app::ingest_file_with_config`.
//! Input: { path }. Output: ingest_report.v1 JSON.
use std::path::PathBuf;
use rmcp::model::CallToolResult;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::{to_tool_error, to_tool_success};
use crate::state::KebabAppState;
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct IngestFileInput {
/// Absolute or relative path to the file to ingest. Workspace external
/// paths are allowed — bytes are copied into `_external/`.
pub path: String,
}
pub fn handle(state: &KebabAppState, input: IngestFileInput) -> CallToolResult {
let cfg_clone = (*state.config).clone();
let path = PathBuf::from(input.path);
match kebab_app::ingest_file_with_config(cfg_clone, &path) {
Ok(report) => match serde_json::to_value(&report) {
Ok(mut v) => {
if let serde_json::Value::Object(ref mut map) = v {
map.entry("schema_version".to_string())
.or_insert_with(|| serde_json::Value::String("ingest_report.v1".to_string()));
}
match serde_json::to_string(&v) {
Ok(json) => to_tool_success(json),
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
}
}
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
},
Err(e) => to_tool_error(&e),
}
}

View File

@@ -0,0 +1,44 @@
//! `ingest_stdin` tool — wraps `kebab_app::ingest_stdin_with_config`.
//! Input: { content, title, source_uri? }. Output: ingest_report.v1 JSON.
use rmcp::model::CallToolResult;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::{to_tool_error, to_tool_success};
use crate::state::KebabAppState;
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct IngestStdinInput {
/// Markdown body content. v1 supports markdown only.
pub content: String,
/// Title for frontmatter injection.
pub title: String,
/// Optional source URI (e.g. https URL agent fetched from).
pub source_uri: Option<String>,
}
pub fn handle(state: &KebabAppState, input: IngestStdinInput) -> CallToolResult {
let cfg_clone = (*state.config).clone();
match kebab_app::ingest_stdin_with_config(
cfg_clone,
&input.content,
&input.title,
input.source_uri.as_deref(),
) {
Ok(report) => match serde_json::to_value(&report) {
Ok(mut v) => {
if let serde_json::Value::Object(ref mut map) = v {
map.entry("schema_version".to_string())
.or_insert_with(|| serde_json::Value::String("ingest_report.v1".to_string()));
}
match serde_json::to_string(&v) {
Ok(json) => to_tool_success(json),
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
}
}
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
},
Err(e) => to_tool_error(&e),
}
}

View File

@@ -0,0 +1,8 @@
//! Tool implementations — one module per tool.
pub mod schema;
pub mod doctor;
pub mod search;
pub mod ask;
pub mod ingest_file;
pub mod ingest_stdin;

View File

@@ -0,0 +1,22 @@
//! `schema` tool — wraps `kebab_app::schema_with_config`.
//! Input: {} (no args). Output: schema.v1 JSON.
use rmcp::model::CallToolResult;
use serde::{Deserialize, Serialize};
use schemars::JsonSchema;
use crate::error::{to_tool_error, to_tool_success};
use crate::state::KebabAppState;
#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
pub struct SchemaInput {}
pub fn handle(state: &KebabAppState, _input: SchemaInput) -> CallToolResult {
match kebab_app::schema_with_config(&state.config) {
Ok(report) => match serde_json::to_string(&report) {
Ok(json) => to_tool_success(json),
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
},
Err(e) => to_tool_error(&e),
}
}

View File

@@ -0,0 +1,71 @@
//! `search` tool — wraps `kebab_app::search_with_config`.
//! Input: { query, mode?, k? }. Output: search_hit.v1 array JSON.
//!
//! First tool with a non-empty `inputSchema`: `SearchInput` derives
//! `JsonSchema` and `Tool::new` uses
//! `rmcp::handler::server::common::schema_for_type::<SearchInput>()`.
use rmcp::model::CallToolResult;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::{to_tool_error, to_tool_success};
use crate::state::KebabAppState;
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct SearchInput {
/// User query (free text).
pub query: String,
/// Retrieval mode: "hybrid" (default), "lexical", or "vector".
#[serde(default = "default_mode")]
pub mode: String,
/// Top-K results. Defaults to 10. Clamped to 1100.
#[serde(default = "default_k")]
pub k: usize,
}
fn default_mode() -> String {
"hybrid".to_string()
}
fn default_k() -> usize {
10
}
pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
let k = input.k.clamp(1, 100);
let mode = match input.mode.as_str() {
"lexical" => kebab_core::SearchMode::Lexical,
"vector" => kebab_core::SearchMode::Vector,
_ => kebab_core::SearchMode::Hybrid,
};
let query = kebab_core::SearchQuery {
text: input.query,
mode,
k,
filters: kebab_core::SearchFilters::default(),
};
match kebab_app::search_with_config((*state.config).clone(), query) {
Ok(hits) => {
// SearchHit (kebab-core) does not carry a `schema_version` field,
// so we tag each element inline before serialising.
let tagged: Vec<serde_json::Value> = hits
.iter()
.map(|h| {
let mut v = serde_json::to_value(h).unwrap_or_default();
if let serde_json::Value::Object(ref mut map) = v {
map.insert(
"schema_version".to_string(),
serde_json::Value::String("search_hit.v1".to_string()),
);
}
v
})
.collect();
match serde_json::to_string(&serde_json::Value::Array(tagged)) {
Ok(json) => to_tool_success(json),
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
}
}
Err(e) => to_tool_error(&e),
}
}

View File

@@ -0,0 +1,36 @@
//! tools/call with bad config → isError=true + error.v1 content.
use kebab_config::Config;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::model::RawContent;
#[tokio::test]
async fn schema_tool_emits_error_v1_when_db_missing() {
// Point at a directory that does NOT have kebab.sqlite.
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
cfg.workspace.root = dir.path().join("notes").to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
// Note: NO ingest call — kebab.sqlite is absent → schema_with_config
// calls open_existing → NotIndexed → tool error.
let state = KebabAppState::new(cfg, None);
let handler = KebabHandler::new(state);
let result = kebab_mcp::tools::schema::handle(
handler.state(),
kebab_mcp::tools::schema::SchemaInput::default(),
);
assert_eq!(result.is_error, Some(true), "expected isError=true on missing DB");
let content = result.content.first().unwrap();
let text = match &content.raw {
RawContent::Text(t) => &t.text,
other => panic!("expected text content, got {other:?}"),
};
let v: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("error.v1"));
assert_eq!(v.get("code").and_then(|s| s.as_str()), Some("not_indexed"));
}

View File

@@ -0,0 +1,19 @@
//! Integration: KebabHandler::get_info returns correct kebab serverInfo.
//! Doesn't exercise full transport — that lands when we have at least
//! one tool to call (Task 4+).
use kebab_config::Config;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::ServerHandler;
#[tokio::test]
async fn initialize_returns_kebab_server_info() {
let cfg = Config::defaults();
let state = KebabAppState::new(cfg, None);
let handler = KebabHandler::new(state);
let info = handler.get_info();
assert_eq!(info.server_info.name, "kebab");
assert!(!info.server_info.version.is_empty());
assert!(info.capabilities.tools.is_some());
}

View File

@@ -0,0 +1,92 @@
//! `ask` tool returns answer.v1 — refusal path covered (no Ollama
//! required for refusal-on-empty-corpus case).
use kebab_config::Config;
use kebab_core::SourceScope;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::model::RawContent;
fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config {
let mut cfg = Config::defaults();
cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
cfg.storage.model_dir = data_dir
.join("models")
.to_string_lossy()
.into_owned();
cfg.workspace.root = workspace_root.to_string_lossy().into_owned();
cfg.workspace.exclude.clear();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
cfg
}
#[tokio::test]
async fn ask_tool_returns_answer_v1_with_refusal_on_empty_kb() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().join("data");
let workspace_root = dir.path().join("notes");
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::create_dir_all(&workspace_root).unwrap();
let cfg = minimal_config(&data_dir, &workspace_root);
// Seed kebab.sqlite (empty corpus — no documents ingested).
let scope = SourceScope {
root: workspace_root.clone(),
include: vec![],
exclude: vec![],
};
let _ = kebab_app::ingest_with_config(cfg.clone(), scope, false).unwrap();
let state = KebabAppState::new(cfg, None);
let handler = KebabHandler::new(state);
// `ask_with_config` builds a `reqwest::blocking::Client` internally (for
// `OllamaLanguageModel`), which spins up and drops a tokio runtime — that
// panics when called from inside an async context. Run it on the blocking
// thread pool to avoid the conflict.
let state_clone = handler.state().clone();
let result = tokio::task::spawn_blocking(move || {
kebab_mcp::tools::ask::handle(
&state_clone,
kebab_mcp::tools::ask::AskInput {
query: "what is the meaning of life".to_string(),
session_id: None,
// Test env uses provider="none" — Hybrid would hard-error on embedding.
// Pass Lexical explicitly so the test stays functional.
mode: Some("lexical".to_string()),
},
)
})
.await
.unwrap();
// Empty KB → refusal (grounded:false) is normal — NOT isError.
assert!(
!result.is_error.unwrap_or(false),
"expected isError=false on refusal, got {:?}",
result
);
let content = result
.content
.first()
.expect("expected at least one content item");
let text = match &content.raw {
RawContent::Text(t) => &t.text,
other => panic!("expected text content, got {other:?}"),
};
let v: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(
v.get("schema_version").and_then(|s| s.as_str()),
Some("answer.v1"),
"response should carry schema_version=answer.v1"
);
assert_eq!(
v.get("grounded").and_then(|b| b.as_bool()),
Some(false),
"empty KB should produce grounded=false"
);
}

View File

@@ -0,0 +1,50 @@
//! Integration: tools/call name=doctor — returns doctor.v1.
use kebab_config::Config;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::model::RawContent;
#[tokio::test]
async fn doctor_tool_returns_doctor_v1_json() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config::defaults();
cfg.storage.data_dir = dir.path().join("data").to_string_lossy().into_owned();
cfg.workspace.root = dir.path().join("notes").to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
std::fs::create_dir_all(&cfg.workspace.root).unwrap();
// Pass None for config_path — doctor falls back to XDG default probe
// (path won't exist in the tempdir, which is fine; doctor reports it
// as missing / error rather than panicking).
let state = KebabAppState::new(cfg, None);
let handler = KebabHandler::new(state);
let result = kebab_mcp::tools::doctor::handle(
handler.state(),
kebab_mcp::tools::doctor::DoctorInput::default(),
);
let content = result
.content
.first()
.expect("expected at least one content item");
let text = match &content.raw {
RawContent::Text(t) => &t.text,
other => panic!("expected text content, got {other:?}"),
};
let v: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(
v.get("schema_version").and_then(|s| s.as_str()),
Some("doctor.v1"),
"unexpected schema_version in: {v}"
);
// `ok` boolean must be present (value may be false in CI where Ollama
// is not reachable — that's expected and acceptable).
assert!(
v.get("ok").and_then(|b| b.as_bool()).is_some(),
"`ok` field missing in doctor.v1 response: {v}"
);
}

View File

@@ -0,0 +1,117 @@
//! Integration: tools/call name=ingest_file → ingest_report.v1.
use std::fs;
use kebab_config::Config;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::model::RawContent;
#[tokio::test]
async fn ingest_file_tool_returns_ingest_report_v1() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("notes");
let data = dir.path().join("data");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&data).unwrap();
let mut cfg = Config::defaults();
cfg.workspace.root = workspace.to_string_lossy().into_owned();
cfg.storage.data_dir = data.to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
let src = dir.path().join("doc.md");
fs::write(&src, "# Title\n\nbody.").unwrap();
let state = KebabAppState::new(cfg, None);
let handler = KebabHandler::new(state);
let result = tokio::task::spawn_blocking({
let state = handler.state().clone();
let path = src.to_string_lossy().into_owned();
move || {
kebab_mcp::tools::ingest_file::handle(
&state,
kebab_mcp::tools::ingest_file::IngestFileInput { path },
)
}
})
.await
.unwrap();
assert!(!result.is_error.unwrap_or(false), "{result:?}");
let text = match &result.content.first().unwrap().raw {
RawContent::Text(t) => &t.text,
other => panic!("expected text content, got {other:?}"),
};
let v: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(
v.get("schema_version").and_then(|s| s.as_str()),
Some("ingest_report.v1")
);
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
}
#[tokio::test]
async fn ingest_file_tool_idempotent_on_second_call() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("notes");
let data = dir.path().join("data");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::create_dir_all(&data).unwrap();
let mut cfg = kebab_config::Config::defaults();
cfg.workspace.root = workspace.to_string_lossy().into_owned();
cfg.storage.data_dir = data.to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
let src = dir.path().join("doc.md");
std::fs::write(&src, "# A\n\nbody.").unwrap();
let state = kebab_mcp::KebabAppState::new(cfg, None);
let handler = kebab_mcp::KebabHandler::new(state);
// First call.
let r1 = tokio::task::spawn_blocking({
let state = handler.state().clone();
let path = src.to_string_lossy().into_owned();
move || {
kebab_mcp::tools::ingest_file::handle(
&state,
kebab_mcp::tools::ingest_file::IngestFileInput { path },
)
}
})
.await
.unwrap();
assert!(!r1.is_error.unwrap_or(false));
let text1 = match &r1.content.first().unwrap().raw {
rmcp::model::RawContent::Text(t) => &t.text,
other => panic!("expected text, got {other:?}"),
};
let v1: serde_json::Value = serde_json::from_str(text1).unwrap();
assert_eq!(v1.get("new").and_then(|n| n.as_u64()), Some(1));
// Second call — same content, expect unchanged=1.
let r2 = tokio::task::spawn_blocking({
let state = handler.state().clone();
let path = src.to_string_lossy().into_owned();
move || {
kebab_mcp::tools::ingest_file::handle(
&state,
kebab_mcp::tools::ingest_file::IngestFileInput { path },
)
}
})
.await
.unwrap();
assert!(!r2.is_error.unwrap_or(false));
let text2 = match &r2.content.first().unwrap().raw {
rmcp::model::RawContent::Text(t) => &t.text,
other => panic!("expected text, got {other:?}"),
};
let v2: serde_json::Value = serde_json::from_str(text2).unwrap();
assert_eq!(v2.get("new").and_then(|n| n.as_u64()), Some(0), "{v2:?}");
assert_eq!(v2.get("unchanged").and_then(|n| n.as_u64()), Some(1), "{v2:?}");
}

View File

@@ -0,0 +1,89 @@
//! Integration: tools/call name=ingest_stdin → ingest_report.v1.
//! Frontmatter precheck path also covered.
use std::fs;
use kebab_config::Config;
use kebab_mcp::KebabAppState;
use rmcp::model::RawContent;
fn fresh_state(dir: &std::path::Path) -> KebabAppState {
let workspace = dir.join("notes");
let data = dir.join("data");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&data).unwrap();
let mut cfg = Config::defaults();
cfg.workspace.root = workspace.to_string_lossy().into_owned();
cfg.storage.data_dir = data.to_string_lossy().into_owned();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
KebabAppState::new(cfg, None)
}
#[tokio::test]
async fn ingest_stdin_tool_returns_ingest_report_v1() {
let dir = tempfile::tempdir().unwrap();
let state = fresh_state(dir.path());
let result = tokio::task::spawn_blocking({
let state = state.clone();
move || {
kebab_mcp::tools::ingest_stdin::handle(
&state,
kebab_mcp::tools::ingest_stdin::IngestStdinInput {
content: "## Body".to_string(),
title: "X".to_string(),
source_uri: Some("https://example.com/x".to_string()),
},
)
}
})
.await
.unwrap();
assert!(!result.is_error.unwrap_or(false), "{result:?}");
let text = match &result.content.first().unwrap().raw {
RawContent::Text(t) => &t.text,
other => panic!("expected text content, got {other:?}"),
};
let v: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(
v.get("schema_version").and_then(|s| s.as_str()),
Some("ingest_report.v1")
);
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
}
#[tokio::test]
async fn ingest_stdin_tool_emits_error_v1_on_existing_frontmatter() {
let dir = tempfile::tempdir().unwrap();
let state = fresh_state(dir.path());
let result = tokio::task::spawn_blocking({
let state = state.clone();
move || {
kebab_mcp::tools::ingest_stdin::handle(
&state,
kebab_mcp::tools::ingest_stdin::IngestStdinInput {
content: "---\ntitle: Existing\n---\n\n## Body".to_string(),
title: "New".to_string(),
source_uri: None,
},
)
}
})
.await
.unwrap();
assert_eq!(result.is_error, Some(true), "{result:?}");
let text = match &result.content.first().unwrap().raw {
RawContent::Text(t) => &t.text,
other => panic!("expected text content, got {other:?}"),
};
let v: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(
v.get("schema_version").and_then(|s| s.as_str()),
Some("error.v1")
);
}

View File

@@ -0,0 +1,75 @@
//! Integration: tools/call name=schema — verify response is schema.v1.
use std::fs;
use kebab_config::Config;
use kebab_core::SourceScope;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::model::RawContent;
fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config {
let mut cfg = Config::defaults();
cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
cfg.storage.model_dir = data_dir
.join("models")
.to_string_lossy()
.into_owned();
cfg.workspace.root = workspace_root.to_string_lossy().into_owned();
cfg.workspace.exclude.clear();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
cfg
}
#[tokio::test]
async fn schema_tool_returns_schema_v1_json() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().join("data");
let workspace_root = dir.path().join("notes");
fs::create_dir_all(&data_dir).unwrap();
fs::create_dir_all(&workspace_root).unwrap();
let config = minimal_config(&data_dir, &workspace_root);
// Seed kebab.sqlite via 0-file ingest so open_existing succeeds later.
let scope = SourceScope {
root: workspace_root.clone(),
include: vec![],
exclude: vec![],
};
let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap();
let state = KebabAppState::new(config, None);
let handler = KebabHandler::new(state);
let result = kebab_mcp::tools::schema::handle(
handler.state(),
kebab_mcp::tools::schema::SchemaInput::default(),
);
assert!(
!result.is_error.unwrap_or(false),
"expected isError=false on healthy schema, got {:?}",
result
);
let content = result.content.first().expect("expected at least one content item");
// Content = Annotated<RawContent>; deref to get the inner RawContent.
let text = match &content.raw {
RawContent::Text(t) => &t.text,
other => panic!("expected text content, got {other:?}"),
};
let v: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(
v.get("schema_version").and_then(|s| s.as_str()),
Some("schema.v1"),
"unexpected schema_version in: {v}"
);
assert_eq!(
v.get("capabilities").and_then(|c| c.get("mcp_server")).and_then(|b| b.as_bool()),
Some(true),
"mcp_server capability flag should be true after fb-30",
);
}

View File

@@ -0,0 +1,90 @@
//! Integration: tools/call name=search — verify response is search_hit.v1 array.
use std::fs;
use kebab_config::Config;
use kebab_core::SourceScope;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::model::RawContent;
fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config {
let mut cfg = Config::defaults();
cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
cfg.storage.model_dir = data_dir
.join("models")
.to_string_lossy()
.into_owned();
cfg.workspace.root = workspace_root.to_string_lossy().into_owned();
cfg.workspace.exclude.clear();
cfg.models.embedding.provider = "none".to_string();
cfg.models.embedding.dimensions = 0;
cfg
}
#[tokio::test]
async fn search_tool_returns_search_hits_array() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().join("data");
let workspace_root = dir.path().join("notes");
fs::create_dir_all(&data_dir).unwrap();
fs::create_dir_all(&workspace_root).unwrap();
let config = minimal_config(&data_dir, &workspace_root);
// Write a markdown document containing the query term.
fs::write(
workspace_root.join("a.md"),
"# Alpha\n\nThis document mentions kebab and bread.",
)
.unwrap();
// Seed kebab.sqlite via ingest so search has indexed content.
let scope = SourceScope {
root: workspace_root.clone(),
include: vec![],
exclude: vec![],
};
let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap();
let state = KebabAppState::new(config, None);
let handler = KebabHandler::new(state);
let result = kebab_mcp::tools::search::handle(
handler.state(),
kebab_mcp::tools::search::SearchInput {
query: "kebab".to_string(),
mode: "lexical".to_string(),
k: 5,
},
);
assert!(
!result.is_error.unwrap_or(false),
"expected isError=false, got {:?}",
result
);
let content = result
.content
.first()
.expect("expected at least one content item");
let text = match &content.raw {
RawContent::Text(t) => &t.text,
other => panic!("expected text content, got {other:?}"),
};
let v: serde_json::Value = serde_json::from_str(text).unwrap();
let arr = v.as_array().expect("search returns a JSON array");
assert!(
!arr.is_empty(),
"expected at least one hit for 'kebab' in 'a.md'"
);
assert_eq!(
arr[0]
.get("schema_version")
.and_then(|s| s.as_str()),
Some("search_hit.v1"),
"first hit should carry schema_version=search_hit.v1"
);
}

View File

@@ -0,0 +1,70 @@
//! Integration: `build_tools_vec` returns 6 tools with correct names and
//! inputSchema. Uses the extracted `pub fn build_tools_vec()` helper — no
//! transport or RequestContext needed.
use kebab_mcp::build_tools_vec;
#[test]
fn tools_list_returns_six_tools() {
let tools = build_tools_vec();
assert_eq!(tools.len(), 6, "expected exactly 6 tools, got {}", tools.len());
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(names.contains(&"schema"), "missing 'schema' tool");
assert!(names.contains(&"doctor"), "missing 'doctor' tool");
assert!(names.contains(&"search"), "missing 'search' tool");
assert!(names.contains(&"ask"), "missing 'ask' tool");
assert!(names.contains(&"ingest_file"), "missing 'ingest_file' tool");
assert!(names.contains(&"ingest_stdin"), "missing 'ingest_stdin' tool");
}
#[test]
fn search_tool_input_schema_has_required_query() {
let tools = build_tools_vec();
let search = tools
.iter()
.find(|t| t.name.as_ref() == "search")
.expect("search tool must be present");
// input_schema is Arc<JsonObject> (serde_json::Map<String, Value>).
let schema_val = serde_json::Value::Object(search.input_schema.as_ref().clone());
let required = schema_val
.get("required")
.and_then(|v| v.as_array())
.expect("search inputSchema must have a 'required' array");
assert!(
required.iter().any(|v| v.as_str() == Some("query")),
"search inputSchema 'required' must contain 'query', got: {required:?}"
);
}
#[test]
fn schema_and_doctor_tools_accept_empty_input() {
let tools = build_tools_vec();
for name in &["schema", "doctor"] {
let tool = tools
.iter()
.find(|t| t.name.as_ref() == *name)
.unwrap_or_else(|| panic!("{name} tool must be present"));
let schema_val = serde_json::Value::Object(tool.input_schema.as_ref().clone());
// An empty-input schema has type "object" and no required fields
// (or no 'required' key at all).
let ty = schema_val.get("type").and_then(|v| v.as_str());
assert_eq!(
ty,
Some("object"),
"{name} inputSchema 'type' must be 'object', got {ty:?}"
);
if let Some(required) = schema_val.get("required").and_then(|v| v.as_array()) {
assert!(
required.is_empty(),
"{name} inputSchema 'required' must be empty, got: {required:?}"
);
}
}
}

View File

@@ -40,6 +40,7 @@ flowchart TB
subgraph UI ["UI binary"]
cli["kebab-cli"]
tui["kebab-tui"]
mcp["kebab-mcp<br/>(P9-FB-30)"]
desktop["kebab-desktop<br/>(P9-5)"]
end
app["kebab-app<br/>(facade)"]
@@ -71,6 +72,7 @@ flowchart TB
cli --> app
tui --> app
mcp --> app
desktop --> app
app --> srcfs
@@ -168,6 +170,7 @@ kebab/
│ ├── kebab-parse-pdf/ # lopdf per-page text extractor (P7-1)
│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체)
│ ├── kebab-tui/ # Ratatui shell + Library 패널 (P9-1)
│ ├── kebab-mcp/ # stdio MCP server — tools: schema, doctor, search, ask (P9-FB-30)
│ └── kebab-cli/ # binary (P0 → 핫픽스로 --config flag wiring 강화)
├── migrations/ # SQLite refinery V001/V002/V003
└── fixtures/ # 테스트 fixture 트리

486
docs/mcp-usage.md Normal file
View File

@@ -0,0 +1,486 @@
# MCP usage — agent integration guide
`kebab mcp` runs an MCP (Model Context Protocol) stdio JSON-RPC server. agent host (Claude Code / Cursor / OpenAI Agents / Copilot CLI 등) 가 본 binary 를 spawn 하여 KB 검색 / 답변 / ingest 를 호출.
shipped since **v0.3.1** (fb-30). 6 tool 으로 확장 (v0.3.2, fb-31).
---
## Quick start
binary 를 PATH 에 두고 (`cargo install --path crates/kebab-cli` 또는 release tarball), agent host 의 mcp config 에 등록:
```json
{
"mcpServers": {
"kebab": {
"command": "kebab",
"args": ["mcp"]
}
}
}
```
session 시작 시 host 가 `kebab mcp` 를 spawn — process 가 session 동안 살아 있어 SQLite / Lance / fastembed 가 hot. 첫 tool call 만 cold-start 비용, 이후 sub-100ms.
`--config` 옵션 thread:
```json
{
"mcpServers": {
"kebab": {
"command": "kebab",
"args": ["--config", "/Users/me/.config/kebab/agent.toml", "mcp"]
}
}
}
```
---
## Host config 예시
### Claude Code
`~/.claude/mcp.json` (또는 OS 별 동등 위치):
```json
{
"mcpServers": {
"kebab": {
"command": "kebab",
"args": ["mcp"]
}
}
}
```
session 재시작 후 `kebab` server 가 tool list 에 등장. agent 가 `mcp__kebab__search` / `mcp__kebab__ask` 등 호출 가능.
### Cursor
`~/.cursor/mcp.json`:
```json
{
"mcpServers": {
"kebab": {
"command": "kebab",
"args": ["mcp"]
}
}
}
```
Cursor 의 Composer / Agent 모드에서 활성화.
### OpenAI Agents (`agents-sdk`)
Python:
```python
from openai_agents import Agent, MCPServerStdio
kebab = MCPServerStdio(
name="kebab",
params={"command": "kebab", "args": ["mcp"]},
)
agent = Agent(
name="researcher",
mcp_servers=[kebab],
)
```
Node:
```ts
import { Agent, MCPServerStdio } from "openai-agents";
const kebab = new MCPServerStdio({
name: "kebab",
params: { command: "kebab", args: ["mcp"] },
});
const agent = new Agent({ name: "researcher", mcpServers: [kebab] });
```
### Copilot CLI
`~/.config/copilot-cli/mcp.json` (or wherever the CLI looks):
```json
{
"mcpServers": {
"kebab": {
"command": "kebab",
"args": ["mcp"]
}
}
}
```
### 기타 host
stdio JSON-RPC MCP 표준을 따르는 모든 host 가 지원. 위 형식 (`command` + `args`) 만 맞추면 동작.
---
## Tool catalog (6 tools)
모든 tool 의 출력은 wire schema v1 JSON 을 MCP `text` content block 으로 직렬화. CLI `--json` 모드와 byte-동일 (single source of truth).
### `search` — corpus 검색
| | |
|---|---|
| Input | `{ "query": string, "mode"?: "lexical"\|"vector"\|"hybrid", "k"?: 1-100 }` |
| Defaults | `mode = "hybrid"`, `k = 10` |
| Output | `search_hit.v1` array, ranked |
예시:
```json
{
"name": "search",
"arguments": {
"query": "Kubernetes ingress controller setup",
"mode": "hybrid",
"k": 5
}
}
```
응답 (한 hit 발췌):
```json
[
{
"schema_version": "search_hit.v1",
"rank": 1,
"score": 0.847,
"doc_id": "...",
"chunk_id": "...",
"doc_path": "k8s/ingress.md",
"heading_path": ["Setup", "Ingress controller"],
"snippet": "...",
"citation": { ... }
},
...
]
```
**언제 사용**: 사용자가 \"문서 어디 있는지\" 묻거나, agent 가 답변 전 raw chunk 가 필요할 때.
### `ask` — RAG 답변
| | |
|---|---|
| Input | `{ "query": string, "session_id"?: string, "mode"?: "lexical"\|"vector"\|"hybrid" }` |
| Defaults | `mode = "hybrid"` |
| Output | `answer.v1` (single object) |
예시:
```json
{
"name": "ask",
"arguments": {
"query": "What's our internal Kubernetes ingress setup?",
"session_id": "ops-onboarding-2026-05"
}
}
```
응답:
```json
{
"schema_version": "answer.v1",
"answer": "...",
"citations": [ ... ],
"grounded": true,
"refusal_reason": null,
"model": { ... },
"conversation_id": "...",
"turn_index": 0
}
```
**`grounded: false` 처리**: KB 에 충분한 context 없음. `refusal_reason` 확인 후 사용자에게 \"KB 에 정보 없음\" 으로 안내, 본인 지식 fallback 또는 source 요청. **paraphrase 하면 안 됨** (hallucination 위험).
multi-turn 은 [Session 관리](#session-관리-multi-turn-ask) 참조.
### `schema` — capability discovery
| | |
|---|---|
| Input | `{}` (no args) |
| Output | `schema.v1` |
예시:
```json
{ "name": "schema", "arguments": {} }
```
응답:
```json
{
"schema_version": "schema.v1",
"kebab_version": "0.3.2",
"wire": { "schemas": ["answer.v1", "search_hit.v1", ...] },
"capabilities": {
"json_mode": true,
"rag_multi_turn": true,
"mcp_server": true,
"streaming_ask": false,
...
},
"models": { "parser_version": "...", "embedding_version": "...", ... },
"stats": { "doc_count": 128, "chunk_count": 2147, "asset_count": 130, ... }
}
```
**언제 사용**: session 시작 시 한 번 — feature gate 결정 (`capabilities.streaming_ask` true 면 streaming 사용 등). cheap call (no LLM, no embedder), session 동안 1 회 충분.
### `doctor` — health check
| | |
|---|---|
| Input | `{}` (no args) |
| Output | `doctor.v1` |
예시:
```json
{ "name": "doctor", "arguments": {} }
```
응답:
```json
{
"schema_version": "doctor.v1",
"ok": true,
"checks": [
{ "name": "config_loaded", "ok": true, "detail": "..." },
{ "name": "ollama_reachable", "ok": true, "detail": "..." },
...
]
}
```
**언제 사용**: 다른 tool 이 실패하거나 비정상 응답 줄 때 first triage. `ok: false``checks[]` 의 failed entry 가 원인 — 사용자에게 보고 후 stop (자동 retry 금지).
### `ingest_file` — 단일 파일 저장 (mutation)
| | |
|---|---|
| Input | `{ "path": string }` |
| Supported ext | `.md` / `.pdf` / `.png` / `.jpg` / `.jpeg` (`unsupported extension` error 그 외) |
| Output | `ingest_report.v1` (single asset) |
예시:
```json
{
"name": "ingest_file",
"arguments": { "path": "/Users/me/Downloads/article.md" }
}
```
응답:
```json
{
"schema_version": "ingest_report.v1",
"scanned": 1,
"new": 1,
"updated": 0,
"unchanged": 0,
"skipped": 0,
"errors": 0,
...
}
```
**언제 사용**: 사용자가 disk 의 file 을 KB 에 저장 의향 명시 시. workspace 외부 path OK — 파일은 `<workspace.root>/_external/<hash12>.<ext>` 으로 copy. 동일 content 재 ingest 면 idempotent (`unchanged: 1`).
**주의**: mutation tool — 사용자 명시 의도 없을 때 자동 호출 금지.
### `ingest_stdin` — stdin markdown 저장 (mutation)
| | |
|---|---|
| Input | `{ "content": string, "title": string, "source_uri"?: string }` |
| v1 scope | markdown only |
| Output | `ingest_report.v1` (single asset) |
예시:
```json
{
"name": "ingest_stdin",
"arguments": {
"content": "## Article body\n\nMain text here.",
"title": "Article X",
"source_uri": "https://example.com/x"
}
}
```
응답:
```json
{
"schema_version": "ingest_report.v1",
"scanned": 1,
"new": 1,
...
}
```
**언제 사용**: agent 가 web fetch 한 markdown article 을 KB 에 저장. 사용자가 \"이거 나중에 또 보고 싶어\" 명시 시 또는 multi-turn 대화에서 자료 누적. content 가 이미 frontmatter (`---` 시작) 이면 error — `ingest_file` 사용.
`title` + `source_uri` 가 frontmatter 로 자동 prepend → `Document.metadata` 에 저장 → 후속 `search` 결과의 `doc_meta` 에 포함. agent 가 source URL 추적 가능.
**주의**: mutation tool. 같은 content 무한 ingest 안 함 (idempotent 보장이지만 embedding cost 낭비).
---
## Troubleshooting
### `isError: true` + `error.v1` content
tool dispatch 가 `Err` 반환 시. content 의 `error.v1` JSON 의 `code` 로 분기:
| code | 의미 | 조치 |
|------|------|------|
| `config_invalid` | `--config` path missing / TOML parse 실패 | path 확인 + `kebab schema` 로 검증. `details.path` + `details.cause` 확인. |
| `not_indexed` | `kebab.sqlite` 미존재 / migration 미실행 | 사용자에게 `kebab init` + `kebab ingest` 실행 안내. retry 자동 금지. |
| `model_unreachable` | Ollama endpoint 연결 실패 | Ollama 실행 확인 (`ollama serve`). `details.endpoint` 의 host 가 reachable 한지. retry 1-2 회 후 사용자 보고. |
| `model_not_pulled` | Ollama model not found | 사용자에게 `ollama pull <model>` 안내 — `details.model` 표시. |
| `timeout` | LLM stream / embed deadline 초과 | 일시적이면 retry 1 회. 재발 시 사용자 보고 (model 응답 느림 / Ollama load). |
| `io_error` | filesystem / 권한 / disk full | `details.kind` 보고 사용자에게 disk space / permission 확인 안내. |
| `generic` | catch-all | `details.chain` (verbose 시) 보고 사용자에게 그대로 전달. retry 금지. |
`hint` field 가 있으면 사용자에게 그대로 보여주기 (각 code 의 가장 빠른 조치).
### `grounded: false` (ask refusal)
`isError: false` (정상 응답). KB 에 충분한 context 없음. `refusal_reason` 확인 후:
- `NoChunks` — 검색 자체가 0 hit. 다른 표현 / 더 일반적인 query 시도.
- `LowScores` — hit 있지만 score gate 미달. `kebab search` (별도) 로 raw hit 확인.
- 그 외 — refusal 메시지 그대로 사용자에게 보고.
자동 paraphrase 금지. 사용자에게 \"KB 에 정보 없음\" 명시 후 본인 지식 또는 source 요청.
### `doctor` `ok: false`
다른 tool 호출 전 `doctor` 부터. `checks[]` 의 failed entry 원인 명시 — 사용자에게 보고 후 stop.
### empty `search` result
`isError: false`, content = `[]` (빈 array). KB 에 매칭 없음. `mode` 변경 (lexical → vector or vice versa) 또는 query 표현 다양화. 그래도 빈 결과면 KB coverage 부족 — 사용자에게 보고.
### tool not found
`tools/list` 에서 본 binary 의 6 tool 확인. 0.3.1 (fb-30) 은 4 tool, 0.3.2 (fb-31) 부터 6. binary version 확인:
```json
{ "name": "schema", "arguments": {} }
```
응답의 `kebab_version` 이 0.3.2+ 인지 확인.
---
## Session 관리 (multi-turn ask)
`ask` tool 의 `session_id` 가 multi-turn RAG context 활성화. 같은 `session_id` 로 연속 호출 시 이전 Q/A history 가 새 query 의 retrieval expansion + prompt context 에 포함.
### session_id 명명
`<topic>-<date>` 형식 권장 — 사용자 친화 + uniqueness:
- `ops-onboarding-2026-05`
- `kubernetes-ingress-debug-2026-05-07`
- `agent-research-session-1` (auto-numbered)
session_id 는 임의 string — kebab 이 처음 보는 id 면 새 session 생성, 기존 id 면 history append.
### 언제 새 session 시작?
- 주제 완전 전환 (KB 의 다른 도메인) — 이전 history 가 noise.
- 사용자 명시 reset 요청.
- Long session (50+ turn) 의 context bloat — 새 session 으로 fresh start.
### Session lifetime
session 데이터는 SQLite `chat_sessions` + `chat_turns` 에 영속. `kebab reset --data-only` 가 모두 wipe. session 별 삭제 명령은 없음 (P+).
### 예시 multi-turn flow
```json
// turn 1
{ "name": "ask", "arguments": {
"query": "What's our internal Kubernetes ingress setup?",
"session_id": "ops-2026-05"
}}
// → answer.v1 with conversation_id, turn_index: 0
// turn 2 — 이전 답변을 context 로 retrieval expansion
{ "name": "ask", "arguments": {
"query": "What about TLS?",
"session_id": "ops-2026-05"
}}
// → kebab 가 "TLS" 만으로 retrieval 안 함, 이전 \"Kubernetes ingress\" history 포함 query 로 검색
// turn 3 — 명시적 reference
{ "name": "ask", "arguments": {
"query": "How does that compare to AWS ALB?",
"session_id": "ops-2026-05"
}}
```
### Session vs single-shot
`session_id` 없이 `ask` 호출 = single-shot. agent host 자체가 conversation 추적하면 single-shot + agent-side context 도 OK. session 이 필요한 경우:
- KB 가 \"이전 질문\" 을 retrieval expansion 에 사용해야 정확 (e.g. follow-up 의 대명사).
- 한 session 안에서 같은 chunk 반복 fetch 회피 (kebab 가 turn 간 chunk overlap 인지).
agent host 가 conversation 추적 + 충분한 context 보유면 session 불필요.
---
## Performance
- **첫 tool call**: cold start ~1-2s (SQLite open + Lance dataset open + fastembed model load).
- **이후 tool call (same session)**: hot — search ~50-200ms, ask ~수 초 (Ollama LLM dominant).
- **session 종료** (host 가 process kill): 모든 cache lost. 다음 session 첫 call 다시 cold.
- **`schema` / `doctor`**: cheap (no LLM / no embedder), 매 call ~ms.
- **`ingest_file` / `ingest_stdin`**: 첫 call 시 fastembed cold start. 이후 file 당 ~수 백 ms (parse + chunk + embed).
cold-start 회피하려면 host 가 long-running session 유지 (Claude Code default).
---
## Security
- stdio MCP — 외부 네트워크 노출 없음. agent host 만 access.
- `kebab mcp` 가 호출하는 facade 는 `--config` 의 권한으로 동작. config 내 secret (Ollama API key 등) 은 process 환경에 한정.
- mutation tool (`ingest_file` / `ingest_stdin`) 는 사용자 명시 의도 없이 자동 호출 금지 — agent 측 가드.
---
## Related
- CLI usage: `kebab --help` + [README.md](../README.md)
- Wire schemas: `docs/wire-schema/v1/*.schema.json`
- design contract: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §10.2
- Claude Code 전용 skill: `integrations/claude-code/kebab/SKILL.md`
- HOTFIXES (post-merge deviations): `tasks/HOTFIXES.md`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1206,6 +1206,12 @@ hint edit ~/.config/kebab/config.toml then `kebab ingest ~/KnowledgeBase`
- 항상 POSIX path 정규화 후 DB 저장. `to_posix` 단일 함수.
- 심볼릭 링크: 1차 follow + 무한루프 detect (`canonicalize` 후 set 추적).
### 6.7 `_external/` subdirectory (fb-31)
`<workspace.root>/_external/` 가 single-file / stdin ingest 의 destination. 명명: `<blake3-12>.<ext>` (12-char hex prefix of content hash + 원래 extension). deterministic — 동일 content 재 ingest 면 idempotent.
첫 생성 시 `<workspace.root>/.kebabignore``_external/` line 자동 append — 향후 `kebab ingest` 전체 walk 가 이 디렉토리 재 walk 안 함 (re-ingestion 무한 루프 방지).
---
## 7. Trait contracts (kebab-core)
@@ -1443,6 +1449,17 @@ HOTFIXES 의 `2026-05-07 — p9-fb-27` 항목이 details shape 의
interim deviation (IoFailure / OpTimeout 신규 typed signal 도입 전까지의
transitional 형태) 의 source of truth.
### 10.2 MCP server transport (fb-30)
`kebab mcp` 가 stdio JSON-RPC server. Rust SDK = `rmcp 1.6`. Tool surface
v1: `search` / `ask` / `schema` / `doctor` (4 read-only). Resources /
Prompts / Sampling 미선언. Output 은 wire schema v1 JSON 을 MCP `text`
content block 으로 직렬화. Tool dispatch 실패는 `isError: true` + error.v1
content; refusal / no-hit / unhealthy 는 정상 응답 (semantic flag 으로
agent 가 분기). HTTP-SSE transport 는 fb-29 deferral 따라 P+. classify
모듈은 `kebab-app::error_wire` 에 single source — kebab-cli + kebab-mcp
공유.
---
## 11. 동결 범위 / 변경 정책

View File

@@ -0,0 +1,222 @@
---
title: "p9-fb-30 — MCP server (stdio) — agent host 무관 protocol surface"
date: 2026-05-07
status: design (brainstorm 완료, plan 단계 대기)
target_version: 0.4.0
task_spec: ../../../tasks/p9/p9-fb-30-mcp-server.md
contract_source: ../specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 RAG, §10 UX]
depends_on: [p9-fb-27]
unblocks: []
---
# MCP server (stdio) — 설계
## 동기
현재 외부 AI 통합은 `integrations/claude-code/kebab/` skill 한 종류 — Claude Code subprocess wrapper. Cursor / OpenAI Agents / Copilot CLI 등 다른 host 는 별도 wrapper 작성 필요.
MCP (Model Context Protocol) 가 표준 — 한 번 server 구현하면 MCP-aware host 모두 지원. 본 task 는 stdio MCP server 도입. fb-29 HTTP daemon 은 deferred (single-user local-first 환경에서 daemon 복잡도 비대 — fb-30 stdio 가 동일 사용자 가치 제공).
fb-27 (introspection + error wire) 의 capability matrix + error.v1 wire 가 본 task 의 prerequisite ✅.
## 결정 요약
| 결정 | 선택 |
|------|------|
| Dispatch | `kebab mcp` subcommand (kebab-cli 내) |
| Tool surface (v1) | `search` / `ask` / `schema` / `doctor` (read-only, 4 개) |
| Resources / Prompts | 모두 skip (tools only) |
| 구현 | Rust MCP SDK (`rmcp` 또는 plan 단계 채택) |
| Transport | stdio 단일 (HTTP-SSE 는 fb-29 deferral 따라 P+) |
| Output | 모든 tool 이 wire schema v1 JSON 을 text content 로 반환 |
| Multi-turn `ask` | optional `session_id` (kebab-app 의 `ask_with_session_with_config` 활용) |
## Surface 1 — `kebab mcp` 신규 subcommand
### CLI
```
kebab mcp # stdio JSON-RPC server 시작
kebab mcp --config <path> # config 명시 (P3-5 / P4-3 패턴)
```
`--config` 외 추가 flag 없음. agent host 가 spawn 명령에서 환경 변수로 추가 설정 주입.
### Crate boundary
새 crate `crates/kebab-mcp/` (lib only). `kebab-cli``Cmd::Mcp` arm 이 한 줄 entry — `kebab_mcp::serve_stdio(cfg)?`.
```
kebab-cli ──► kebab-mcp ──► kebab-app ──► kebab-store-* / kebab-llm-* / kebab-parse-*
│ │
└─ rmcp ─────┴─ kebab-config / kebab-core
```
CLAUDE.md facade 룰 준수:
- `kebab-mcp``kebab-app` facade + `kebab-config` + `kebab-core` 만 import. 구현 crate 직접 금지.
- `kebab-cli``kebab-mcp` 만 알고 MCP 내부 미인지 — 다른 UI crate (TUI / desktop) 가 mcp surface 필요해지면 동일하게 import.
- `rmcp` (또는 채택 SDK) 는 `kebab-mcp``[dependencies]` 만 — kebab-cli 는 transitive.
CLAUDE.md 의 "UI crates" 카테고리에 `kebab-mcp` 추가 (의존 경계 절).
## Surface 2 — Tool catalog (4 tools)
`tools/list` response 가 4 tool 을 노출. 각 tool 은 inputSchema (JSON Schema) 가 inline.
### `search`
| 항목 | 값 |
|------|-----|
| description | "Lexical / vector / hybrid retrieval over indexed corpus." |
| input | `{ query: string (required), mode?: "lexical" \| "vector" \| "hybrid" (default "hybrid"), k?: integer (default 10, range 1-100) }` |
| facade | `kebab_app::search_with_config(&cfg, query, mode, k)` |
| output | text content = `serde_json::to_string(wire::wire_search_hits(&hits))` |
빈 결과 = 정상 응답 (empty `search_hit.v1` array). NoHitSignal 의 exit-code 분기는 stdio 무관.
### `ask`
| 항목 | 값 |
|------|-----|
| description | "Grounded RAG answer with citations. Returns answer.v1 with grounded=false when KB lacks context." |
| input | `{ query: string (required), session_id?: string }` |
| facade | `session_id` 있으면 `ask_with_session_with_config`, 없으면 `ask_with_config` |
| output | text content = `serde_json::to_string(wire::wire_answer(&answer))` |
Refusal (`grounded: false`) = 정상 응답. agent 가 wire payload 의 `grounded` flag 로 분기. `refusal_reason` 도 답변에 포함.
### `schema`
| 항목 | 값 |
|------|-----|
| description | "Introspection — wire schemas, capabilities, model versions, index stats." |
| input | `{}` (no args) |
| facade | `schema_with_config(&cfg)` |
| output | text content = `serde_json::to_string(wire::wire_schema(&schema))` |
`capabilities.mcp_server``true` (본 PR 에서 `capabilities_snapshot()` 갱신).
### `doctor`
| 항목 | 값 |
|------|-----|
| description | "Health check — config / data dir / Ollama reachability." |
| input | `{}` (no args) |
| facade | `doctor_with_config_path(cli.config.as_deref())` (or equivalent) |
| output | text content = `serde_json::to_string(wire::wire_doctor(&report))` |
DoctorUnhealthy = 정상 응답 (doctor.v1 with `ok: false`). agent 가 검사.
## Surface 3 — Lifecycle / capabilities / error mapping
### Initialize handshake
server `initialize` 응답:
```jsonc
{
"protocolVersion": "<rmcp 가 pin 하는 stable version, 예: 2025-03-26>",
"capabilities": {
"tools": { "listChanged": false }
},
"serverInfo": {
"name": "kebab",
"version": "<env!(\"CARGO_PKG_VERSION\")>"
}
}
```
resources / prompts / sampling / notifications — 모두 미선언.
### Tool error envelope
| 시나리오 | MCP 응답 | content |
|----------|----------|---------|
| facade `Err(e)` | `{ isError: true, content: [{ type: "text", text: <error.v1 JSON> }] }` | `error_wire::classify(&e, false)` 결과 |
| facade `Ok(...)` | `{ isError: false, content: [{ type: "text", text: <wire JSON> }] }` | search_hit.v1 / answer.v1 / schema.v1 / doctor.v1 |
protocol-level error (invalid method / malformed params / panic) 는 SDK 가 JSON-RPC error envelope 으로 자동 처리.
### Refusal / no-hit / unhealthy 가 isError 아님
CLI 의 exit code 1 (refusal/no-hit) / 3 (doctor unhealthy) 는 stdio 환경에 의미 없음. 모두 `isError: false` 정상 응답으로 반환 — agent 가 wire payload 의 semantic flag (`grounded` / 빈 array / `ok: false`) 로 분기. 이게 MCP 표준 패턴 — error envelope 은 protocol 실패 전용.
### classify 모듈 이전 (load-bearing 구조 변경)
`kebab-cli::error_classify` (fb-27 도입) 를 `kebab-app::error_wire` 로 promotion. 본 PR 에 포함:
- `crates/kebab-app/src/error_wire.rs` 신규 — `ErrorV1` struct + `classify(&anyhow::Error, verbose: bool) -> ErrorV1` 함수 + `classify_llm` helper. fb-27 commit `c91228e``error_classify.rs` 코드 그대로 이전.
- `crates/kebab-app/src/lib.rs` `pub mod error_wire;` + re-export.
- `crates/kebab-cli/src/error_classify.rs` 삭제 — `kebab-cli::main` 의 import 가 `kebab_app::error_wire::*` 로 변경.
- 기존 7 unit test 도 함께 이전 (`kebab-app/src/error_wire.rs::tests`).
- `kebab-cli::wire::wire_error_v1``&crate::error_classify::ErrorV1``&kebab_app::ErrorV1` 1 줄 변경.
- kebab-cli 의 reqwest dev-dep 는 유지 (`llm_unreachable_classifies` 가 함께 이동) — 또는 reqwest dev-dep 도 kebab-app 으로 이전.
근거: kebab-cli + kebab-mcp 둘 다 동일 classify 사용. UI crate (kebab-cli) 가 다른 UI crate import 는 facade 룰 위반. kebab-app 으로 promotion 이 정공법.
### Concurrency
stdio JSON-RPC 는 클라이언트 측 순차 호출 default 지만 MCP spec 은 동시 호출 허용. tokio runtime (kebab-app 의 `rt-multi-thread` feature 활용):
-`tools/call` request 가 독립 task — `tokio::task::spawn`.
- facade 가 sync API (현재 대부분) → `tokio::task::spawn_blocking` wrap.
- 한 process 안에 SQLite / Lance / fastembed connection 공유 — 한 번 init 후 모든 tool call 이 hot. `kebab-app` 의 facade 가 매 호출 마다 `Config` load + store open 시 cold-start 절감 효과 약화 — plan 단계에서 server-scope `App` 인스턴스 (혹은 connection pool) 도입 검토.
세부 async pattern + connection lifetime 은 plan 단계 결정 (rmcp SDK 의 dispatch 모델에 의존).
## Out of scope (defer)
- **HTTP-SSE transport** — fb-29 P+ 와 묶어 진행. 본 task 는 stdio 단일.
- **Resources** — `kebab://chunk/<id>` / `kebab://doc/<id>` URI scheme. fb-35 verbatim fetch 와 함께 v2.
- **Prompts** — reusable prompt template. RAG 자체가 prompt template 내장 — 사용자 가치 약함, defer.
- **Streaming `ask`** — fb-33 streaming ask 와 함께 ndjson delta tool 결과.
- **`ingest_file` / `ingest_stdin` tools** — fb-31 single-file ingest 머지 시 추가.
- **`fetch` (verbatim doc/chunk)** — fb-35 verbatim fetch 머지 시 추가.
- **`list_docs` / `inspect_chunk` tools** — demand 발생 시.
- **Server logging notifications** (`notifications/message`) — SDK 자동 처리만.
- **Sampling capability** — 본 server 는 sampling 미수행.
## Testing 전략
| crate | type | 파일 | 검증 |
|-------|------|------|------|
| `kebab-app` | unit | `src/error_wire.rs::tests` | 기존 7 classify test (fb-27 에서 promotion) |
| `kebab-mcp` | unit | `src/lib.rs::tests` | tool input schema parse + dispatch (mock or TempDir) |
| `kebab-mcp` | integration | `tests/initialize.rs` | initialize handshake — protocolVersion / serverInfo / capabilities.tools 정확 |
| `kebab-mcp` | integration | `tests/tools_list.rs` | `tools/list` 가 4 tool name + inputSchema 정확 반환 |
| `kebab-mcp` | integration | `tests/tools_call_search.rs` | search tool call → text content = search_hit.v1 array, isError=false |
| `kebab-mcp` | integration | `tests/tools_call_ask.rs` | ask tool call → answer.v1 (refusal 시 grounded=false 정상) |
| `kebab-mcp` | integration | `tests/tools_call_schema.rs` | schema.v1 정확 + capabilities.mcp_server=true 검증 |
| `kebab-mcp` | integration | `tests/tools_call_doctor.rs` | doctor.v1 정확 |
| `kebab-mcp` | integration | `tests/error_mapping.rs` | bad config 로 호출 → tool error with error.v1 + isError=true |
| `kebab-cli` | integration | `tests/cli_mcp_smoke.rs` | `target/debug/kebab mcp` spawn + 1 round-trip JSON-RPC |
| `kebab-app` | unit | `tests/schema_report.rs` (기존) | `capabilities.mcp_server == true` assertion 1 줄 추가 |
JSON-RPC client = rmcp 의 in-process test harness (지원 시) 또는 hand-roll line write/read 헬퍼.
## Spec / doc sync (PR 같은 commit)
1. **frozen design §10.1** — MCP transport 절 추가 (또는 §10.2 신설). stdio-only / 4 tool / capability flag flip 명시.
2. **README.md** — 명령 표 에 `kebab mcp` row + MCP usage section (Claude Code `~/.claude/mcp.json` config 예시).
3. **HANDOFF.md**`2026-05-?? P9 post-도그푸딩 (p9-fb-30)` 한 줄.
4. **CLAUDE.md** — facade 룰 절 에 `kebab-mcp` UI crate 카테고리 추가. 새 crate 카운트 갱신 (~20 → ~21).
5. **integrations/claude-code/kebab/SKILL.md** — MCP 사용 권장 + Claude Code `~/.claude/mcp.json` 예시 한 블록 추가. 기존 subprocess wrapper 형태도 backwards-compat 유지 (일부 사용자가 MCP 미지원 host 에서 호출).
6. **HOTFIXES.md**`2026-05-?? — fb-30` entry. classify 모듈 이전 + capability flag flip + 기타 deviation 명시.
7. **`tasks/p9/p9-fb-30-mcp-server.md`** — status `open``completed`, banner 갱신, depends_on 갱신 (이미 fb-29 제거됨 from 2026-05-07 commit).
## Release trigger
0.3.0 → **0.4.0** minor bump — fb-30 머지 = 신규 CLI surface (`kebab mcp`) + new crate (`kebab-mcp`) + capability flag flip (`mcp_server: true`) + design §10 변경 (3 trigger 모두 발동).
agent integration "MVP" 완성 신호. release notes 에 강조: "MCP 표준 protocol 으로 Claude Code / Cursor / OpenAI Agents 등 host-agnostic 사용 가능."
## Risks / notes
- **rmcp version maturity**: plan 단계 verify 필요. 미존재 / 미성숙 시 hand-roll JSON-RPC 또는 hybrid (transport hand-roll + spec literal struct serde) fallback. rmcp 채택 가정 하 spec 작성됨 — 심각한 호환성 문제 발생 시 spec 갱신 + HOTFIXES.
- **classify 이전의 회귀 위험**: kebab-cli 의 7 test + 1 wire test 가 import path 변경. mechanical 이지만 누락 시 컴파일 실패로 catch.
- **`Config` resolution per call**: 매 tool call 마다 `Config::load(...)` + `App` open 하면 daemon 의 hot-cache 효과 미미. 첫 call 시 server-scope `App` 인스턴스 만들고 이후 재사용 — plan 단계 concrete 설계.
- **MCP version evolution**: spec 가 진화 중. SDK pin 따르고 README 에 명시. major change 발생 시 별 task.
- **ask 의 multi-turn session 의 정합**: kebab session 은 kebab 의 RAG history. agent host 도 자체 conversation 추적. 둘이 다른 식별자 — sync 필요 시 사용자가 명시적으로 `session_id` 매핑. 본 PR scope 밖 — agent 사용 가이드에 명시.
- **Tool error 에 hint 손실 위험**: `error_wire::classify``hint: Option<String>` 채움. MCP 응답에서 `hint` 가 보존되는지 — text content 의 JSON 이 `hint` field 그대로 가짐. agent 가 parse 하면 readable. OK.
- **stdin/stdout 충돌**: kebab-mcp 가 stdin/stdout 으로 JSON-RPC 통신. `tracing` log 가 stdout 으로 쓰면 protocol 깨짐. 모든 log 는 stderr 또는 file (`~/.local/state/kebab/logs/`) — kebab-app 의 logging init 가 이미 stderr 기본. 명시 verify.

View File

@@ -0,0 +1,240 @@
---
title: "p9-fb-31 — Single-file / stdin ingest — agent on-demand 저장"
date: 2026-05-07
status: design (brainstorm 완료, plan 단계 대기)
target_version: 0.3.x
task_spec: ../../../tasks/p9/p9-fb-31-single-file-stdin-ingest.md
contract_source: ../specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§3 ingest, §6 filesystem, §10 UX]
depends_on: []
unblocks: []
---
# Single-file / stdin ingest — 설계
## 동기
agent (Claude Code via MCP, fb-30) 가 web 에서 fetch 한 markdown / pdf 를 KB 에 저장하려면 현재는:
1. agent 가 workspace 디렉토리에 file 쓰기.
2. `kebab ingest` 전체 walk 재실행.
(2) 가 비효율 — 100+ doc workspace 면 모든 doc 의 incremental check 비용. agent 메모리상 string contents 면 임시 file 거치는 우회.
본 task 는 두 신규 명령 도입:
- `kebab ingest-file <path>` — 단일 file (workspace 외부 포함) 만 ingest.
- `kebab ingest-stdin --title <T> [--source-uri <URI>]` — stdin 에서 markdown 본문 read 후 ingest.
MCP tool `ingest_file` + `ingest_stdin` 도 동시 추가 — agent 가 CLI 우회 없이 직접 호출.
## 결정 요약
| 결정 | 선택 |
|------|------|
| 외부 file 저장 정책 | Copy in (`<workspace.root>/_external/<hash12>.<ext>`) |
| CLI surface | 신규 subcommand 2개 (`ingest-file` + `ingest-stdin`) |
| MCP tool | 동시 추가 (4 → 6 tool) — `ingest_file` + `ingest_stdin` |
| .kebabignore | bypass + warn (explicit ingest 가 default bypass intent) |
| stdin v1 scope | markdown 전용 + flag → frontmatter 자동 주입 |
## Surface 1 — `kebab ingest-file`
### CLI
```
kebab ingest-file <path> [--config <path>]
```
- `path`: positional, absolute / relative file path. workspace 외부 가능.
- `--config <path>`: 기존 facade rule 일관 (P3-5 / P4-3 패턴).
- 추가 flag 없음 — 명시 ingest 자체가 .kebabignore bypass intent.
### Behavior
1. file 존재 여부 + 크기 + media type (extension) 검증.
2. workspace.root 의 `.kebabignore` pattern 과 source path 매치 검사 — 매치 시 stderr warn (`warn: <path> matches .kebabignore patterns; proceeding (explicit ingest bypasses ignore)`). 진행은 계속.
3. blake3 content hash 계산 → `_external/<hash12>.<ext>` workspace 상대 경로 derive.
4. `<workspace.root>/_external/` 디렉토리 자동 생성 (없으면). 첫 생성 시 `<workspace.root>/.kebabignore``_external/` line 자동 append (없으면) — 향후 walk 중복 방지.
5. file content → `<workspace.root>/_external/<hash12>.<ext>` 로 copy. 동일 hash 면 skip (idempotent).
6. 단일 asset 으로 기존 ingest pipeline 재사용 (parse → chunk → embed → vector store + SQLite upsert). incremental ingest (fb-23) 가 동일 hash 면 unchanged 처리.
7. `IngestReport` (`ingest_report.v1`) 반환 — single asset count.
### Output
stdout 은 기존 `kebab ingest` 와 동일 — 사람 모드는 한 줄 summary, `--json``ingest_report.v1` JSON.
```text
$ kebab ingest-file ~/Downloads/article.md
ingested 1 new (~/Downloads/article.md → _external/a3f7b9e2c1d4.md)
```
`--json`:
```json
{"schema_version":"ingest_report.v1","scope":{"root":".../_external/a3f7b9e2c1d4.md","include":[],"exclude":[]},"scanned":1,"new":1,"updated":0,"skipped":0,"unchanged":0,"errors":0,...}
```
(`scope.root` 표현은 plan 단계 결정 — 단일 file path 또는 fake scope.)
## Surface 2 — `kebab ingest-stdin`
### CLI
```
kebab ingest-stdin --title <T> [--source-uri <URI>] [--config <path>]
```
- `--title <T>`: 필수. frontmatter `title` field 채움.
- `--source-uri <URI>`: 옵션. 제공 시 frontmatter `source_uri` field 채움.
- v1 markdown 전용 — `--media` flag 없음.
### Behavior
1. stdin 전체 read → `String content`.
2. **Frontmatter pre-check**: `content.trim_start().starts_with("---\n")` 면 — `Err`: `"stdin already has frontmatter; use \`kebab ingest-file\` for files with metadata"`. exit 2.
3. 그 외 frontmatter block prepend:
```md
---
title: "<T>"
source_uri: "<URI>" # only if --source-uri provided
---
<stdin contents>
```
(YAML escaping for title — `serde_yaml::to_string` 또는 inline quote escape. plan 단계 결정.)
4. 합친 markdown 의 blake3 hash → `<workspace.root>/_external/<hash12>.md` 로 write.
5. ingest-file path 의 5-7 단계 재사용.
### Output
```text
$ echo "## Body" | kebab ingest-stdin --title "Article X" --source-uri "https://example.com/x"
ingested 1 new (stdin → _external/7c8e1f3a2b9d.md)
```
`--json` 동일 `ingest_report.v1`.
### source_uri metadata 흐름
- frontmatter `source_uri` 는 markdown parser 가 `Document.metadata` 의 free-form map 에 string field 로 저장 (이미 처리됨 — frontmatter 의 모든 key 가 metadata 로 흘러감).
- `kebab inspect` / `kebab search --json``doc_meta` 에 자동 포함. agent 가 search 결과의 source_uri 로 원본 web URL 추적 가능.
- v1 wire schema 추가 변경 없음 — `metadata` 가 이미 free-form map.
## Surface 3 — MCP tools `ingest_file` + `ingest_stdin`
### `ingest_file`
```rust
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct IngestFileInput {
/// Absolute or relative path to the file to ingest.
pub path: String,
}
```
facade: `kebab_app::ingest_file_with_config(cfg, &Path) -> Result<IngestReport>`.
handle: `spawn_blocking` wrap (touches embedder + SqliteStore). text content = `ingest_report.v1` JSON.
### `ingest_stdin`
```rust
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct IngestStdinInput {
/// Markdown body content. v1 supports markdown only.
pub content: String,
/// Title for frontmatter injection.
pub title: String,
/// Optional source URI (e.g. https URL agent fetched from).
pub source_uri: Option<String>,
}
```
facade: `kebab_app::ingest_stdin_with_config(cfg, content, title, source_uri) -> Result<IngestReport>`.
handle: `spawn_blocking` wrap. text content = `ingest_report.v1` JSON.
### `KebabHandler` 변경
- `build_tools_vec()` 가 4 → 6 entries 반환.
- `call_tool` match 에 `"ingest_file"` + `"ingest_stdin"` arm 추가 (spawn_tool helper 재사용).
- 신규 module `crates/kebab-mcp/src/tools/ingest_file.rs` + `ingest_stdin.rs`.
### Mutation tool 첫 도입
fb-30 v1 은 read-only 4 tool. fb-31 머지로 mutation surface 등장 — agent 가 KB 에 직접 write 가능. 의도된 진화 — agent flow 의 자연스러운 다음 단계. HOTFIXES entry 명시.
## `_external/` 디렉토리 정책
- 위치: `<workspace.root>/_external/`.
- 첫 ingest-file / ingest-stdin 호출 시 자동 생성.
- 생성과 동시에 `<workspace.root>/.kebabignore``_external/` line append (없으면) — 향후 `kebab ingest` 전체 walk 가 이 디렉토리 재 walk 안 함 (re-ingestion 무한 루프 방지).
- 파일명 = `blake3(content) 12-char prefix + 원래 ext`. deterministic — 동일 content 재 ingest 면 같은 파일명, idempotent (incremental ingest 가 unchanged 처리).
- 사용자가 `_external/` 안 파일 직접 수정해도 OK — explicit `ingest-file` 또는 manual `kebab ingest` (`.kebabignore` 우회 시) 가 incremental 변경 감지.
## 의존 경계 + 신규 facade
- `kebab-app::ingest_file_with_config(cfg, &Path) -> Result<IngestReport>` — 신규 facade fn.
- `kebab-app::ingest_stdin_with_config(cfg, content, title, source_uri) -> Result<IngestReport>` — 신규.
- 둘 모두 내부적으로 기존 `ingest_with_config_opts` 의 single-asset 변종 OR 별 helper. plan 단계 구체화.
- frontmatter injection helper (`kebab-app::frontmatter::inject(content, title, source_uri) -> String`) — kebab-app 안. kebab-mcp + kebab-cli 둘 다 facade 통해 호출.
- `_external/` 디렉토리 + `.kebabignore` 자동 추가도 `kebab-app` 책임 (ingest_file_with_config 안에서).
## Wire schema impact
**없음**. 모두 기존 schema 재사용:
- `ingest_report.v1` — single-asset count. 기존 shape 그대로.
- `error.v1` — file not found / frontmatter precheck 등 facade error 가 기존 code 로 매핑 (`io_error`, `generic`).
`source_uri``Document.metadata` 의 free-form map 안 — `inspect` / `search_hit.v1` / `answer.v1``doc_meta` 에 자동 포함.
## Testing 전략
| crate | type | 파일 | 검증 |
|-------|------|------|------|
| `kebab-app` | unit | `tests/ingest_file.rs` | external file → `_external/<hash>.md` copy + IngestReport new=1, 두 번째 호출 unchanged=1, .kebabignore match warn (stderr capture), file-not-found Err |
| `kebab-app` | unit | `tests/ingest_stdin.rs` | content + title → frontmatter prepend + ingest, source_uri 옵션 처리, stdin already-frontmatter Err |
| `kebab-mcp` | integration | `tests/tools_call_ingest_file.rs` | tool call → ingest_report.v1 (isError=false), idempotent 두 번째 호출 unchanged=1 |
| `kebab-mcp` | integration | `tests/tools_call_ingest_stdin.rs` | content + title input → ingest_report.v1, frontmatter precheck error 시 isError=true + error.v1 |
| `kebab-mcp` | integration | `tests/tools_list.rs` (기존) | 4 → 6 tool 검증 (assertion update) |
| `kebab-cli` | integration | `tests/cli_ingest_file.rs` | spawn `kebab ingest-file <tempfile>` → ingest_report.v1 stdout, exit 0 |
| `kebab-cli` | integration | `tests/cli_ingest_stdin.rs` | spawn `kebab ingest-stdin --title X` + stdin pipe → ingest_report.v1, exit 0 |
## Spec / doc sync (PR 같은 commit)
1. **frozen design §3 / §6**`_external/` 디렉토리 + .kebabignore auto-add 정책 명시.
2. **README** — 명령 표 에 `kebab ingest-file` + `kebab ingest-stdin` 두 row + MCP usage section 의 tool list 4 → 6 update.
3. **HANDOFF** — post-도그푸딩 entry.
4. **CLAUDE.md** — wire schema 목록 변경 없음 (`ingest_report.v1` 재사용). `_external/` 디렉토리 + naming convention 한 줄.
5. **integrations/claude-code/kebab/SKILL.md**`ingest_file` / `ingest_stdin` MCP tool 사용 안내 + agent fetch flow 예시.
6. **HOTFIXES** — 신규 entry. fb-30 v1 read-only 정책 변경 (mutation tool 도입) 명시.
7. **`tasks/p9/p9-fb-31-single-file-stdin-ingest.md`** — status `open``completed`.
## Release trigger
0.3.1 → **0.3.2** patch — additive only (신규 subcommand + 신규 MCP tool, 기존 surface 동작 무영향, wire schema 변경 없음). pre-1.0 patch 정책 일관 (fb-30 도 0.3.1 patch 였음).
## Out of scope (defer)
- **PDF / image stdin** — binary stream + base64 처리 v2.
- **다른 metadata field** (tags, language hint, custom kv) — `--title` + `--source-uri` 외 v2.
- **자동 dedup by source_uri** — content hash 기반 dedup 은 incremental ingest 가 이미 처리. URI 별 lookup 은 별 task.
- **Storage quota / TTL** — agent 무한 ingest 시 KB 비대 우려. monitor + 별 task.
- **Frontmatter merge** (stdin 이 이미 frontmatter 보유 시 머지) — v1 은 error. user 가 경우에 맞게 ingest-file 사용.
- **`--force-ignore` flag** — 명시 ingest 가 default bypass 라 flag 불필요.
- **MCP `ingest_file` 의 multi-file batch** (`paths: Vec<String>`) — v1 single path. 여러 file 호출은 agent 가 N 회.
## Risks / notes
- **`_external/` 디렉토리 명명**: underscore prefix 가 dotfile 만큼 강한 hide 신호 아님. 사용자 workspace listing 시 보임. README 에 명시.
- **.kebabignore auto-append 의 idempotency**: file 이 이미 `_external/` line 보유 시 중복 append 안 함. 정확한 정합 검사 필요.
- **YAML escaping**: title 에 quote / special char 포함 시 frontmatter parse 실패 위험. `serde_yaml` 사용 또는 strict escape.
- **Mutation tool 의 input validation**: `ingest_stdin``content` 가 매우 클 경우 (수 MB markdown) 메모리 압박. v1 size limit 없음 — agent 책임. monitor + 별 task.
- **agent 의 무한 ingest**: KB 비대 + cost (embedding). 사용자 쪽 monitoring + storage quota 별 task.
- **`_external/` workspace 외부 이동 / 백업 정책**: workspace 안 일반 파일과 동일 — 사용자 백업 정책 일관.
- **hash collision 확률**: blake3 12-char prefix = 48 bit. ~16M files 까지 안전 (birthday bound). single-user KB 에 충분. 충돌 시 first-write-wins (idempotency 와 동일 동작).

View File

@@ -71,6 +71,42 @@ Returns a `schema.v1` object with: `wire.schemas` (supported wire ids), `capabil
If a call fails or returns suspicious output, run `kebab doctor` first — it surfaces config-load / data-dir / Ollama-reachability problems in one line each. Don't silently retry on errors; report the doctor output.
## MCP server (recommended over CLI subprocess wrapping)
Since v0.3.1, `kebab` exposes an MCP (Model Context Protocol) stdio server. Configure once in `~/.claude/mcp.json`:
```json
{
"mcpServers": {
"kebab": {
"command": "kebab",
"args": ["mcp"]
}
}
}
```
Claude Code spawns `kebab mcp` at session start; the process stays alive across all tool calls so SQLite / Lance / fastembed are hot after the first call. 6 tools available: `search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`. Same wire shapes as the CLI `--json` mode — see `Two surfaces, pick the right one` above for the same guidance.
If your host doesn't support MCP, the CLI subprocess pattern (`kebab search --json` / `kebab ask --json`) above continues to work.
For per-tool input/output examples, error code reference, multi-turn ask + session management, and host config beyond Claude Code (Cursor / OpenAI Agents / Copilot CLI), see [docs/mcp-usage.md](../../../docs/mcp-usage.md) in the kebab repo.
## Recipe D — agent fetched a web doc, save to KB
When you've fetched a markdown article (e.g. via WebFetch) that the user might query later:
1. Call MCP tool `ingest_stdin` with:
- `content`: the markdown body
- `title`: a stable title (article H1 or page title)
- `source_uri`: the URL you fetched from
The doc lands in `<workspace.root>/_external/<hash>.md` and is indexed for `search` / `ask` immediately. Subsequent calls with identical content are no-ops (incremental ingest detects unchanged hash).
Don't loop ingest the same article — content-hash dedup makes it safe but wastes embedding cost.
For files already on disk that the user references, prefer `ingest_file` with the path — kebab handles the copy + dedup.
## Workflow recipes
**Recipe A — user asks an internal-context question, you want grounded answer:**

View File

@@ -14,6 +14,79 @@ historical contract that was implemented; this file accumulates the
deltas so phase 5+ readers can find the live behavior without diffing
git history.
## 2026-05-07 — p9-fb-31 (post-dogfooding): single-file / stdin ingest
**Source feedback**: 사용자 도그푸딩 2026-05-06 — agent (Claude Code via MCP, fb-30) 가 web fetch 한 markdown / 단일 외부 file 을 KB 에 저장하려면 `kebab ingest` 전체 walk 재실행 비효율. agent 메모리상 string contents 도 stdin ingest 가능해야.
**Live binding 변경**:
- 신규 subcommand `kebab ingest-file <path>` — 단일 file ingest, workspace 외부 path 가능.
- 신규 subcommand `kebab ingest-stdin --title <T> [--source-uri <URI>]` — stdin 의 markdown 본문 ingest, v1 markdown only.
- 신규 MCP tool `ingest_file` + `ingest_stdin` — fb-30 v1 read-only 정책 변경, 첫 mutation surface 도입 (의도된 진화). tools/list 4 → 6.
- 외부 file 저장 정책: `<workspace.root>/_external/<blake3-12>.<ext>` 로 copy. deterministic 명명 → idempotent. `_external/` 첫 생성 시 `.kebabignore` 자동 append (walk 무한 루프 방지).
- `.kebabignore` 매치 시 stderr warn (`warn: <path> matches .kebabignore patterns; proceeding (explicit ingest bypasses ignore)`) 후 진행. `--force-ignore` flag 불필요 — explicit ingest 가 default bypass intent.
- stdin frontmatter 처리: 본문이 `---` 으로 시작하면 error (`use kebab ingest-file`); 그 외 frontmatter block prepend (title + 옵션 source_uri, YAML 더블쿼트 escape).
- `kebab-app::external` 신규 모듈 — `ensure_external_dir`, `ensure_kebabignore_entry`, `copy_to_external`, `inject_frontmatter` helper. kebab-cli + kebab-mcp 둘 다 facade 통해 호출.
- `kebab-app::ingest_file_with_config` + `ingest_stdin_with_config` 신규 facade fn.
**Spec contract impact**: design §6 에 `_external/` subdirectory 절 추가 (실제 §6.7 — 기존 §6 sub-section 이 6.6 까지 채워져 있어 §6.7 로 부착됨; spec stub 의 §6.3 명시는 deviation).
**Tests added**: kebab-app external::tests (14: dir / kebabignore append / copy / inject_frontmatter / yaml_quote), kebab-app integration (3 + 3: ingest_file + ingest_stdin), kebab-cli integration (2: cli_ingest_file + cli_ingest_stdin spawn-based), kebab-mcp integration (1 + 2: tools_call_ingest_file + tools_call_ingest_stdin), tools_list assertion update (4 → 6).
**Known limitation (deferred)**:
- PDF / image stdin — binary stream + base64 처리 v2.
- `--title` + `--source-uri` 외 metadata field (tags, language, custom kv) — v2.
- 자동 dedup by source_uri — content hash 기반 dedup 만 (incremental ingest). URI lookup 별 task.
- Storage quota / TTL — agent 무한 ingest 시 KB 비대 우려. monitor + 별 task.
- frontmatter merge (stdin 이 이미 frontmatter 보유 시 머지) — v1 은 error.
- MCP `ingest_file` 의 multi-file batch 입력 — v1 single path. 여러 file 호출은 agent 가 N 회.
**Amends**:
- design §6 (`_external/` subdirectory subsection 추가, §6.7 위치).
- spec `tasks/p9/p9-fb-31-single-file-stdin-ingest.md` (status `open``completed`).
- spec stub 의 §6.3 명시 → 실제 §6.7 (기존 §6 구조 우선).
## 2026-05-07 — p9-fb-30 (post-dogfooding): MCP server (stdio) — agent integration MVP
**Source feedback**: 사용자 도그푸딩 2026-05-06 — Claude Code 같은 AI agent 가 kebab CLI 를 사용하는 것이 궁극 목표. 현재 surface 는 Claude Code 전용 skill (subprocess wrapper) 만 — host 무관 표준 통신 없음. fb-29 HTTP daemon 은 single-user local-first 환경 대비 비대로 deferred (2026-05-07), fb-30 stdio MCP 가 동일 사용자 가치 (agent integration + session 동안 hot cache) 를 daemon 복잡도 없이 제공.
**Live binding 변경**:
- 신규 subcommand `kebab mcp` — stdio JSON-RPC server, `--config <path>` honor.
- 신규 crate `kebab-mcp` (lib only) — `serve_stdio(Config, Option<PathBuf>)` entry. UI crate 카테고리 (kebab-cli + kebab-tui + kebab-mcp 가 facade 룰 동일 적용 — `kebab-app` facade 만 import).
- Tool surface v1 (read-only 4): `search` (lexical/vector/hybrid 검색, default Hybrid), `ask` (RAG 답변, default mode Hybrid, optional `session_id` for multi-turn + optional `mode` override), `schema` (introspection), `doctor` (health check). `ingest_*` / `fetch` / `list_docs` / `inspect_chunk` 는 fb-31 / fb-35 / 후속 task 머지 시 추가.
- Resources / Prompts / Sampling — 모두 미선언 (tools-only v1).
- Output: 모든 tool 이 wire schema v1 JSON 을 MCP `text` content block 으로 직렬화. CLI `--json` 모드와 동일 wire — single source.
- Error mapping: tool dispatch `Err(e)``isError: true` + error.v1 content. Refusal (`grounded: false`) / no-hit (empty array) / unhealthy (`ok: false`) 는 모두 정상 응답 — agent 가 wire payload semantic flag 으로 분기.
- `kebab-app::error_wire` 신규 — fb-27 의 `kebab-cli::error_classify` 코드 그대로 promotion (struct + classify + classify_llm + 7 unit test). kebab-cli + kebab-mcp 둘 다 동일 모듈 사용. reqwest dev-dep 도 함께 이동. 부수 변경: `ErrorV1``schema_version: String` 필드 추가 — kebab-mcp 의 직접 serialize 경로에서도 wire 정합 (kebab-cli 의 `wire_error_v1``tag_object` 는 idempotent 로 작동, 동작 무영향).
- `kebab-app::Capabilities::mcp_server`: `false``true`. `schema_report` 통합 테스트 + `cli_schema` 통합 테스트 assertion 갱신.
- Initialize handshake: `protocolVersion = "2025-03-26"` (rmcp 1.6 default), `capabilities.tools = { listChanged: false }`, `serverInfo = { name: "kebab", version: <CARGO_PKG_VERSION> }`.
- `KebabAppState``(Config, Option<PathBuf>)` carry — `kebab_app::doctor_with_config_path``Option<&Path>` 만 받기 때문 (`doctor_with_config(&Config)` 미존재). path 없으면 `None` (XDG default 동작).
- `tokio::task::spawn_blocking` wrap on `call_tool` arms for `ask` + `search``OllamaLanguageModel``reqwest::blocking::Client::build()` 가 내부적으로 tokio runtime create+drop 하므로 async 안에서 panic. spawn_blocking 으로 우회. schema / doctor 는 cheap reads 라 wrap 불필요.
- `tools/list` 의 list construction 을 `pub fn build_tools_vec()` 로 추출 — rmcp 1.6 가 in-memory test transport 미노출이라 spawn 없이 unit-level 검증 위함.
**Spec contract impact**: design §10 에 §10.2 MCP transport 절 추가.
**Tests added**: kebab-mcp integration (5: tools_call_search / tools_call_ask / tools_call_schema / tools_call_doctor / tools_list / error_mapping + initialize), kebab-cli integration (1: cli_mcp_smoke spawn + initialize + tools/list round-trip). 약 8 신규 테스트.
**Known limitation (deferred)**:
- HTTP-SSE transport — fb-29 P+ deferral 따라 stdio 단일. browser agent / remote 시나리오 등장 시 재개.
- Resources (`kebab://chunk/<id>` URI) — fb-35 verbatim fetch 와 함께 v2.
- Prompts — RAG 자체 prompt template 내장으로 사용자 가치 약함, defer.
- Streaming `ask` — fb-33 streaming ask 와 함께.
- `ingest_*` / `fetch` / `list_docs` / `inspect_chunk` tools — 후속 task 별로 추가.
- Server-scope state caching — 현재 매 tool call 마다 store open. 첫 call 시 `KebabAppState``OnceLock<SqliteStore>` 도입 검토 (post-merge 후속 PR).
- rmcp SDK API 호환성 — 1.6 채택, 미래 major bump 시 별 task.
- Manual `tools/list` + `tools/call` dispatch 채택 — rmcp 1.6 의 `#[tool_router]` 매크로보다 명시적, 디버깅 쉬움. 하지만 새 tool 추가 시 두 곳 (list_tools 의 vec + call_tool 의 match) 동시 갱신 필요. 후속 task 가 5개 이상 tool 추가하면 매크로 도입 재검토.
- `AskOpts``Default` 미도입 — kebab-cli + kebab-tui + kebab-mcp 의 모든 호출 site 가 9 field 를 명시적으로 초기화. 새 field 추가 시 모든 site 동시 갱신 필요. `impl Default for AskOpts` 또는 builder 패턴 도입은 별 PR.
**Amends**:
- design §10 (MCP transport subsection 추가).
- spec `tasks/p9/p9-fb-30-mcp-server.md` (status `open``completed`).
- spec stub 의 `transport: stdio default + http (fb-29 daemon) 위에 SSE 옵션` → 실제 채택 stdio 단일 (fb-29 deferral 결과, 2026-05-07 commit `2e8de14` 의 spec 갱신과 일관).
## 2026-05-07 — p9-fb-27 (post-dogfooding): introspection (`kebab schema`) + structured error wire
**Source feedback**: 사용자 도그푸딩 2026-05-06 — agent 가 kebab 인스턴스의 wire 버전 / 기능 / 모델 / 인덱스 통계 introspect 못 함; error 가 stderr text 라 substring 분기 필요.

View File

@@ -111,12 +111,12 @@ P0~P5 는 직렬. P6~P9 는 P5 이후 병렬 가능.
- [p9-fb-25 config workspace.include 제거 + 지원 형식 가시성 (post-도그푸딩)](p9/p9-fb-25-config-include-removal.md)
- **⏳ fb-26 ~ fb-42: 백로그 only — 미구현 + brainstorm 선행 필요.** spec 작성 시 [superpowers:brainstorming](../docs/superpowers/) 부터 시작. status: open. 다른 세션에서 이 그룹 손대기 전 사용자 확인 필요. **번호 = release 순서** — 작은 번호일수록 먼저 작업 (2026-05-06 renumber).
### 🎯 0.3.0 — agent foundation (MCP + daemon + introspection)
### 🎯 0.3.0+ — agent foundation (MCP + introspection)
- [p9-fb-26 ingest 로그 출력 일관성](p9/p9-fb-26-ingest-log-consistency.md) — ⏳ 미구현, brainstorm 필요
- [p9-fb-27 introspection + structured error wire](p9/p9-fb-27-introspection-and-error-wire.md) — ⏳ 미구현, brainstorm 필요
- [p9-fb-27 introspection + structured error wire](p9/p9-fb-27-introspection-and-error-wire.md) — ✅ 머지 + v0.3.0 cut (2026-05-07)
- [p9-fb-28 agent invocation flags (--readonly / --quiet)](p9/p9-fb-28-agent-invocation-flags.md) — ⏳ 미구현, brainstorm 필요
- [p9-fb-29 HTTP daemon (`kebab serve`)](p9/p9-fb-29-http-daemon.md) — ⏳ 미구현, brainstorm 필요
- [p9-fb-30 MCP server](p9/p9-fb-30-mcp-server.md) — ⏳ 미구현, brainstorm 필요 (depends_on 27, 29)
- [p9-fb-29 HTTP daemon (`kebab serve`)](p9/p9-fb-29-http-daemon.md) — 🚫 deferred (2026-05-07) — fb-30 stdio MCP 가 동일 가치 제공, daemon 복잡도 회피. P+ 재개 trigger 는 spec 참조.
- [p9-fb-30 MCP server](p9/p9-fb-30-mcp-server.md) — ⏳ 미구현, brainstorm 필요 (depends_on 27 ✅, stdio-only)
- [p9-fb-31 single-file / stdin ingest](p9/p9-fb-31-single-file-stdin-ingest.md) — ⏳ 미구현, brainstorm 필요
### 🎯 0.4.0 — agent surface refinement (additive only)

View File

@@ -3,10 +3,10 @@ phase: P9
component: kebab-cli + new crate (kebab-server)
task_id: p9-fb-29
title: "HTTP daemon (`kebab serve`) — subprocess overhead 제거"
status: open
target_version: 0.3.0
status: deferred
target_version: P+
depends_on: []
unblocks: [p9-fb-30]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 RAG, §10 UX]
source_feedback: 사용자 도그푸딩 2026-05-06 — agent loop 가 kebab CLI 를 반복 호출 시 subprocess fork + Lance/SQLite cold start 비용 누적. local HTTP daemon 이 latency 해결.
@@ -14,7 +14,12 @@ source_feedback: 사용자 도그푸딩 2026-05-06 — agent loop 가 kebab CLI
# p9-fb-29 — HTTP daemon (`kebab serve`)
> **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. bind / auth / endpoint scheme / lifecycle (auto-start vs explicit) brainstorm 후 확정.
> 🚫 **Deferred (2026-05-07 brainstorm).** fb-30 stdio MCP 가 동일 사용자 가치 (agent integration + session 동안 hot cache) 를 daemon 복잡도 (PID file / port lock / single-instance / lifecycle UX / loopback security) 없이 제공. single-user local-first 환경에서 HTTP transport 가치 미미 (cold start 의 dominant cost = fastembed model load 는 stdio MCP subprocess 가 session 동안 보유 시 동일하게 회피, ask 는 Ollama 추론 latency 가 dominant 라 daemon 효과 제한적). 본 task 는 다음 trigger 시 재개:
> - browser agent / remote multi-host 시나리오 등장 (현재 사용자 패턴 외).
> - TUI ↔ CLI 다중 인스턴스 state sharing 요구.
> - fb-30 MCP HTTP-SSE transport 옵션 도입 검토.
>
> fb-30 의 prerequisite 였던 본 task 는 fb-30 stdio-only 결정으로 의존 제거. 본 spec 은 brainstorm 결정의 기록 보존용.
## 증상 / 동기

View File

@@ -3,9 +3,9 @@ phase: P9
component: integrations + new crate (kebab-mcp)
task_id: p9-fb-30
title: "MCP server — agent host 무관 protocol surface"
status: open
status: completed
target_version: 0.3.0
depends_on: [p9-fb-27, p9-fb-29]
depends_on: [p9-fb-27]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§7 RAG, §10 UX, externalAI 통합 절]
@@ -14,7 +14,7 @@ source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 같은 AI age
# p9-fb-30 — MCP server
> **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. transport 선택 (stdio / socket) / tool surface 범위 / authentication / resources vs tools 매핑 brainstorm 후 확정.
> **구현 완료.** 본 spec 은 구현 시점의 frozen 상태. post-merge deviation (특히 `error.v1` 에 schema_version 필드 추가, ask/search spawn_blocking, manual dispatch 채택) 은 [HOTFIXES.md](../HOTFIXES.md) 의 `2026-05-07 — p9-fb-30` 항목 참조 — live source of truth.
## 증상 / 동기
@@ -32,7 +32,7 @@ source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 같은 AI age
## 후속 작업 — brainstorm 필요 항목
- transport: stdio default, http (fb-29 daemon) 위에 SSE 옵션.
- transport: stdio only (default + sole). fb-29 HTTP daemon 은 deferred — HTTP-SSE 옵션은 browser agent / remote 시나리오 demand 발생 시 fb-29 와 함께 재개.
- tool 이름 / 인자 스키마 — wire schema v1 재사용 가능?
- authentication — local-only 면 무인증, daemon 위면 token.
- 새 crate `kebab-mcp` 위치 / 의존성 boundary (kebab-app facade 만 import).
@@ -41,4 +41,4 @@ source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 같은 AI age
- MCP spec 진화 중 — 버전 lock 명시 필요.
- skill 과 surface 중복 — 사용자 혼란 방지 README 안내.
- fb-29 (daemon) 선행 또는 동시 — daemon 모드 위에 MCP HTTP 변형 가능.
- fb-29 deferral 결과 — MCP transport 는 stdio 단일. HTTP 변형은 future task.

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-cli + kebab-app
task_id: p9-fb-31
title: "Single-file / stdin ingest — agent on-demand 저장"
status: open
status: completed
target_version: 0.3.0
depends_on: []
unblocks: []
@@ -14,7 +14,7 @@ source_feedback: 사용자 도그푸딩 2026-05-06 — agent 가 읽은 article
# p9-fb-31 — Single-file / stdin ingest
> **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. workspace 외부 file 의 저장 위치 / metadata 입력 방식 / .kebabignore 우회 정책 brainstorm 후 확정.
> **구현 완료.** 본 spec 은 구현 시점의 frozen 상태. post-merge deviation 은 [HOTFIXES.md](../HOTFIXES.md) 의 `2026-05-07 — p9-fb-31` 항목 참조 — live source of truth.
## 증상 / 동기