diff --git a/.mcp.json b/.mcp.json index 7a7f94ef..ba9a72fc 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,5 +1,15 @@ { "mcpServers": { + "code-intel": { + "command": "code-intel", + "args": [ + "serve", + "--mcp", + "--repo", + "code-intel-pipeline" + ], + "description": "code-intel: agent query surface. Read-only; gates nowhere. Data source differs per tool — get_gate_verdict / get_facts / get_evidence / get_audit_status project the last committed run; get_change_impact reads the committed import graph but compares it against the live --repo-path and labels the result stale-advisory; plan_structural_edit scans the live checkout and writes nothing. --repo is pinned because a worktree directory name is not the name run commit publishes under; --repo-path defaults to the working directory." + }, "repowise": { "command": "repowise", "args": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e06c6a..1135fe59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to **code-intel-pipeline** are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`code-intel serve --mcp`:agent 原生查询面上线,管线在写码中途终于能被问一句话**(#54、#58 提案 3)。stdio MCP server,六个工具:`get_gate_verdict`(权威 run 的门禁结论 + 第一条失败规则 + 最小重跑命令)、`get_facts`(按 artifact type/schema/子串查已验证事实)、`get_evidence`(一条 finding 的证据链:产物、sha256、记录时的 snapshot;查不到明说 `unbacked`,不装真)、`get_audit_status`(各科室结论;没跑过 audit 明说 `unavailable`,不装绿)、`get_change_impact`(默认 stale-advisory——CLI 在这里 fail-closed 正是管线写码时隐形的原因,#58 定为 critical)、`plan_structural_edit`(ast-grep 预览,`repositoryMutation=false`)。**只读,不裁决**:门禁判定照旧只走 CLI 与 CI,查询面被 prompt injection 说服也改不了结论;唯一会执行东西的 `plan_structural_edit` 在跑之前拿注册表核对自己的 capability 声明,一旦出现 `repo_mutation` 直接拒绝(有测试为证)。路径参数复用 `change_impact` / `evidence_query` 的既有请求类型,JSON 进来的路径和 `--changed` 打进来的走同一道越界闸。工具拒答走 `isError` 结果而不是 JSON-RPC error——"还没跑过 run"是答案,不是传输故障。声明的 MCP 版本收窄到 `2025-06-18` 一版:更早的修订要求 JSON-RPC 批量支持,本 transport 一行一条消息,宣称就会让客户端发批量后挂死;走错路的批量收到显式 `-32600` 而不是被静默丢弃。仓库 `.mcp.json` 已注册;README 与 SKILL.md 改为主推查询面,全量扫描降为深检模式,并按工具写清数据来源(四个是已提交 run 的投影,`get_change_impact` 是已提交 import 图 × 当前工作树,`plan_structural_edit` 扫的是当前工作树)。 + ## [0.7.0-beta.6] — 2026-08-05 这一批的主线是「门禁自己说的话得能被核对」:扫描面、锚点、巨石身份、退出码、schema 五处都从体感口径换成可计算口径——覆盖率不再靠感觉、锚点不再靠猜、巨石不再靠数数、gate finding 不再伪装成崩溃、产物不再违反自家 schema。另一条线是写路径的第一块落地(span 寻址补丁)和文档语言首选项。 diff --git a/README.md b/README.md index 368f96e7..d5fdb08d 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,33 @@ code-intel audit --operation scope --repo C:\path\to\repo --since ## Agent 工作流 +### 先接查询面,全量扫描是深检模式 + +Agent 平时问单点问题走 MCP,不必为一个问题跑一整轮 pipeline: + +```powershell +code-intel serve --mcp --repo +``` + +stdio MCP server,按需起、随 session 生灭。注册进 `.mcp.json` 后 Claude Code / Codex 直连可调。**数据来源逐个工具不同**,读答案时按这一列判它有多新: + +| 工具 | 回答什么 | 数据来源 | +|---|---|---| +| `get_gate_verdict` | 权威 run 的门禁结论、第一条失败规则、最小重跑命令 | 已提交 run;附 freshness | +| `get_facts` | 按 artifact type / schema / 子串查已验证事实(热点、import 边、scorecard、符号) | 已提交 run(digest 已校验) | +| `get_evidence` | 一条 finding 的证据链:哪些产物提到它、各自 sha256、记录时的 snapshot | 已提交 run | +| `get_audit_status` | 各科室审计结论、评分、覆盖;没跑过 audit 会明说"不可用"而不是装绿 | 已提交 run | +| `get_change_impact` | 改这些文件会波及谁、该先跑哪些测试 | **已提交 import 图 × 当前 `--repo-path`**;默认标 stale-advisory 并同时给出 recorded/current 两个 snapshot identity | +| `plan_structural_edit` | ast-grep 结构改写预览,只出匹配清单,不落盘 | **当前工作树**(不是已提交 run) | + +**这个面只读,不裁决。** 门禁判定照旧只走 CLI 与 CI 路径——查询面被 prompt injection 说服也改不了结论。唯一会执行东西的工具是 `plan_structural_edit`,它在跑之前拿注册表核对自己的 capability 声明,一旦声明里出现 `repo_mutation` 就直接拒绝。 + +`--repo` 建议显式给:worktree 的目录名不是 `run commit` 发布时用的仓名,不给就会去查错仓的 run。`--repo-path` 默认取工作目录。 + +全量 `code-intel --mode normal` 留给深检和出证据,不是日常问答的入口。 + +### 结构门禁 + Agent 开始改代码前: ```powershell diff --git a/crates/code-intel-cli/src/change_impact.rs b/crates/code-intel-cli/src/change_impact.rs index 4594c8d5..85e8c502 100644 --- a/crates/code-intel-cli/src/change_impact.rs +++ b/crates/code-intel-cli/src/change_impact.rs @@ -55,6 +55,39 @@ pub(crate) struct ChangeImpactRequest { changed: Vec, } +impl ChangeImpactRequest { + /// Build a request from already-typed values instead of argv. + /// + /// `changed` goes through the same `normalize_relative` guard the + /// `--changed` flag uses, so a path arriving as a JSON string over the MCP + /// surface cannot escape the repository by a route the flag parser closes. + /// Reusing the guard is the point; re-stating it here would be the bug. + pub(crate) fn new( + artifact_root: PathBuf, + repo: String, + repo_path: PathBuf, + changed: Vec, + ) -> Result { + let mut changed = changed + .iter() + .map(|path| normalize_relative(path)) + .collect::, _>>()?; + changed.sort(); + changed.dedup(); + if changed.is_empty() { + return Err(ImpactError::Contract( + "at least one changed path is required".into(), + )); + } + Ok(Self { + artifact_root, + repo, + repo_path, + changed, + }) + } +} + impl ChangeImpactInvocation { pub(crate) fn parse(raw: &[String]) -> Result { if raw.first().map(String::as_str) != Some("impact") { diff --git a/crates/code-intel-cli/src/cli/command_catalog/mod.rs b/crates/code-intel-cli/src/cli/command_catalog/mod.rs index cf32573b..ab695ca0 100644 --- a/crates/code-intel-cli/src/cli/command_catalog/mod.rs +++ b/crates/code-intel-cli/src/cli/command_catalog/mod.rs @@ -6,8 +6,8 @@ use serde_json::{json, Value}; use crate::{ admissibility, artifact_index, audit_report, change_agenda, change_impact, change_risk, compatibility_retirement_ticket, decision_port, decision_record, doctor_bootstrap, edit_apply, - edit_impact, evidence_query, invocation_identity, model_channels, ponytail_gate, providers, - repin, run_cli, run_commit, session_evidence, snapshot, survival_scan, + edit_impact, evidence_query, invocation_identity, mcp_serve, model_channels, ponytail_gate, + providers, repin, run_cli, run_commit, session_evidence, snapshot, survival_scan, }; use super::legacy::{ @@ -85,6 +85,7 @@ enum CompatibilityRoute { Decision, RunExecute, RunDagCoordinate, + Serve, Governance, } @@ -450,6 +451,7 @@ fn execute_compatibility(command: CompatibilityCommand) -> i32 { CompatibilityRoute::RunExecute | CompatibilityRoute::RunDagCoordinate => { run_cli::run_raw(raw) } + CompatibilityRoute::Serve => mcp_serve::run_raw(raw), CompatibilityRoute::Governance => ponytail_gate::run_raw(raw), } } diff --git a/crates/code-intel-cli/src/cli/command_catalog/routes/mod.rs b/crates/code-intel-cli/src/cli/command_catalog/routes/mod.rs index 04da470c..1b604ff6 100644 --- a/crates/code-intel-cli/src/cli/command_catalog/routes/mod.rs +++ b/crates/code-intel-cli/src/cli/command_catalog/routes/mod.rs @@ -7,6 +7,7 @@ use crate::cli::help_contract::{HELP_ALIASES, HELP_COMMAND}; mod edit_routes; mod run_routes; +mod serve_routes; mod types; pub(super) use types::{CommandRoute, LegacyRoute, RawRoute, VersionRoute}; @@ -540,6 +541,7 @@ pub(super) const COMMAND_ROUTES: &[CommandRoute] = &[ }, CommandRoute::Raw(run_routes::EXECUTE), CommandRoute::Raw(run_routes::DAG_COORDINATE), + CommandRoute::Raw(serve_routes::MCP), raw_route! { command: "governance", subcommand: None, diff --git a/crates/code-intel-cli/src/cli/command_catalog/routes/serve_routes.rs b/crates/code-intel-cli/src/cli/command_catalog/routes/serve_routes.rs new file mode 100644 index 00000000..377468e2 --- /dev/null +++ b/crates/code-intel-cli/src/cli/command_catalog/routes/serve_routes.rs @@ -0,0 +1,54 @@ +//! The agent-native query surface (#54, #58 proposal 3). +//! +//! Split out of `routes.rs` for the reason `edit_routes` and `run_routes` +//! were: the route table is the file every new command touches, and it already +//! sits just under this repository's own god-file threshold. The seam here is +//! the transport — this is the only route that speaks a protocol rather than +//! argv-in / stdout-out, and the only one whose process lifetime is a client +//! session rather than a single answer. + +/// `serve` takes no subcommand so the transport stays a flag: `--mcp` is the +/// only one today, and a future transport should be `serve --http`, not a +/// second route with a duplicated contract. The parser refuses when no +/// transport is named rather than defaulting to one, because "which protocol +/// is this process speaking on stdio" is not a question to guess at. +/// +/// `LocalWrite` and `ProcessSpawn` are declared for the single tool that +/// executes anything — `plan_structural_edit` stages an ast-grep preview into +/// a temporary directory. `RepoMutation` is absent and the handler refuses if +/// the registry ever declares it, so the effect set here is the enforced +/// boundary, not a description of intent. +pub(super) const MCP: super::RawRoute = super::RawRoute { + command: "serve", + subcommand: None, + argument_offset: 1, + id: super::CompatibilityRoute::Serve, + contract: super::CommandContract { + stability: super::CommandStability::Public, + controller: super::ControllerOwnership::AgentSession, + authority: super::CommandAuthority::Conditional( + super::AuthorityCondition::CommittedOrStaleAdvisory, + ), + effects: &[ + super::CommandEffect::RepoRead, + super::CommandEffect::LocalWrite, + super::CommandEffect::ProcessSpawn, + ], + output_contract: super::OutputContract::Stdout { + identities: &[ + "text-format:mcp-jsonrpc-stream.v1", + "code-intel-mcp-gate-verdict.v1", + "code-intel-mcp-evidence-chain.v1", + "code-intel-mcp-audit-status.v1", + "code-intel-mcp-structural-edit-plan.v1", + "code-intel-mcp-tool-error.v1", + "code-intel-evidence-query.v1", + "code-intel-change-impact.v1", + ], + }, + exit_contract: super::ExitContract::Exact(&[0, 64, 74]), + retirement_condition: + "retire only through a versioned agent query-surface replacement; the served payloads \ +are projections and may be retired individually with their source contracts", + }, +}; diff --git a/crates/code-intel-cli/src/cli/command_catalog/tests.rs b/crates/code-intel-cli/src/cli/command_catalog/tests.rs index a5851ae5..53c82bff 100644 --- a/crates/code-intel-cli/src/cli/command_catalog/tests.rs +++ b/crates/code-intel-cli/src/cli/command_catalog/tests.rs @@ -208,7 +208,7 @@ fn unified_route_inventory_owns_version_primary_raw_and_legacy_dispatch() { .iter() .filter(|route| matches!(route, CommandRoute::Raw(_))) .count(), - 31 + 32 ); assert_eq!( COMMAND_ROUTES diff --git a/crates/code-intel-cli/src/cli/legacy.rs b/crates/code-intel-cli/src/cli/legacy.rs index 75432964..fbc40c36 100644 --- a/crates/code-intel-cli/src/cli/legacy.rs +++ b/crates/code-intel-cli/src/cli/legacy.rs @@ -1156,6 +1156,7 @@ Commands: run execute --repo --out --authority-root --final-name [--profile default|strict|offline] [--manifest ] [--max-concurrency ] [--session-evidence ] run dag-coordinate --repo --out [--manifest ] [--max-concurrency ] [--session-evidence ] run commit --source-root --authority-root --manifest-ref --final-name + serve --mcp [--repo-path ] [--repo ] [--artifact-root ] [--manifest ] (stdio MCP query surface over the committed run; read-only, gates nowhere) benchmark orientation --out [--repetitions <2..10>] benchmark tools --corpus --runs --artifact-root --out governance ponytail-gate --request diff --git a/crates/code-intel-cli/src/evidence_query.rs b/crates/code-intel-cli/src/evidence_query.rs index c3fb8de9..01286410 100644 --- a/crates/code-intel-cli/src/evidence_query.rs +++ b/crates/code-intel-cli/src/evidence_query.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; use crate::committed_evidence::{self, CommittedEvidence, EvidenceError}; const DEFAULT_LIMIT: usize = 20; -const MAX_LIMIT: usize = 100; +pub(crate) const MAX_LIMIT: usize = 100; const PREVIEW_CHARS: usize = 400; pub(crate) fn run_raw(raw: &[String]) -> i32 { @@ -43,6 +43,38 @@ pub(crate) struct EvidenceQueryRequest { } impl EvidenceQueryRequest { + /// Build a request from already-typed values instead of argv. + /// + /// The MCP query surface receives its filters as JSON, not as flags, but + /// must not therefore get a laxer request: the limit bound checked here is + /// the same `1..=MAX_LIMIT` the flag parser enforces, read from the same + /// constant. A second copy of that range is how the two surfaces would + /// drift. + pub(crate) fn new( + artifact_root: PathBuf, + repo: String, + repo_path: Option, + artifact_schema: Option, + artifact_type: Option, + contains: Option, + limit: usize, + ) -> Result { + if !(1..=MAX_LIMIT).contains(&limit) { + return Err(QueryError::Contract( + "limit must be an integer in 1..=100".into(), + )); + } + Ok(Self { + artifact_root, + repo, + repo_path, + artifact_schema, + artifact_type, + contains, + limit, + }) + } + pub(crate) fn parse(raw: &[String]) -> Result { if raw.first().map(String::as_str) != Some("query") { return Err(QueryError::Contract("usage: artifact query --artifact-root --repo [--repo-path ] [--artifact-schema ] [--type ] [--contains ] [--limit <1..100>]".into())); diff --git a/crates/code-intel-cli/src/main.rs b/crates/code-intel-cli/src/main.rs index 2074831f..ebfdc9e8 100644 --- a/crates/code-intel-cli/src/main.rs +++ b/crates/code-intel-cli/src/main.rs @@ -40,6 +40,7 @@ mod hospital_score; mod impact_graph; mod invocation_identity; mod language_pref; +mod mcp_serve; mod method_catalog; mod model_channels; mod orchestration; diff --git a/crates/code-intel-cli/src/mcp_serve/handlers.rs b/crates/code-intel-cli/src/mcp_serve/handlers.rs new file mode 100644 index 00000000..ffb2bd90 --- /dev/null +++ b/crates/code-intel-cli/src/mcp_serve/handlers.rs @@ -0,0 +1,506 @@ +//! The six served tools. +//! +//! Each one is a projection: it re-reads what `run commit` published, or it +//! re-runs a registered preview capability. None of them decides anything. The +//! request types come from the same modules the CLI parsers use +//! (`evidence_query`, `change_impact`), so a path arriving as a JSON string +//! over stdio meets the same traversal and inventory guards as one typed after +//! `--changed` — there is no second, looser parser on this side. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{json, Map, Value}; + +use super::ServeContext; +use crate::committed_evidence::{self, CommittedEvidence, EvidenceError}; +use crate::{ + capability, capability_inventory, change_impact, evidence_query, execution_policy, snapshot, +}; + +const EDIT_PLAN_CAPABILITY: &str = "edit.ast-grep-plan"; +const DEFAULT_LIMIT: usize = 20; +/// Read from `evidence_query` rather than restated: the tool descriptors, the +/// `--limit` flag, and this surface must agree on the ceiling, and three copies +/// of `100` is how they stop agreeing. +use crate::evidence_query::MAX_LIMIT; + +pub(super) fn call(context: &ServeContext, name: &str, arguments: &Value) -> Result { + if !arguments.is_object() { + return Err("arguments must be a JSON object".into()); + } + match name { + "get_gate_verdict" => gate_verdict(context, arguments), + "get_facts" => facts(context, arguments), + "get_evidence" => evidence_chain(context, arguments), + "get_audit_status" => audit_status(context, arguments), + "get_change_impact" => blast_radius(context, arguments), + "plan_structural_edit" => structural_edit(context, arguments), + other => Err(format!("unknown tool: {other}")), + } +} + +fn gate_verdict(context: &ServeContext, arguments: &Value) -> Result { + expect_keys(arguments, &[])?; + let evidence = load(context)?; + let freshness = freshness(&evidence, context)?; + let mut result = json!({ + "schema": "code-intel-mcp-gate-verdict.v1", + "repo": context.repo, + "run": evidence.entry["run"], + "runIdentity": evidence.entry["runIdentity"], + "runOutcome": evidence.entry["outcome"], + "snapshotIdentity": evidence.snapshot_identity(), + "freshness": freshness, + "minimalRerunCommand": rerun_command(context), + // Said out loud on every verdict: reading a verdict here is not the + // same as the gate having run, and this surface cannot make it run. + "authority": {"status": "committed", "readOnly": true, "gatesHere": false}, + }); + match evidence.artifact("diagnosis.hospital") { + Some((artifact_ref, verified)) => { + let hospital: Value = serde_json::from_slice(verified.bytes()) + .map_err(|error| format!("committed hospital artifact is invalid JSON: {error}"))?; + let triage = &hospital["triage"]; + let failing = triage["failing_rules"] + .as_array() + .cloned() + .unwrap_or_default(); + result["verdict"] = json!({ + "status": triage["status"], + "domainVerdict": hospital["domainVerdict"], + "primaryDiagnosis": triage["primary_diagnosis"], + "disposition": triage["disposition"], + "risk": hospital["diagnosis"]["risk"], + }); + result["firstFailingRule"] = failing.first().cloned().unwrap_or(Value::Null); + result["failingRuleCount"] = json!(failing.len()); + result["evidenceRef"] = artifact_ref.clone(); + } + None => { + result["verdict"] = json!({ + "status": "unknown", + "reason": "the committed run publishes no diagnosis.hospital artifact", + }); + result["firstFailingRule"] = Value::Null; + result["failingRuleCount"] = Value::Null; + result["evidenceRef"] = Value::Null; + } + } + Ok(result) +} + +fn facts(context: &ServeContext, arguments: &Value) -> Result { + expect_keys(arguments, &["type", "artifactSchema", "contains", "limit"])?; + let request = evidence_query::EvidenceQueryRequest::new( + context.artifact_root.clone(), + context.repo.clone(), + Some(context.repo_path.clone()), + optional_text(arguments, "artifactSchema")?, + optional_text(arguments, "type")?, + optional_text(arguments, "contains")?, + optional_limit(arguments)?, + ) + .map_err(query_message)?; + let evidence = load(context)?; + evidence_query::execute(request, &evidence) + .map(|result| result.value().clone()) + .map_err(query_message) +} + +/// The provenance chain behind one identifier. +/// +/// "Which artifacts mention this" is a weaker claim than "this finding is +/// real", and the result says so: `status` reports whether committed evidence +/// backs the identifier at all, and an empty chain is reported as `unbacked` +/// rather than as an error. An agent asked to justify a finding needs to be +/// able to discover that nothing recorded supports it. +fn evidence_chain(context: &ServeContext, arguments: &Value) -> Result { + expect_keys(arguments, &["findingId", "limit"])?; + let finding = required_text(arguments, "findingId")?; + let limit = optional_limit(arguments)?; + let evidence = load(context)?; + let needle = finding.to_lowercase(); + let mut links = Vec::new(); + let mut scanned = 0usize; + let mut truncated = false; + for (artifact_ref, verified) in evidence.refs.iter().zip(evidence.verified.iter()) { + scanned += 1; + if !String::from_utf8_lossy(verified.bytes()) + .to_lowercase() + .contains(&needle) + { + continue; + } + if links.len() == limit { + truncated = true; + break; + } + links.push(json!({ + "artifactRef": artifact_ref, + "artifactType": artifact_ref["type"], + "artifactSchema": artifact_ref["artifactSchema"], + "sha256": artifact_ref["sha256"], + "consumedSnapshotIdentity": artifact_ref["consumedSnapshotIdentity"], + })); + } + Ok(json!({ + "schema": "code-intel-mcp-evidence-chain.v1", + "repo": context.repo, + "run": evidence.entry["run"], + "runIdentity": evidence.entry["runIdentity"], + "snapshotIdentity": evidence.snapshot_identity(), + "findingId": finding, + "status": if links.is_empty() { "unbacked" } else { "backed" }, + "links": links, + "linksTruncated": truncated, + "artifactsScanned": scanned, + "explanation": "Each link is an A07-committed artifact whose verified bytes mention the \ + requested identifier. A digest-verified mention is provenance, not proof that the finding is \ + correct; 'unbacked' means no committed artifact mentions it, not that it is false.", + })) +} + +fn audit_status(context: &ServeContext, arguments: &Value) -> Result { + expect_keys(arguments, &["department"])?; + let wanted = optional_text(arguments, "department")?; + let evidence = load(context)?; + let mut result = json!({ + "schema": "code-intel-mcp-audit-status.v1", + "repo": context.repo, + "run": evidence.entry["run"], + "snapshotIdentity": evidence.snapshot_identity(), + "department": wanted, + }); + let Some((artifact_ref, verified)) = evidence.artifact("diagnosis.audit") else { + // An absent audit is not a clean audit. Naming the reason keeps an + // agent from reading silence as a pass. + result["status"] = json!("unavailable"); + result["reason"] = json!( + "the committed run publishes no diagnosis.audit artifact; no audit department has run \ +against this snapshot" + ); + return Ok(result); + }; + let report: Value = serde_json::from_slice(verified.bytes()) + .map_err(|error| format!("committed audit artifact is invalid JSON: {error}"))?; + let matches = |row: &Value, key: &str| match &wanted { + Some(wanted) => row[key] == json!(wanted), + None => true, + }; + let filtered = |key: &str, id_key: &str| -> Vec { + report[key] + .as_array() + .into_iter() + .flatten() + .filter(|row| matches(row, id_key)) + .cloned() + .collect() + }; + let departments = filtered("departments", "id"); + if wanted.is_some() && departments.is_empty() { + result["status"] = json!("unknown_department"); + result["reason"] = json!("the committed audit report has no run for that department"); + result["knownDepartments"] = json!(report["departments"] + .as_array() + .into_iter() + .flatten() + .filter_map(|row| row["id"].as_str()) + .collect::>()); + return Ok(result); + } + result["status"] = json!("available"); + result["departments"] = json!(departments); + result["scores"] = json!(filtered("scores", "department")); + result["coverage"] = json!(filtered("coverage", "department")); + result["findings"] = json!(filtered("findings", "department")); + result["evidenceRef"] = artifact_ref.clone(); + Ok(result) +} + +/// The mid-edit question the CLI refuses by design. +/// +/// `change impact` fails closed when the committed snapshot is not current, +/// which is always true while an agent is typing — that refusal is what made +/// the pipeline invisible at write time (#58). Here the default is the +/// advisory answer, labelled with both snapshot identities, and `requireCurrent` +/// restores the strict behaviour for a caller that wants it. +fn blast_radius(context: &ServeContext, arguments: &Value) -> Result { + expect_keys(arguments, &["changed", "requireCurrent"])?; + let changed = required_string_array(arguments, "changed")?; + let require_current = optional_bool(arguments, "requireCurrent")?.unwrap_or(false); + // The request is built before the evidence is loaded so a rejected path + // costs an argument check rather than a full index rebuild — and so the + // traversal guard is reachable in a test that has no committed run. + let request = change_impact::ChangeImpactRequest::new( + context.artifact_root.clone(), + context.repo.clone(), + context.repo_path.clone(), + changed, + ) + .map_err(impact_message)?; + let evidence = load(context)?; + let result = if require_current { + change_impact::execute_committed(request, &evidence) + } else { + change_impact::execute_stale_advisory(request, &evidence) + } + .map_err(impact_message)?; + Ok(result.into_value()) +} + +fn structural_edit(context: &ServeContext, arguments: &Value) -> Result { + expect_keys(arguments, &["language", "pattern", "rewrite", "paths"])?; + let language = required_text(arguments, "language")?; + let pattern = required_text(arguments, "pattern")?; + let rewrite = optional_text(arguments, "rewrite")?; + let paths = match arguments.get("paths") { + None | Some(Value::Null) => vec![".".to_string()], + Some(_) => required_string_array(arguments, "paths")?, + }; + let declaration = + capability::declaration_for(EDIT_PLAN_CAPABILITY, context.manifest.as_deref()) + .map_err(|error| format!("capability registry: {error}"))?; + let policy = + execution_policy::ExecutionPolicy::for_profile(execution_policy::RunProfile::Default); + let allowed_effects = policy.allowed_effects(&declaration); + refuse_repository_mutation(&allowed_effects)?; + let snapshot = snapshot::build_for_dag(&context.repo_path, "explicit_overlay", &paths) + .map_err(|error| format!("snapshot identity for the requested paths: {error}"))?; + + // `rewrite` is inserted only when supplied: the adapter validates its + // options as a closed key set and rejects a null rewrite as an empty + // string, so an omitted argument must stay an omitted key. + let mut options = Map::new(); + options.insert("repoPath".into(), json!(context.repo_path)); + options.insert("language".into(), json!(language)); + options.insert("pattern".into(), json!(pattern)); + if let Some(rewrite) = &rewrite { + options.insert("rewrite".into(), json!(rewrite)); + } + options.insert("paths".into(), json!(paths)); + + let request = json!({ + "schema": "code-intel-capability-request.v1", + "capability": EDIT_PLAN_CAPABILITY, + "contractVersion": 1, + "implementation": declaration["implementation"], + "snapshot": snapshot["snapshot"], + "options": options, + "inputs": [], + "effectPolicy": {"allowedEffects": allowed_effects}, + }); + + let staging = staging_path()?; + let outcome = capability::exec_in_process( + EDIT_PLAN_CAPABILITY, + &request, + &staging, + context.manifest.as_deref(), + capability_inventory::execute, + ); + // Each step keeps its own failure reason. Collapsing all three through + // `.ok()` reported "produced no artifact" for a plan that was produced and + // then turned out to be unreadable or malformed — three very different + // things for whoever has to work out why. + let plan = read_plan_artifact(&outcome, &staging); + let _ = fs::remove_dir_all(&staging); + match plan { + Ok(plan) => Ok(json!({ + "schema": "code-intel-mcp-structural-edit-plan.v1", + "capability": EDIT_PLAN_CAPABILITY, + "repo": context.repo, + "snapshotIdentity": snapshot["snapshot"]["identity"], + "authority": {"mode": "preview_only", "repositoryMutation": false}, + "exitCode": outcome.exit_code, + "plan": plan, + })), + Err(reason) => Err(format!( + "{reason} (exit {}): {}", + outcome.exit_code, + outcome + .diagnostic + .unwrap_or_else(|| "no diagnostic was emitted".into()) + )), + } +} + +fn read_plan_artifact(outcome: &capability::ExecOutcome, staging: &Path) -> Result { + let relative = outcome + .result + .as_ref() + .and_then(|result| result["artifacts"][0]["path"].as_str()) + .ok_or("edit plan produced no artifact")?; + let bytes = fs::read(staging.join(relative)) + .map_err(|error| format!("edit plan artifact {relative} could not be read: {error}"))?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("edit plan artifact {relative} is not valid JSON: {error}")) +} + +/// The read-only boundary, enforced against the registry rather than asserted +/// in prose. +/// +/// `plan_structural_edit` is the one tool here that executes anything. If the +/// capability it fronts ever declares `repo_mutation`, that is a contract +/// change someone must review — not something this server should discover at +/// runtime and proceed through. Refusing here means no argument value, however +/// crafted, can reach a writer from the MCP surface. +pub(super) fn refuse_repository_mutation(allowed_effects: &Value) -> Result<(), String> { + let mutates = allowed_effects + .as_array() + .into_iter() + .flatten() + .any(|effect| effect == "repo_mutation"); + if mutates { + return Err(format!( + "refused: {EDIT_PLAN_CAPABILITY} declares repo_mutation; the MCP surface never \ +executes a repository writer" + )); + } + Ok(()) +} + +fn load(context: &ServeContext) -> Result { + committed_evidence::load(&context.artifact_root, &context.repo).map_err(|error| match error { + EvidenceError::Contract(message) | EvidenceError::HostIo(message) => message, + }) +} + +fn freshness(evidence: &CommittedEvidence, context: &ServeContext) -> Result { + evidence + .freshness(Some(&context.repo_path)) + .map_err(|error| match error { + EvidenceError::Contract(message) | EvidenceError::HostIo(message) => message, + }) +} + +/// The command is meant to be run, so it is spelled the way a shell accepts. +/// +/// `repo_path` is canonicalized, which on Windows yields the `\\?\` verbatim +/// prefix. Handing an agent a command it cannot paste is worse than handing it +/// none — it will invent one. +pub(super) fn rerun_command(context: &ServeContext) -> String { + let path = context.repo_path.display().to_string(); + let path = path.strip_prefix(r"\\?\").unwrap_or(&path); + if path.contains(' ') { + format!("code-intel \"{path}\" --mode normal") + } else { + format!("code-intel {path} --mode normal") + } +} + +fn staging_path() -> Result { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("resolve staging nonce: {error}"))? + .as_nanos(); + Ok(env::temp_dir().join(format!( + "code-intel-mcp-plan-{}-{nonce}", + std::process::id() + ))) +} + +/// A closed argument set, checked before anything is read. +/// +/// The tool schemas declare `additionalProperties: false`, but a schema is a +/// hint to a well-behaved client, not a guard. Rejecting unexpected keys here +/// means a caller cannot smuggle an argument that a future version of a +/// handler might start honouring. +fn expect_keys(arguments: &Value, allowed: &[&str]) -> Result<(), String> { + let object = arguments + .as_object() + .ok_or_else(|| "arguments must be a JSON object".to_string())?; + match object.keys().find(|key| !allowed.contains(&key.as_str())) { + Some(unexpected) => Err(format!( + "unexpected argument: {unexpected} (accepted: {})", + if allowed.is_empty() { + "none".to_string() + } else { + allowed.join(", ") + } + )), + None => Ok(()), + } +} + +fn required_text(arguments: &Value, key: &str) -> Result { + optional_text(arguments, key)?.ok_or_else(|| format!("{key} is required")) +} + +fn optional_text(arguments: &Value, key: &str) -> Result, String> { + match arguments.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) if !value.is_empty() => Ok(Some(value.clone())), + Some(Value::String(_)) => Err(format!("{key} must not be empty")), + Some(_) => Err(format!("{key} must be a string")), + } +} + +fn optional_bool(arguments: &Value, key: &str) -> Result, String> { + match arguments.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(value)) => Ok(Some(*value)), + Some(_) => Err(format!("{key} must be a boolean")), + } +} + +/// The declared `1..=100` bound, enforced here rather than at each call site. +/// +/// `get_facts` used to be the only bounded caller, because its limit passes +/// through `EvidenceQueryRequest::new`; `get_evidence` consumed the raw value. +/// A `limit` of 0 then made `evidence_chain` break on its first match and +/// report `status: "unbacked"` — a claim that no committed artifact mentions +/// the identifier — while artifacts that mention it were sitting right there. +/// A bound that only one of two callers honours is not a bound. +/// +/// The comparison happens on the `u64` before any cast: `as usize` truncates on +/// a 32-bit target, which would turn an out-of-range value into an accepted one. +fn optional_limit(arguments: &Value) -> Result { + let out_of_range = || format!("limit must be an integer in 1..={MAX_LIMIT}"); + match arguments.get("limit") { + None | Some(Value::Null) => Ok(DEFAULT_LIMIT), + Some(Value::Number(value)) => { + let value = value.as_u64().ok_or_else(out_of_range)?; + if !(1..=MAX_LIMIT as u64).contains(&value) { + return Err(out_of_range()); + } + Ok(value as usize) + } + Some(_) => Err("limit must be an integer".into()), + } +} + +fn required_string_array(arguments: &Value, key: &str) -> Result, String> { + let items = arguments + .get(key) + .and_then(Value::as_array) + .ok_or_else(|| format!("{key} must be an array of strings"))?; + if items.is_empty() { + return Err(format!("{key} must contain at least one entry")); + } + items + .iter() + .map(|item| { + item.as_str() + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("{key} entries must be non-empty strings")) + }) + .collect() +} + +fn query_message(error: evidence_query::QueryError) -> String { + match error { + evidence_query::QueryError::Contract(message) + | evidence_query::QueryError::HostIo(message) => message, + } +} + +fn impact_message(error: change_impact::ImpactError) -> String { + match error { + change_impact::ImpactError::Contract(message) + | change_impact::ImpactError::HostIo(message) => message, + } +} diff --git a/crates/code-intel-cli/src/mcp_serve/mod.rs b/crates/code-intel-cli/src/mcp_serve/mod.rs new file mode 100644 index 00000000..55c78621 --- /dev/null +++ b/crates/code-intel-cli/src/mcp_serve/mod.rs @@ -0,0 +1,336 @@ +//! `code-intel serve --mcp` — the agent-native query surface (#54, and the +//! third proposal of the write-path audit in #58). +//! +//! Every other agent-facing surface in this repository answers *after* a full +//! authoritative run and *through* artifact files. That shape is correct for +//! auditing and useless while an agent is writing code: it cannot ask one +//! question and get one answer. This module is that missing plane — a stdio +//! MCP server projecting the already-committed evidence, plus the two +//! write-assist projections #58 named. +//! +//! The boundary is structural, not a convention: this server owns no +//! authority. It re-reads what `run commit` published, re-verifies the digests +//! through `committed_evidence`, and re-uses the same request types the CLI +//! parsers build — so a query that arrives over stdio traverses exactly the +//! guards a query typed at a shell does. The single capability it can execute +//! (`edit.ast-grep-plan`) is checked against its registry declaration before +//! it runs and refused if that declaration ever admits `repo_mutation`. Gate +//! verdicts stay where they were: in the CLI and CI paths. A prompt-injected +//! query string reaching this surface can therefore read, and cannot decide. + +use std::env; +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +use serde_json::{json, Value}; + +use crate::artifacts; + +mod handlers; +mod tools; + +#[cfg(test)] +mod tests; + +/// The MCP revisions this server implements — deliberately just one. +/// +/// `2025-03-26` and earlier require a server to accept JSON-RPC *batches*: a +/// top-level array of requests answered by an array of responses. This +/// transport answers one object per line and has no batch dispatch, so +/// advertising those revisions would promise something a client could hang +/// waiting for. `2025-06-18` removed batching, which is exactly why it is the +/// one revision this framing can honestly claim. +/// +/// A client asking for anything else is answered with this revision, which is +/// what the specification prescribes: the client then decides whether it can +/// proceed. That is a visible negotiation failure rather than a silent one. +const PROTOCOL_VERSION: &str = "2025-06-18"; +const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[PROTOCOL_VERSION]; + +const USAGE: &str = "usage: serve --mcp [--repo-path ] [--repo ] [--artifact-root ] [--manifest ]"; + +const INSTRUCTIONS: &str = + "Read-only projection of this repository's committed Code Intel evidence. \ +Ask get_gate_verdict before trusting a green tree, get_change_impact before editing files, and \ +plan_structural_edit before a mechanical multi-file rewrite. Every answer names the run and \ +snapshot identity it came from; treat a stale-advisory freshness as advice, never as a gate."; + +/// Where a served answer came from and which checkout it is being compared +/// against. Resolved once at startup so no per-call argument can redirect the +/// server at another repository — the client chooses tools, never targets. +#[derive(Debug)] +pub(super) struct ServeContext { + pub(super) repo_path: PathBuf, + pub(super) repo: String, + pub(super) artifact_root: PathBuf, + pub(super) manifest: Option, +} + +pub(crate) fn run_raw(raw: &[String]) -> i32 { + let context = match parse(raw) { + Ok(context) => context, + Err(message) => { + eprintln!("{message}"); + return 64; + } + }; + let stdin = io::stdin(); + let stdout = io::stdout(); + match serve(&context, &mut stdin.lock(), &mut stdout.lock()) { + Ok(()) => 0, + Err(message) => { + eprintln!("{message}"); + 74 + } + } +} + +fn parse(raw: &[String]) -> Result { + let mut transport = false; + let mut repo_path: Option = None; + let mut repo: Option = None; + let mut artifact_root: Option = None; + let mut manifest: Option = None; + let mut index = 0; + while index < raw.len() { + let flag = raw[index].as_str(); + if flag == "--mcp" { + if transport { + return Err("duplicate --mcp".into()); + } + transport = true; + index += 1; + continue; + } + if !matches!( + flag, + "--repo-path" | "--repo" | "--artifact-root" | "--manifest" + ) { + return Err(format!("unknown serve argument: {flag}\n{USAGE}")); + } + let value = raw + .get(index + 1) + .filter(|value| !value.is_empty() && !value.starts_with("--")) + .ok_or_else(|| format!("{flag} requires one value"))?; + match flag { + "--repo-path" => set_once(&mut repo_path, PathBuf::from(value), flag)?, + "--repo" => set_once(&mut repo, value.clone(), flag)?, + "--artifact-root" => set_once(&mut artifact_root, PathBuf::from(value), flag)?, + "--manifest" => set_once(&mut manifest, PathBuf::from(value), flag)?, + _ => unreachable!("serve flags are matched above"), + } + index += 2; + } + if !transport { + return Err(format!("serve requires a transport\n{USAGE}")); + } + let repo_path = match repo_path { + Some(path) => path, + None => { + env::current_dir().map_err(|error| format!("resolve working directory: {error}"))? + } + }; + if !repo_path.is_dir() { + return Err(format!( + "--repo-path is not a directory: {}", + repo_path.display() + )); + } + let repo_path = fs::canonicalize(&repo_path) + .map_err(|error| format!("resolve --repo-path {}: {error}", repo_path.display()))?; + // The artifact-index key defaults to the checkout's directory name because + // that is the name `run commit` publishes under. A worktree whose folder + // name differs from the published repository name must say so with + // `--repo`; guessing would silently answer from another repository's runs. + let repo = match repo { + Some(repo) => repo, + None => repo_path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .ok_or("--repo-path has no usable directory name; pass --repo")? + .to_string(), + }; + let artifact_root = match artifact_root { + Some(root) => root, + None => artifacts::resolve_artifact_root(None) + .map_err(|error| format!("resolve artifact root: {error}"))?, + }; + Ok(ServeContext { + repo_path, + repo, + artifact_root, + manifest, + }) +} + +fn set_once(slot: &mut Option, value: T, flag: &str) -> Result<(), String> { + if slot.replace(value).is_some() { + Err(format!("duplicate {flag}")) + } else { + Ok(()) + } +} + +/// The stdio transport: newline-delimited JSON-RPC, one message per line. +/// +/// A malformed line is answered and the session continues. Only a broken pipe +/// or an unreadable stdin ends the loop with a host-IO exit, because a client +/// that disconnects mid-session is the normal way this process dies. +fn serve( + context: &ServeContext, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result<(), String> { + let mut line = String::new(); + loop { + line.clear(); + let read = input + .read_line(&mut line) + .map_err(|error| format!("read MCP stdin: {error}"))?; + if read == 0 { + return Ok(()); + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Some(response) = handle_line(context, trimmed) else { + continue; + }; + writeln!( + output, + "{}", + serde_json::to_string(&response).expect("MCP response serializes") + ) + .map_err(|error| format!("write MCP stdout: {error}"))?; + output + .flush() + .map_err(|error| format!("flush MCP stdout: {error}"))?; + } +} + +/// One request in, at most one response out. +/// +/// `None` means the message was a notification. JSON-RPC forbids answering +/// those, and a client that receives an unsolicited response for +/// `notifications/initialized` treats the session as broken. +pub(super) fn handle_line(context: &ServeContext, line: &str) -> Option { + let message: Value = match serde_json::from_str(line) { + Ok(message) => message, + Err(error) => { + return Some(failure( + Value::Null, + -32700, + &format!("parse error: {error}"), + )) + } + }; + // A batch is refused out loud. It has no `id` of its own, so falling + // through to the notification branch below would drop it silently and + // leave the client waiting for responses that are never coming — the exact + // hang that advertising a batching revision would have caused. + if message.is_array() { + return Some(failure( + Value::Null, + -32600, + "invalid request: JSON-RPC batches are not supported; this server speaks MCP \ + 2025-06-18, which sends messages individually", + )); + } + let id = message.get("id").filter(|id| !id.is_null()).cloned()?; + let Some(method) = message["method"].as_str() else { + return Some(failure(id, -32600, "invalid request: no method")); + }; + Some(dispatch(context, id, method, &message["params"])) +} + +fn dispatch(context: &ServeContext, id: Value, method: &str, params: &Value) -> Value { + match method { + "initialize" => success(id, initialize_result(params)), + "ping" => success(id, json!({})), + "tools/list" => success(id, json!({ "tools": tools::descriptors() })), + "tools/call" => tools_call(context, id, params), + // Declared empty rather than unimplemented: a client that probes these + // during handshake should see "this server has none", not an error it + // may surface to the user as a failed connection. + "resources/list" => success(id, json!({ "resources": [] })), + "resources/templates/list" => success(id, json!({ "resourceTemplates": [] })), + "prompts/list" => success(id, json!({ "prompts": [] })), + other => failure(id, -32601, &format!("method not found: {other}")), + } +} + +fn initialize_result(params: &Value) -> Value { + let requested = params["protocolVersion"] + .as_str() + .unwrap_or(PROTOCOL_VERSION); + let negotiated = if SUPPORTED_PROTOCOL_VERSIONS.contains(&requested) { + requested + } else { + PROTOCOL_VERSION + }; + json!({ + "protocolVersion": negotiated, + "capabilities": {"tools": {"listChanged": false}}, + "serverInfo": {"name": "code-intel", "version": env!("CARGO_PKG_VERSION")}, + "instructions": INSTRUCTIONS, + }) +} + +/// A tool that refuses is a *result* with `isError`, not a JSON-RPC error. +/// +/// The distinction is load-bearing for an agent: a JSON-RPC error is a +/// transport fault it should retry or report, while `isError` is an answer it +/// should read — "no committed run exists yet", "that path escapes the +/// repository". Collapsing the two would train agents to treat a refusal as a +/// broken server. Only an unknown tool name is a protocol error. +fn tools_call(context: &ServeContext, id: Value, params: &Value) -> Value { + let Some(name) = params["name"].as_str() else { + return failure( + id, + -32602, + "invalid params: tools/call requires a tool name", + ); + }; + if !tools::is_registered(name) { + return failure(id, -32602, &format!("unknown tool: {name}")); + } + let arguments = params + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})); + match handlers::call(context, name, &arguments) { + Ok(payload) => success(id, tool_result(&payload, false)), + Err(message) => success( + id, + tool_result( + &json!({ + "schema": "code-intel-mcp-tool-error.v1", + "tool": name, + "error": message, + }), + true, + ), + ), + } +} + +fn tool_result(payload: &Value, is_error: bool) -> Value { + json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string(payload).expect("tool payload serializes"), + }], + "isError": is_error, + }) +} + +fn success(id: Value, result: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} + +fn failure(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} diff --git a/crates/code-intel-cli/src/mcp_serve/tests.rs b/crates/code-intel-cli/src/mcp_serve/tests.rs new file mode 100644 index 00000000..0005c7c1 --- /dev/null +++ b/crates/code-intel-cli/src/mcp_serve/tests.rs @@ -0,0 +1,423 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{json, Value}; + +use super::{handlers, tools, ServeContext}; + +/// A context pointing at a real directory with no committed run. +/// +/// Most guard tests want exactly this: argument validation must refuse before +/// the evidence loader is ever consulted, so a context with nothing published +/// is the honest fixture. Tests that need the loader to be the thing that +/// fails read its message instead. +struct Fixture(PathBuf); + +impl Fixture { + fn create(label: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = env::temp_dir().join(format!( + "code-intel-mcp-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create MCP fixture directory"); + Self(path) + } + + fn context(&self) -> ServeContext { + ServeContext { + repo_path: self.0.clone(), + repo: "fixture-repo".into(), + artifact_root: self.0.join("artifacts"), + manifest: None, + } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn request(id: i64, method: &str, params: Value) -> String { + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string() +} + +fn call(context: &ServeContext, tool: &str, arguments: Value) -> Value { + super::handle_line( + context, + &request( + 1, + "tools/call", + json!({"name": tool, "arguments": arguments}), + ), + ) + .expect("tools/call is a request, not a notification") +} + +fn tool_payload(response: &Value) -> Value { + let text = response["result"]["content"][0]["text"] + .as_str() + .expect("tool result carries one text block"); + serde_json::from_str(text).expect("tool payload is JSON") +} + +#[test] +fn every_registered_tool_has_a_handler() { + let fixture = Fixture::create("registry"); + let context = fixture.context(); + for name in tools::NAMES { + let error = handlers::call(&context, name, &json!({})) + .err() + .unwrap_or_default(); + assert!( + !error.starts_with("unknown tool"), + "{name} is advertised but has no handler" + ); + } + assert_eq!(tools::descriptors().len(), tools::NAMES.len()); +} + +#[test] +fn every_descriptor_declares_a_closed_read_only_schema() { + for descriptor in tools::descriptors() { + let name = descriptor["name"].as_str().expect("descriptor name"); + assert!( + tools::is_registered(name), + "{name} is described but not registered" + ); + assert_eq!( + descriptor["inputSchema"]["additionalProperties"], + json!(false), + "{name} accepts unexpected arguments" + ); + assert_eq!( + descriptor["annotations"]["readOnlyHint"], + json!(true), + "{name} is not annotated read-only" + ); + assert!( + descriptor["description"] + .as_str() + .is_some_and(|text| text.len() > 80), + "{name} has no usable description" + ); + } +} + +#[test] +fn notifications_are_never_answered() { + let fixture = Fixture::create("notify"); + let context = fixture.context(); + for line in [ + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(), + json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "id": Value::Null}) + .to_string(), + ] { + assert!( + super::handle_line(&context, &line).is_none(), + "a notification must not produce a response: {line}" + ); + } +} + +/// Only revisions this transport can actually honour are advertised. +/// +/// `2025-03-26` and earlier mandate JSON-RPC batch support, which this +/// line-per-message framing does not implement. Echoing one back would let a +/// client send a batch and wait forever. +#[test] +fn only_the_non_batching_revision_is_advertised() { + assert_eq!( + super::SUPPORTED_PROTOCOL_VERSIONS, + &[super::PROTOCOL_VERSION] + ); + for batching_revision in ["2025-03-26", "2024-11-05"] { + assert!( + !super::SUPPORTED_PROTOCOL_VERSIONS.contains(&batching_revision), + "{batching_revision} requires batch dispatch this transport does not have" + ); + } +} + +/// A batch has no `id`, so without an explicit refusal it would fall through +/// the notification branch and be dropped in silence — the client hangs. +#[test] +fn a_json_rpc_batch_is_refused_out_loud() { + let fixture = Fixture::create("batch"); + let context = fixture.context(); + let batch = json!([ + {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + {"jsonrpc": "2.0", "id": 2, "method": "ping", "params": {}}, + ]) + .to_string(); + let response = super::handle_line(&context, &batch).expect("a batch must not be dropped"); + assert_eq!(response["error"]["code"], json!(-32600)); + assert!(response["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("batches are not supported"))); +} + +#[test] +fn initialize_substitutes_an_unsupported_revision() { + let fixture = Fixture::create("initialize"); + let context = fixture.context(); + let unknown = super::handle_line( + &context, + &request(2, "initialize", json!({"protocolVersion": "1999-01-01"})), + ) + .expect("initialize response"); + assert_eq!( + unknown["result"]["protocolVersion"], + json!(super::PROTOCOL_VERSION) + ); + assert_eq!(unknown["result"]["serverInfo"]["name"], json!("code-intel")); + assert_eq!( + unknown["result"]["capabilities"]["tools"]["listChanged"], + json!(false) + ); +} + +#[test] +fn malformed_and_unroutable_messages_answer_as_protocol_errors() { + let fixture = Fixture::create("protocol"); + let context = fixture.context(); + + let parse_error = super::handle_line(&context, "{not json").expect("parse error response"); + assert_eq!(parse_error["error"]["code"], json!(-32700)); + assert_eq!(parse_error["id"], Value::Null); + + let no_method = super::handle_line(&context, &json!({"jsonrpc": "2.0", "id": 7}).to_string()) + .expect("invalid request response"); + assert_eq!(no_method["error"]["code"], json!(-32600)); + + let unknown_method = + super::handle_line(&context, &request(8, "tools/summon", json!({}))).expect("response"); + assert_eq!(unknown_method["error"]["code"], json!(-32601)); + + let unknown_tool = super::handle_line( + &context, + &request(9, "tools/call", json!({"name": "rm_rf", "arguments": {}})), + ) + .expect("response"); + assert_eq!(unknown_tool["error"]["code"], json!(-32602)); +} + +#[test] +fn tools_list_serves_the_whole_registry() { + let fixture = Fixture::create("list"); + let context = fixture.context(); + let response = + super::handle_line(&context, &request(1, "tools/list", json!({}))).expect("response"); + let served = response["result"]["tools"] + .as_array() + .expect("tools array") + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect::>(); + assert_eq!(served, tools::NAMES.to_vec()); +} + +/// A refusal is an answer, not a transport fault. +/// +/// An agent that sees a JSON-RPC error learns "this server is broken"; one +/// that sees `isError` with a readable payload learns "run the pipeline +/// first". Getting this backwards is how a working tool acquires a reputation +/// for being flaky. +#[test] +fn a_tool_that_cannot_answer_returns_an_error_result_not_a_transport_error() { + let fixture = Fixture::create("norun"); + let context = fixture.context(); + let response = call(&context, "get_gate_verdict", json!({})); + assert!( + response.get("error").is_none(), + "a missing run is not a protocol error: {response}" + ); + assert_eq!(response["result"]["isError"], json!(true)); + let payload = tool_payload(&response); + assert_eq!(payload["schema"], json!("code-intel-mcp-tool-error.v1")); + assert_eq!(payload["tool"], json!("get_gate_verdict")); + assert!( + payload["error"] + .as_str() + .is_some_and(|text| !text.is_empty()), + "a refusal must say why: {payload}" + ); +} + +/// Injection coverage: a crafted argument must not reach a path the flag +/// parsers close, and must not reach a handler at all when it is not a +/// declared argument. +#[test] +fn crafted_arguments_are_refused_before_any_evidence_is_read() { + let fixture = Fixture::create("injection"); + let context = fixture.context(); + + for escape in [ + "../../../etc/passwd", + "..\\..\\windows\\system32\\config\\sam", + "/etc/shadow", + "C:/Windows/System32/drivers/etc/hosts", + "src/../../outside.rs", + ] { + let response = call(&context, "get_change_impact", json!({"changed": [escape]})); + assert_eq!( + response["result"]["isError"], + json!(true), + "{escape} was not refused" + ); + let message = tool_payload(&response)["error"].to_string(); + assert!( + message.contains("portable repository-relative path"), + "{escape} was refused for the wrong reason: {message}" + ); + } + + let smuggled = call( + &context, + "get_facts", + json!({"type": "code_evidence.files", "repoPath": "/tmp/elsewhere"}), + ); + assert_eq!(smuggled["result"]["isError"], json!(true)); + assert!(tool_payload(&smuggled)["error"] + .as_str() + .expect("error text") + .contains("unexpected argument: repoPath")); + + let non_object = handlers::call(&context, "get_facts", &json!("--artifact-root /etc")); + assert_eq!( + non_object.err().as_deref(), + Some("arguments must be a JSON object") + ); +} + +/// The declared `1..=100` bound must hold for every tool that takes a limit, +/// not just the one whose request type happened to check it. +/// +/// `limit: 0` used to make `get_evidence` break on its first match and answer +/// `status: "unbacked"` — asserting that no committed artifact mentions the +/// identifier while artifacts that mention it were right there. A bound one +/// caller honours is not a bound. +#[test] +fn every_limit_taking_tool_shares_the_declared_bound() { + let fixture = Fixture::create("limit"); + let context = fixture.context(); + for (tool, arguments) in [ + ("get_evidence", json!({"findingId": "anything", "limit": 0})), + ( + "get_evidence", + json!({"findingId": "anything", "limit": 101}), + ), + ("get_facts", json!({"limit": 0})), + ("get_facts", json!({"limit": 101})), + ] { + let error = handlers::call(&context, tool, &arguments) + .err() + .unwrap_or_else(|| panic!("{tool} accepted {arguments}")); + assert!( + error.contains("1..=100"), + "{tool} rejected {arguments} for the wrong reason: {error}" + ); + } + + // The bound is a range, not a rejection of everything: the endpoints and a + // missing limit must still get past argument validation and reach the + // (absent) committed run. + for arguments in [json!({"limit": 1}), json!({"limit": 100}), json!({})] { + let error = handlers::call(&context, "get_facts", &arguments) + .err() + .unwrap_or_default(); + assert!( + !error.contains("1..=100"), + "a valid limit was rejected: {arguments} -> {error}" + ); + } +} + +#[test] +fn a_capability_that_declared_repository_mutation_would_be_refused() { + assert!(handlers::refuse_repository_mutation(&json!(["repo_read", "process_spawn"])).is_ok()); + let refused = + handlers::refuse_repository_mutation(&json!(["repo_read", "local_write", "repo_mutation"])); + assert!( + refused + .as_ref() + .err() + .is_some_and(|message| message.contains("never executes a repository writer")), + "a mutating declaration must be refused: {refused:?}" + ); +} + +/// The rerun command is advice an agent will act on, so it has to be +/// runnable: a canonicalized Windows path carries the `\\?\` verbatim prefix +/// that no shell accepts, and a path with a space needs quoting. +#[test] +fn the_rerun_command_is_shell_runnable() { + let verbatim = ServeContext { + repo_path: PathBuf::from(r"\\?\C:\repo\project"), + repo: "project".into(), + artifact_root: PathBuf::from(r"C:\artifacts"), + manifest: None, + }; + assert_eq!( + handlers::rerun_command(&verbatim), + r"code-intel C:\repo\project --mode normal" + ); + + let spaced = ServeContext { + repo_path: PathBuf::from(r"\\?\C:\my repo\project"), + repo: "project".into(), + artifact_root: PathBuf::from(r"C:\artifacts"), + manifest: None, + }; + assert_eq!( + handlers::rerun_command(&spaced), + "code-intel \"C:\\my repo\\project\" --mode normal" + ); +} + +#[test] +fn serve_requires_a_transport_and_rejects_unknown_flags() { + let argv = |args: &[&str]| args.iter().map(|arg| arg.to_string()).collect::>(); + + let no_transport = super::parse(&argv(&[])).expect_err("a transport is required"); + assert!(no_transport.contains("serve requires a transport")); + + let unknown = super::parse(&argv(&["--mcp", "--exec"])).expect_err("unknown flag"); + assert!(unknown.contains("unknown serve argument: --exec")); + + let duplicate = super::parse(&argv(&["--mcp", "--mcp"])).expect_err("duplicate transport"); + assert_eq!(duplicate, "duplicate --mcp"); + + let missing_value = + super::parse(&argv(&["--mcp", "--repo"])).expect_err("flag without a value"); + assert!(missing_value.contains("--repo requires one value")); +} + +#[test] +fn serve_defaults_the_repository_name_to_the_published_directory_name() { + let fixture = Fixture::create("defaults"); + let argv = [ + "--mcp".to_string(), + "--repo-path".to_string(), + fixture.0.display().to_string(), + "--artifact-root".to_string(), + fixture.0.join("artifacts").display().to_string(), + ]; + let context = super::parse(&argv).expect("serve parses"); + assert_eq!( + context.repo, + fixture + .0 + .file_name() + .and_then(|name| name.to_str()) + .expect("fixture directory name") + ); + assert!(context.manifest.is_none()); +} diff --git a/crates/code-intel-cli/src/mcp_serve/tools.rs b/crates/code-intel-cli/src/mcp_serve/tools.rs new file mode 100644 index 00000000..72dbd450 --- /dev/null +++ b/crates/code-intel-cli/src/mcp_serve/tools.rs @@ -0,0 +1,175 @@ +//! The served tool registry. +//! +//! Kept apart from `handlers` so the wire-facing contract (names, argument +//! schemas, the prose an agent reads when deciding what to call) can be +//! reviewed as one surface. `handlers::call` and `NAMES` are held together by +//! `every_registered_tool_has_a_handler` in `tests`, so a descriptor can never +//! advertise a tool that answers "unknown tool" at call time. + +use serde_json::{json, Value}; + +/// Every served tool, in the order an agent should reach for them: the four +/// read projections #54 specified, then the two write-assist projections #58 +/// added. `plan_structural_edit` is last deliberately — it is the only one +/// that spawns a child process, and an agent scanning this list top-down +/// should find the cheap answers first. +pub(super) const NAMES: &[&str] = &[ + "get_gate_verdict", + "get_facts", + "get_evidence", + "get_audit_status", + "get_change_impact", + "plan_structural_edit", +]; + +pub(super) fn is_registered(name: &str) -> bool { + NAMES.contains(&name) +} + +pub(super) fn descriptors() -> Vec { + vec![ + gate_verdict(), + facts(), + evidence(), + audit_status(), + change_impact(), + structural_edit(), + ] +} + +fn gate_verdict() -> Value { + json!({ + "name": "get_gate_verdict", + "title": "Gate verdict", + "description": "The committed run's gate conclusion for this repository: triage status, \ + the first failing rule, and the minimal command that reruns it. Call this before trusting that \ + the tree is green — a passing local build says nothing about the authoritative verdict. Answers \ + from the last committed run and reports whether that run is still current for this checkout.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "annotations": {"readOnlyHint": true, "destructiveHint": false, "openWorldHint": false}, + }) +} + +fn facts() -> Value { + json!({ + "name": "get_facts", + "title": "Query committed facts", + "description": "Search the committed run's verified artifacts — hotspots, import edges, \ + scorecards, coverage, symbols, doctor observations. Filter by artifact type, artifact schema, or \ + substring. Every hit carries the digest-verified artifact reference it came from. Use this \ + instead of reading artifact files off disk: the bytes here have already been checked against the \ + snapshot they were recorded under.", + "inputSchema": { + "type": "object", + "properties": { + "type": {"type": "string", "description": "Artifact type, e.g. code_evidence.files, diagnosis.hospital, code_evidence.scorecard."}, + "artifactSchema": {"type": "string", "description": "Artifact schema id, e.g. code-intel-hospital.v1."}, + "contains": {"type": "string", "description": "Case-insensitive substring the artifact bytes must contain."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 100, "description": "Maximum matches to return (default 20)."} + }, + "additionalProperties": false + }, + "annotations": {"readOnlyHint": true, "destructiveHint": false, "openWorldHint": false}, + }) +} + +fn evidence() -> Value { + json!({ + "name": "get_evidence", + "title": "Evidence chain for a finding", + "description": "The provenance chain behind one finding, rule id, or file path: which \ + committed artifacts mention it, each one's sha256, the snapshot identity it was recorded under, \ + and the run that published it. Call this when a verdict or a finding needs to be justified to a \ + human, or when you need to know whether a claim is backed by recorded evidence at all.", + "inputSchema": { + "type": "object", + "properties": { + "findingId": {"type": "string", "description": "Finding id, rule id, symbol, or repository-relative path to trace."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 100, "description": "Maximum evidence links to return (default 20)."} + }, + "required": ["findingId"], + "additionalProperties": false + }, + "annotations": {"readOnlyHint": true, "destructiveHint": false, "openWorldHint": false}, + }) +} + +fn audit_status() -> Value { + json!({ + "name": "get_audit_status", + "title": "Audit department status", + "description": "The latest audit report's per-department conclusions, scores, and \ + coverage. Optionally narrowed to one department. Reports explicitly when the committed run \ + carries no audit artifact, rather than implying a clean audit that never ran.", + "inputSchema": { + "type": "object", + "properties": { + "department": {"type": "string", "description": "Department id to narrow to; omit for every department."} + }, + "additionalProperties": false + }, + "annotations": {"readOnlyHint": true, "destructiveHint": false, "openWorldHint": false}, + }) +} + +fn change_impact() -> Value { + json!({ + "name": "get_change_impact", + "title": "Blast radius and test selection", + "description": "Given the files you are about to change (or just changed), the files \ + reachable from them through the committed reverse import graph, plus candidate tests to run. \ + This is the mid-edit question the CLI refuses to answer, because the snapshot is never current \ + while you are typing: here the answer is served as stale-advisory, labelled with the recorded \ + and current snapshot identities. Advisory only — never gate on it.", + "inputSchema": { + "type": "object", + "properties": { + "changed": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "description": "Repository-relative paths you are changing, e.g. src/auth/token.rs." + }, + "requireCurrent": { + "type": "boolean", + "description": "Refuse unless the committed snapshot still matches this checkout (default false)." + } + }, + "required": ["changed"], + "additionalProperties": false + }, + "annotations": {"readOnlyHint": true, "destructiveHint": false, "openWorldHint": false}, + }) +} + +fn structural_edit() -> Value { + json!({ + "name": "plan_structural_edit", + "title": "Preview a structural rewrite", + "description": "Run an ast-grep pattern across the checkout and return every match, with \ + the rewritten text when a rewrite is supplied. Preview only: nothing is written to the \ + repository. Call this before a mechanical multi-file rewrite so you edit from a match list \ + rather than from guesses, then apply the changes yourself.", + "inputSchema": { + "type": "object", + "properties": { + "language": {"type": "string", "description": "ast-grep language id, e.g. rust, ts, python."}, + "pattern": {"type": "string", "description": "ast-grep pattern to match."}, + "rewrite": {"type": "string", "description": "Optional ast-grep rewrite template; matches are previewed, never written."}, + "paths": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 64, + "description": "Repository-relative paths to search (default the whole checkout)." + } + }, + "required": ["language", "pattern"], + "additionalProperties": false + }, + "annotations": {"readOnlyHint": true, "destructiveHint": false, "openWorldHint": false}, + }) +} diff --git a/crates/code-intel-cli/tests/fixtures/cli-head-parity.v2.json b/crates/code-intel-cli/tests/fixtures/cli-head-parity.v2.json index cee2ab80..5471186a 100644 --- a/crates/code-intel-cli/tests/fixtures/cli-head-parity.v2.json +++ b/crates/code-intel-cli/tests/fixtures/cli-head-parity.v2.json @@ -585,7 +585,7 @@ "--help", "--all" ], - "reason": "Phase 2 acceptance requires full help to expose every registered compatibility alias. The v1 bytes omitted aliases, so byte parity and alias completeness conflict; v2 resolves that conflict as one explicit versioned behavior change.", + "reason": "Phase 2 acceptance requires full help to expose every registered compatibility alias. The v1 bytes omitted aliases, so byte parity and alias completeness conflict; v2 resolves that conflict as one explicit versioned behavior change. Registering a route adds its line to these bytes (`serve --mcp`, #54): that is the same versioned behavior -- full help documents every registered route -- so the contract id pair stays v1 -> v2 rather than climbing a version per command.", "oldContractId": "text-format:help-full.v1", "newContractId": "text-format:help-full.v2", "old": { @@ -595,7 +595,7 @@ }, "new": { "exitCode": 0, - "stdoutUtf8": "code-intel [options]\n\nCommands:\n --version|-V [--json]\n help|--help|-h [--all]\n resume --repo [--artifact-root ] [--json]\n classify --report [--json]\n sentrux-normalize --steps [--out ]\n sentrux-debt-register --failures [--repo ] [--out ]\n doctor [--artifact-root ] [--json]\n doctor bootstrap [--repo ] [--repo-path ] [--config ] [--platform auto|windows|macos|linux] [--no-require-repowise] [--require-understand] [--json]\n graph|understand --repo [--language zh] [--full] [--write] [--json]\n provider|providers [--action List|Plan|Validate|Invoke] [--provider repowise|understand] [--operation ] [--repo ] [--language zh] [--write] [--json]\n provider repowise-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider graph-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider sentrux-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider session-adapt --repo --trace [--hotspots ] [--out ] [--working-tree-policy head_only|explicit_overlay]\n provider codenexus-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider file-boundary --request --out \n provider runtime-ci-evidence --artifact-root --request --out \n compatibility retirement-ticket lint --ticket --evaluated-at \n route|routes [--action List|Plan|Validate] [--provider repowise|understand] [--operation ] [--repo ] [--json]\n sentrux [--no-ratchet]\n (--no-ratchet: `check` only, skip the .sentrux/baseline.json ratchet)\n capability exec --request --out [--artifact-root ] [--manifest ]\n model inventory-validate --request [--out ]\n model route --request [--out ]\n snapshot identity --repo --working-tree-policy [--scope ]...\n repin [--repo ] [--write] [--json] [--exclude ]...\n evidence validate --request --artifact-root \n repository survival-scan --request --artifact-root \n audit --operation validate|render --repo --report [--format markdown|html]\n audit --operation scope --repo --since \n artifact index --artifact-root [--output ] [--operation rebuild|incremental] [--existing ]\n artifact query --artifact-root --repo [--repo-path ] [--artifact-schema ] [--type ] [--contains ] [--limit <1..100>]\n change impact --artifact-root --repo --repo-path --changed [--changed ]... [--staleness current|advisory]\n change risk [--repo ] [--sample ] [--format json|text] (git-only defect-risk score, no prior run, no index)\n change agenda [--repo ] [--min-cochange ] [--format json|text] (git-only review units clustered by co-change, ranked worst first)\n edit impact --repo-path --changed [--changed ]... [--scope ]... (working tree, no prior run, authority: none)\n edit apply --repo-path --file (--span --expect-sha256 --replacement |--replacement-file )... [--out ] [--manifest ] [--envelope] (span-addressed patch; refuses with evidence on digest drift, exit 10)\n decision request-response --request [--response |--cancel ] --now --branch ...\n decision record --resolution --store \n decision replay --query --store \n run execute --repo --out --authority-root --final-name [--profile default|strict|offline] [--manifest ] [--max-concurrency ] [--session-evidence ]\n run dag-coordinate --repo --out [--manifest ] [--max-concurrency ] [--session-evidence ]\n run commit --source-root --authority-root --manifest-ref --final-name \n benchmark orientation --out [--repetitions <2..10>]\n benchmark tools --corpus --runs --artifact-root --out \n governance ponytail-gate --request \n orchestrate|orchestration [--action Validate|List|Plan] [--repo ] [--mode lite|normal|full] [--capability ] [--manifest ] [--json]\n language set --language --repo [--json]\n", + "stdoutUtf8": "code-intel [options]\n\nCommands:\n --version|-V [--json]\n help|--help|-h [--all]\n resume --repo [--artifact-root ] [--json]\n classify --report [--json]\n sentrux-normalize --steps [--out ]\n sentrux-debt-register --failures [--repo ] [--out ]\n doctor [--artifact-root ] [--json]\n doctor bootstrap [--repo ] [--repo-path ] [--config ] [--platform auto|windows|macos|linux] [--no-require-repowise] [--require-understand] [--json]\n graph|understand --repo [--language zh] [--full] [--write] [--json]\n provider|providers [--action List|Plan|Validate|Invoke] [--provider repowise|understand] [--operation ] [--repo ] [--language zh] [--write] [--json]\n provider repowise-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider graph-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider sentrux-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider session-adapt --repo --trace [--hotspots ] [--out ] [--working-tree-policy head_only|explicit_overlay]\n provider codenexus-adapt --request --artifact-root --evaluated-at --max-age-seconds \n provider file-boundary --request --out \n provider runtime-ci-evidence --artifact-root --request --out \n compatibility retirement-ticket lint --ticket --evaluated-at \n route|routes [--action List|Plan|Validate] [--provider repowise|understand] [--operation ] [--repo ] [--json]\n sentrux [--no-ratchet]\n (--no-ratchet: `check` only, skip the .sentrux/baseline.json ratchet)\n capability exec --request --out [--artifact-root ] [--manifest ]\n model inventory-validate --request [--out ]\n model route --request [--out ]\n snapshot identity --repo --working-tree-policy [--scope ]...\n repin [--repo ] [--write] [--json] [--exclude ]...\n evidence validate --request --artifact-root \n repository survival-scan --request --artifact-root \n audit --operation validate|render --repo --report [--format markdown|html]\n audit --operation scope --repo --since \n artifact index --artifact-root [--output ] [--operation rebuild|incremental] [--existing ]\n artifact query --artifact-root --repo [--repo-path ] [--artifact-schema ] [--type ] [--contains ] [--limit <1..100>]\n change impact --artifact-root --repo --repo-path --changed [--changed ]... [--staleness current|advisory]\n change risk [--repo ] [--sample ] [--format json|text] (git-only defect-risk score, no prior run, no index)\n change agenda [--repo ] [--min-cochange ] [--format json|text] (git-only review units clustered by co-change, ranked worst first)\n edit impact --repo-path --changed [--changed ]... [--scope ]... (working tree, no prior run, authority: none)\n edit apply --repo-path --file (--span --expect-sha256 --replacement |--replacement-file )... [--out ] [--manifest ] [--envelope] (span-addressed patch; refuses with evidence on digest drift, exit 10)\n decision request-response --request [--response |--cancel ] --now --branch ...\n decision record --resolution --store \n decision replay --query --store \n run execute --repo --out --authority-root --final-name [--profile default|strict|offline] [--manifest ] [--max-concurrency ] [--session-evidence ]\n run dag-coordinate --repo --out [--manifest ] [--max-concurrency ] [--session-evidence ]\n run commit --source-root --authority-root --manifest-ref --final-name \n serve --mcp [--repo-path ] [--repo ] [--artifact-root ] [--manifest ] (stdio MCP query surface over the committed run; read-only, gates nowhere)\n benchmark orientation --out [--repetitions <2..10>]\n benchmark tools --corpus --runs --artifact-root --out \n governance ponytail-gate --request \n orchestrate|orchestration [--action Validate|List|Plan] [--repo ] [--mode lite|normal|full] [--capability ] [--manifest ] [--json]\n language set --language --repo [--json]\n", "stderrUtf8": "" } }, diff --git a/crates/code-intel-cli/tests/mcp_serve.rs b/crates/code-intel-cli/tests/mcp_serve.rs new file mode 100644 index 00000000..e41c97dd --- /dev/null +++ b/crates/code-intel-cli/tests/mcp_serve.rs @@ -0,0 +1,204 @@ +//! Process-level contract for `code-intel serve --mcp`. +//! +//! The unit tests in `src/mcp_serve/tests.rs` cover dispatch and the argument +//! guards in-process. What they cannot cover is the wiring: that the route +//! table reaches the module, that stdio framing survives a real pipe, and that +//! a client's session ends cleanly when it closes stdin. Those only fail as a +//! spawned process, which is how every client will run this. + +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::Stdio; + +use serde_json::{json, Value}; + +mod common; + +/// A directory that exists but publishes nothing. +/// +/// The handshake and framing are what this file tests, and they must hold +/// before any run has been committed — that is precisely the state an agent +/// meets on the first day in a new repository. +struct Fixture(PathBuf); + +impl Fixture { + fn create(label: &str) -> Self { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "code-intel-serve-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(path.join("artifacts")).expect("create serve fixture"); + Self(path) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Feed the server a session and collect every response line. +/// +/// Spawned through `common::cli()` so every pipeline-owned variable is cleared +/// from the same list the binary declares. Clearing a hand-picked pair here +/// instead would leave the rest of `PIPELINE_VARS` free to point this at the +/// developer's real installation and make the test pass for the wrong reason. +fn session(fixture: &Fixture, requests: &[Value]) -> (Vec, i32, String) { + let mut child = common::cli() + .args(["serve", "--mcp", "--repo-path"]) + .arg(&fixture.0) + .args(["--repo", "serve-fixture", "--artifact-root"]) + .arg(fixture.0.join("artifacts")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn code-intel serve --mcp"); + + { + let stdin = child.stdin.as_mut().expect("serve stdin"); + for request in requests { + writeln!(stdin, "{request}").expect("write MCP request"); + } + } + // Dropping stdin is the session's end-of-input; the server must exit 0. + drop(child.stdin.take()); + + let output = child.wait_with_output().expect("await serve"); + let responses = BufReader::new(output.stdout.as_slice()) + .lines() + .map(|line| line.expect("read MCP response line")) + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(&line).expect("each response line is JSON")) + .collect(); + ( + responses, + output.status.code().expect("serve exit code"), + String::from_utf8_lossy(&output.stderr).to_string(), + ) +} + +#[test] +fn a_client_session_handshakes_lists_tools_and_closes_cleanly() { + let fixture = Fixture::create("session"); + let (responses, exit, stderr) = session( + &fixture, + &[ + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2025-06-18","capabilities":{}, + "clientInfo":{"name":"contract-test","version":"1"}}}), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}), + json!({"jsonrpc":"2.0","id":3,"method":"ping","params":{}}), + ], + ); + + assert_eq!(exit, 0, "closing stdin ends the session cleanly: {stderr}"); + assert!( + stderr.is_empty(), + "a clean session writes no stderr: {stderr}" + ); + assert_eq!( + responses.len(), + 3, + "four messages, one of them a notification: {responses:?}" + ); + + assert_eq!(responses[0]["id"], json!(1)); + assert_eq!(responses[0]["jsonrpc"], json!("2.0")); + assert_eq!( + responses[0]["result"]["serverInfo"]["name"], + json!("code-intel") + ); + assert_eq!( + responses[0]["result"]["protocolVersion"], + json!("2025-06-18") + ); + + let tools = responses[1]["result"]["tools"] + .as_array() + .expect("tools/list serves an array"); + let names = tools + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + "get_gate_verdict", + "get_facts", + "get_evidence", + "get_audit_status", + "get_change_impact", + "plan_structural_edit", + ] + ); + for tool in tools { + assert_eq!( + tool["annotations"]["readOnlyHint"], + json!(true), + "{} is served without a read-only annotation", + tool["name"] + ); + } + + assert_eq!(responses[2]["result"], json!({}), "ping answers empty"); +} + +/// Before the first run there is nothing to serve, and the server has to say +/// so as an answer rather than as a crash — an agent that gets a dead process +/// on day one never calls the tool again. +#[test] +fn tools_refuse_readably_when_no_run_has_been_committed() { + let fixture = Fixture::create("norun"); + let (responses, exit, stderr) = session( + &fixture, + &[ + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ + "name":"get_gate_verdict","arguments":{}}}), + ], + ); + + assert_eq!(exit, 0, "a refusal is not a process failure: {stderr}"); + assert_eq!(responses.len(), 1); + let result = &responses[0]["result"]; + assert!( + responses[0].get("error").is_none(), + "a missing run is not a transport error: {:?}", + responses[0] + ); + assert_eq!(result["isError"], json!(true)); + let payload: Value = serde_json::from_str( + result["content"][0]["text"] + .as_str() + .expect("one text block"), + ) + .expect("tool payload is JSON"); + assert_eq!(payload["schema"], json!("code-intel-mcp-tool-error.v1")); + assert!( + payload["error"] + .as_str() + .is_some_and(|text| text.contains("no committed authoritative run is indexed")), + "the refusal must name the missing precondition: {payload}" + ); +} + +#[test] +fn serve_without_a_transport_is_a_usage_error() { + let output = common::cli() + .arg("serve") + .output() + .expect("spawn serve without a transport"); + assert_eq!(output.status.code(), Some(64)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("serve requires a transport") && stderr.contains("--mcp"), + "usage must name the missing transport: {stderr}" + ); + assert!(output.stdout.is_empty(), "a usage error writes no stdout"); +} diff --git a/orchestration/internalization/c03-r05-r12-measurements.json b/orchestration/internalization/c03-r05-r12-measurements.json index df446b7c..84975a8d 100644 --- a/orchestration/internalization/c03-r05-r12-measurements.json +++ b/orchestration/internalization/c03-r05-r12-measurements.json @@ -23,7 +23,7 @@ "unsupportedLanguageRelationshipPrecision": "unknown", "suiteElapsedMs": 27110, "sourceSha256": "38e165ad6fb351a27c8187838e71b3a1526408105c4efb8bcfd64672ff2f6064", - "testSha256": "83eec5e8dff23026b9edb477ba41d5e577dfb8c4b9b5b812854a3f5174e24046", + "testSha256": "ea0e62cf23d14e4207f626ed2c2ead0f49c7c6231e9df0e1398f4b934a19392f", "labeledCorpus": { "fixture": "orchestration/internalization/fixtures/r06-native-labeled-corpus.json", "samples": 12, diff --git a/orchestration/internalization/cocoindex.json b/orchestration/internalization/cocoindex.json index 3a5355a0..ce9d0ad1 100644 --- a/orchestration/internalization/cocoindex.json +++ b/orchestration/internalization/cocoindex.json @@ -1,6 +1,6 @@ { "schema": "code-intel-internalization-record.v1", "id": "internalization.cocoindex-record", "projectId": "code-intel-pipeline", - "subject": { "name": "cocoindex-code reviewed retirement record", "kind": "evidence_provider", "source": { "uri": "https://github.com/cocoindex-io/cocoindex-code; installed-via=uv-tool", "revision": "installed-version:0.2.37; production-semantic-invocations:0; production-registry-status:deleted; production-integration-present:false; measurement-sha256:c9fc9a0b4440624c1b8ca83f1068fb4f84b7ecb9d0cb8ce8c3644948d8568091" }, "license": { "id": "Apache-2.0-INSTALLED-PACKAGE-METADATA", "obligations": ["retain this provenance only as retirement evidence", "a future proposal must start with a new pinned, licensed, measured, authority-reviewed record"] } }, + "subject": { "name": "cocoindex-code reviewed retirement record", "kind": "evidence_provider", "source": { "uri": "https://github.com/cocoindex-io/cocoindex-code; installed-via=uv-tool", "revision": "installed-version:0.2.37; production-semantic-invocations:0; production-registry-status:deleted; production-integration-present:false; measurement-sha256:8c9332a2f2da30c36f24192ec70dfbf88941d5315062597b648a0422be5715ef" }, "license": { "id": "Apache-2.0-INSTALLED-PACKAGE-METADATA", "obligations": ["retain this provenance only as retirement evidence", "a future proposal must start with a new pinned, licensed, measured, authority-reviewed record"] } }, "adoption": { "rung": "invoke", "ownedBoundary": ["Pipeline removed configuration lookup, executable discovery, production declaration, and integration registration after zero measured unique value", "Native Code Evidence remains independent; the legacy outcome file is a static reviewed-retirement tombstone and cannot invoke cocoindex-code"], "necessityEvidence": { "evidenceIds": ["local:r07:reviewed-deletion", "local:r07:production-semantic-invocations-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "compatibilityEvidence": { "evidenceIds": ["local:r07:native-baseline-independent", "local:r07:legacy-tombstone-only"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "conformanceEvidence": { "evidenceIds": ["local:r07:b07-registry-reconciled", "local:r07:command-discovery-call-sites-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "economics": { "benefit": { "metric": "production semantic invocations retained", "value": 0, "unit": "invocations" }, "cost": { "metric": "production integration paths retained", "value": 0, "unit": "paths" }, "benefitEvidence": { "evidenceIds": ["local:r07:production-semantic-invocations-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "costEvidence": { "evidenceIds": ["local:r07:production-integration-present-false"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "assurance": { "maintenanceEvidence": { "evidenceIds": ["local:r07:reviewed-deletion"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "securityEvidence": { "evidenceIds": ["local:r07:no-command-discovery", "local:r07:no-provider-effects"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, diff --git a/orchestration/internalization/git.json b/orchestration/internalization/git.json index 281e4725..2149a699 100644 --- a/orchestration/internalization/git.json +++ b/orchestration/internalization/git.json @@ -1,6 +1,6 @@ { "schema": "code-intel-internalization-record.v1", "id": "internalization.git-record", "projectId": "code-intel-pipeline", - "subject": { "name": "Git read-only repository protocol dependency", "kind": "adapted_capability", "source": { "uri": "https://git-scm.com; installed-path=C:/Program Files/Git/cmd/git.exe", "revision": "installed-version:2.54.0.windows.1; local-license-sha256:5b2198d1645f767585e8a88ac0499b04472164c0d2da22e75ecf97ef443ab32e; local-snapshot-source-sha256:1fa974b28cc2ab92b1ac04e22702b6b126023bcc0ccf1feee14e3d31a833f9dc; local-conformance-sha256:945cbe76b309680752154ed83c4557611ca0f9975d9af57cc7fea8ddc7fb0364; measurement-sha256:c9fc9a0b4440624c1b8ca83f1068fb4f84b7ecb9d0cb8ce8c3644948d8568091" }, "license": { "id": "GPL-2.0-only-LOCAL-LICENSE-COPY-BOUND", "obligations": ["retain C:/Program Files/Git/LICENSE.txt and its bound digest with package provenance before redistribution", "restrict current adapter authority to read-only repository inspection; mutation commands require a separately registered and A05-gated capability"] } }, + "subject": { "name": "Git read-only repository protocol dependency", "kind": "adapted_capability", "source": { "uri": "https://git-scm.com; installed-path=C:/Program Files/Git/cmd/git.exe", "revision": "installed-version:2.54.0.windows.1; local-license-sha256:5b2198d1645f767585e8a88ac0499b04472164c0d2da22e75ecf97ef443ab32e; local-snapshot-source-sha256:1fa974b28cc2ab92b1ac04e22702b6b126023bcc0ccf1feee14e3d31a833f9dc; local-conformance-sha256:945cbe76b309680752154ed83c4557611ca0f9975d9af57cc7fea8ddc7fb0364; measurement-sha256:8c9332a2f2da30c36f24192ec70dfbf88941d5315062597b648a0422be5715ef" }, "license": { "id": "GPL-2.0-only-LOCAL-LICENSE-COPY-BOUND", "obligations": ["retain C:/Program Files/Git/LICENSE.txt and its bound digest with package provenance before redistribution", "restrict current adapter authority to read-only repository inspection; mutation commands require a separately registered and A05-gated capability"] } }, "adoption": { "rung": "invoke", "ownedBoundary": ["Pipeline owns portable Snapshot Identity, explicit head_only and explicit_overlay semantics, scope normalization, Git command argument construction, output parsing, and failure taxonomy", "Git owns repository storage and protocol behavior; commit, checkout, reset, clean, add, index mutation, fetch, push, config writes, hooks, credentials, and network operations are out of scope"], "necessityEvidence": { "evidenceIds": ["local:a02:repository.snapshot-identity", "local:b07:snapshot-registered", "local:r10:installed-git-2.54.0.windows.1"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "compatibilityEvidence": { "evidenceIds": ["local:r10:head-overlay-fixtures", "local:r10:unborn-shallow-gitlink-lfs-fixtures", "local:r10:alternate-vcs-adapter-actually-executed", "local:r10:alternate-vcs-mismatch-fail-closed", "local:r10:rollback-to-git-or-unversioned"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "conformanceEvidence": { "evidenceIds": ["local:r10:operation-trace", "local:r10:read-only-command-audit", "local:r10:alternate-mismatch-exit-65", "gap:git:representative-platform-matrix"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "operationTrace": [ { "integrationId": "repository.snapshot-identity", "operation": "snapshotIdentity", "command": "target/debug/code-intel.exe snapshot identity --repo --working-tree-policy --scope ", "implementationIdentity": { "providerId": "git-system-dependency", "implementationId": "git-2.54.0.windows.1+snapshot-v1", "activation": "required read-only repository inspection" }, "source": { "path": "crates/code-intel-cli/src/snapshot.rs", "sha256": "1fa974b28cc2ab92b1ac04e22702b6b126023bcc0ccf1feee14e3d31a833f9dc" }, "conformance": { "path": "crates/code-intel-cli/tests/snapshot_identity.rs", "sha256": "945cbe76b309680752154ed83c4557611ca0f9975d9af57cc7fea8ddc7fb0364", "testName": "alternate_vcs_contract_fixture_is_fail_closed_and_rolls_back_to_unversioned" } }, diff --git a/orchestration/internalization/github-research.json b/orchestration/internalization/github-research.json index 514f8b0d..e00bccec 100644 --- a/orchestration/internalization/github-research.json +++ b/orchestration/internalization/github-research.json @@ -1,6 +1,6 @@ { "schema": "code-intel-internalization-record.v1", "id": "internalization.github-research-record", "projectId": "code-intel-pipeline", - "subject": { "name": "GitHub Solution Research reviewed retirement record", "kind": "adapted_capability", "source": { "uri": "https://github.com/cli/cli; historical-local-adapter=legacy/Invoke-GitHubSolutionResearch.ps1", "revision": "installed-gh-version:2.95.0; representative-live-run:manual_required-invalid-query; run-artifact:orchestration/internalization/evidence/r08-live-20260714/github-solution-research.json; run-artifact-sha256:30d023b5978654c7db164430242a455357fcc0d5a4d021ac3984bee998c3ea2d; candidates:0; reproducible:false; production-registry-status:deleted; measurement-sha256:c9fc9a0b4440624c1b8ca83f1068fb4f84b7ecb9d0cb8ce8c3644948d8568091" }, "license": { "id": "MIT-GH-CLI-UPSTREAM-CLAIM-PLUS-SOURCE-ATTRIBUTION", "obligations": ["retain historical URL attribution and quotation obligations", "never persist credentials or restore production network reads without new reproducible value evidence and authority"] } }, + "subject": { "name": "GitHub Solution Research reviewed retirement record", "kind": "adapted_capability", "source": { "uri": "https://github.com/cli/cli; historical-local-adapter=legacy/Invoke-GitHubSolutionResearch.ps1", "revision": "installed-gh-version:2.95.0; representative-live-run:manual_required-invalid-query; run-artifact:orchestration/internalization/evidence/r08-live-20260714/github-solution-research.json; run-artifact-sha256:30d023b5978654c7db164430242a455357fcc0d5a4d021ac3984bee998c3ea2d; candidates:0; reproducible:false; production-registry-status:deleted; measurement-sha256:8c9332a2f2da30c36f24192ec70dfbf88941d5315062597b648a0422be5715ef" }, "license": { "id": "MIT-GH-CLI-UPSTREAM-CLAIM-PLUS-SOURCE-ATTRIBUTION", "obligations": ["retain historical URL attribution and quotation obligations", "never persist credentials or restore production network reads without new reproducible value evidence and authority"] } }, "adoption": { "rung": "adapt", "ownedBoundary": ["The authenticated representative blocker query took 13591.4758 ms and returned manual_required with zero candidates because the generated query was invalid", "Pipeline removed the production network/credential call site instead of treating offline controls as blocker-resolution value"], "necessityEvidence": { "evidenceIds": ["local:r08:representative-live-run", "local:r08:run-artifact-sha256-f838958011ac2ec9e8a525f5d0d0d249dd78c8fd481628f84061b97eef463b6c", "local:r08:resolution-at-k-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "compatibilityEvidence": { "evidenceIds": ["local:r08:reproducible-false", "local:r08:invalid-query"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "conformanceEvidence": { "evidenceIds": ["local:r08:b07-reviewed-deletion", "local:r08:production-call-sites-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "economics": { "benefit": { "metric": "representative blocker resolution@k", "value": 0, "unit": "resolved-blockers" }, "cost": { "metric": "representative failed live-query latency", "value": 13591.4758, "unit": "milliseconds" }, "benefitEvidence": { "evidenceIds": ["local:r08:candidates-0", "local:r08:resolution-at-k-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "costEvidence": { "evidenceIds": ["local:r08:live-elapsed-ms-13591.4758", "local:r08:rate-cost-requests-1", "local:r08:external-writes-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "assurance": { "maintenanceEvidence": { "evidenceIds": ["local:r08:invalid-query", "local:r08:reviewed-deletion"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "securityEvidence": { "evidenceIds": ["local:r08:production-network-read-removed", "local:r08:production-credential-read-removed", "local:r08:external-writes-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, diff --git a/orchestration/internalization/greenfield.json b/orchestration/internalization/greenfield.json index 85f363c8..5016b4e7 100644 --- a/orchestration/internalization/greenfield.json +++ b/orchestration/internalization/greenfield.json @@ -1,6 +1,6 @@ { "schema": "code-intel-internalization-record.v1", "id": "internalization.greenfield-record", "projectId": "code-intel-pipeline", - "subject": { "name": "Pipeline-owned plan-only specification handoff; external Greenfield plugin retired", "kind": "selectively_owned_implementation", "source": { "uri": "local:legacy/Invoke-GreenfieldSpecExtraction.ps1; external-plugin-reference=https://greenfield.tools", "revision": "pipeline-owned-plan-only; external-plugin-retired; production-integration-present:false; measurement-sha256:c9fc9a0b4440624c1b8ca83f1068fb4f84b7ecb9d0cb8ce8c3644948d8568091" }, "license": { "id": "PROJECT-LICENSE-PIPELINE-OWNED-PLAN-ONLY", "obligations": ["do not represent the local plan contract as execution or provenance for an external plugin", "generated specifications, tests, validation, and provenance never grant specification or implementation authority"] } }, + "subject": { "name": "Pipeline-owned plan-only specification handoff; external Greenfield plugin retired", "kind": "selectively_owned_implementation", "source": { "uri": "local:legacy/Invoke-GreenfieldSpecExtraction.ps1; external-plugin-reference=https://greenfield.tools", "revision": "pipeline-owned-plan-only; external-plugin-retired; production-integration-present:false; measurement-sha256:8c9332a2f2da30c36f24192ec70dfbf88941d5315062597b648a0422be5715ef" }, "license": { "id": "PROJECT-LICENSE-PIPELINE-OWNED-PLAN-ONLY", "obligations": ["do not represent the local plan contract as execution or provenance for an external plugin", "generated specifications, tests, validation, and provenance never grant specification or implementation authority"] } }, "adoption": { "rung": "reimplement", "ownedBoundary": ["Pipeline owns only the plan prompt, workspace layout, manifest, expected artifact locations, exclude validation, and review-required handoff", "The external plugin source, runtime, license, generated content, data effects, and value are not adopted; spec.greenfield was removed from production integration"], "necessityEvidence": { "evidenceIds": ["local:r12:pipeline-owned-plan-only", "local:r12:external-plugin-retired"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "compatibilityEvidence": { "evidenceIds": ["local:r12:plan-only-fixture", "local:r12:auto-analyze-without-flag-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "conformanceEvidence": { "evidenceIds": ["local:r12:plan-artifacts-2", "local:r12:explicit-analyze-fixture-1", "local:r12:production-integration-present-false"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "economics": { "benefit": { "metric": "pipeline-owned plan and explicit fixture paths", "value": 2, "unit": "paths" }, "cost": { "metric": "offline plan-contract fixture elapsed time", "value": 2127.4285, "unit": "milliseconds" }, "benefitEvidence": { "evidenceIds": ["local:r12:plan-artifacts-2", "local:r12:explicit-analyze-fixture-1", "local:r12:auto-analyze-without-flag-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "costEvidence": { "evidenceIds": ["local:r12:fixture-elapsed-ms-2127.4285", "local:r12:external-provider-writes-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "assurance": { "maintenanceEvidence": { "evidenceIds": ["local:r12:plan-only-stable-contract", "local:r12:external-plugin-retired"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "securityEvidence": { "evidenceIds": ["local:r12:no-auto-execution", "local:r12:no-external-plugin-production-path"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, diff --git a/orchestration/internalization/native-code-evidence.json b/orchestration/internalization/native-code-evidence.json index 4625b599..189a3ae4 100644 --- a/orchestration/internalization/native-code-evidence.json +++ b/orchestration/internalization/native-code-evidence.json @@ -1,6 +1,6 @@ { "schema": "code-intel-internalization-record.v1", "id": "internalization.native-code-evidence-record", "projectId": "code-intel-pipeline", - "subject": { "name": "Native Code Evidence deterministic baseline", "kind": "selectively_owned_implementation", "source": { "uri": "local:crates/code-intel-cli/src/native_code_evidence.rs", "revision": "pipeline-owned-v1; local-runtime-sha256:38e165ad6fb351a27c8187838e71b3a1526408105c4efb8bcfd64672ff2f6064; local-conformance-sha256:ea0e62cf23d14e4207f626ed2c2ead0f49c7c6231e9df0e1398f4b934a19392f; measurement-sha256:c9fc9a0b4440624c1b8ca83f1068fb4f84b7ecb9d0cb8ce8c3644948d8568091" }, "license": { "id": "PROJECT-LICENSE-PIPELINE-OWNED", "obligations": ["retain repository license and provenance for the owned implementation", "never relabel line heuristics as parser, call-graph, semantic, or framework precision"] } }, + "subject": { "name": "Native Code Evidence deterministic baseline", "kind": "selectively_owned_implementation", "source": { "uri": "local:crates/code-intel-cli/src/native_code_evidence.rs", "revision": "pipeline-owned-v1; local-runtime-sha256:38e165ad6fb351a27c8187838e71b3a1526408105c4efb8bcfd64672ff2f6064; local-conformance-sha256:ea0e62cf23d14e4207f626ed2c2ead0f49c7c6231e9df0e1398f4b934a19392f; measurement-sha256:8c9332a2f2da30c36f24192ec70dfbf88941d5315062597b648a0422be5715ef" }, "license": { "id": "PROJECT-LICENSE-PIPELINE-OWNED", "obligations": ["retain repository license and provenance for the owned implementation", "never relabel line heuristics as parser, call-graph, semantic, or framework precision"] } }, "adoption": { "rung": "reimplement", "ownedBoundary": ["Pipeline owns deterministic file, line-heuristic symbol, file-chunk, containment, heuristic import, scorecard, and agent-slice artifacts", "Pipeline does not claim AST parsing, dynamic calls, cross-file call graph, framework semantics, embeddings, or cocoindex behavior"], "necessityEvidence": { "evidenceIds": ["local:b07:evidence.native-code-required", "local:b08:legacy-parity"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "compatibilityEvidence": { "evidenceIds": ["local:b08:a01-a03-a09-artifact-parity", "local:r06:unsupported-language-unknown"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "conformanceEvidence": { "evidenceIds": ["local:r06:operation-trace", "local:b08:native-contract-tests", "local:r06:labeled-corpus-samples-12", "local:r06:precision-0.75", "local:r06:recall-0.75", "local:r06:supported-coverage-0.833333"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "operationTrace": [{ "integrationId": "evidence.native-code", "operation": "capabilityExec", "command": "target/debug/code-intel.exe capability exec evidence.native-code --request --out ", "implementationIdentity": { "providerId": "code-intel-pipeline", "implementationId": "evidence.native-code.compat-1.0.0", "activation": "required deterministic production baseline" }, "source": { "path": "crates/code-intel-cli/src/native_code_evidence.rs", "sha256": "38e165ad6fb351a27c8187838e71b3a1526408105c4efb8bcfd64672ff2f6064" }, "conformance": { "path": "crates/code-intel-cli/tests/native_code_evidence.rs", "sha256": "ea0e62cf23d14e4207f626ed2c2ead0f49c7c6231e9df0e1398f4b934a19392f", "testName": "labeled_multilingual_corpus_quantifies_native_symbol_precision_recall_and_coverage" } }], "economics": { "benefit": { "metric": "B08 normalized legacy parity artifacts", "value": 6, "unit": "artifacts" }, "cost": { "metric": "four-test B08 fixture suite elapsed time", "value": 27110, "unit": "milliseconds" }, "benefitEvidence": { "evidenceIds": ["local:b08:normalized-parity-artifacts-6", "local:r06:artifact-refs-8", "local:r06:fixture-files-2", "local:r06:labeled-corpus-samples-12", "local:r06:true-positive-6", "local:r06:false-positive-2", "local:r06:false-negative-2"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "costEvidence": { "evidenceIds": ["local:r06:suite-elapsed-ms-27110", "local:r06:labeled-corpus-elapsed-ms-3620", "gap:native-code:framework-matrix"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, diff --git a/orchestration/internalization/repomix.json b/orchestration/internalization/repomix.json index da2a4a12..d0bc3802 100644 --- a/orchestration/internalization/repomix.json +++ b/orchestration/internalization/repomix.json @@ -1,11 +1,11 @@ { "schema": "code-intel-internalization-record.v1", "id": "internalization.repomix-record", "projectId": "code-intel-pipeline", - "subject": { "name": "Repomix repository packaging capability", "kind": "adapted_capability", "source": { "uri": "https://www.npmjs.com/package/repomix; repository=https://github.com/yamadashy/repomix", "revision": "npm-observed-latest:1.16.0; audited-host-command:unavailable; npm-registry-metadata-entries:3; npm-package-tarball-entries:3; npm-installed-or-extracted-executable-entries:0; measurement-sha256:c9fc9a0b4440624c1b8ca83f1068fb4f84b7ecb9d0cb8ce8c3644948d8568091" }, "license": { "id": "MIT-UPSTREAM-PACKAGE-METADATA-LOCAL-COPY-MISSING", "obligations": ["retain exact installed package provenance and MIT notice before any future production use", "a new authority-approved record and B07 declaration are required before restoring invocation"] } }, + "subject": { "name": "Repomix repository packaging capability", "kind": "adapted_capability", "source": { "uri": "https://www.npmjs.com/package/repomix; repository=https://github.com/yamadashy/repomix", "revision": "npm-observed-latest:1.16.0; audited-host-command:unavailable; npm-registry-metadata-entries:3; npm-package-tarball-entries:3; npm-installed-or-extracted-executable-entries:0; measurement-sha256:8c9332a2f2da30c36f24192ec70dfbf88941d5315062597b648a0422be5715ef" }, "license": { "id": "MIT-UPSTREAM-PACKAGE-METADATA-LOCAL-COPY-MISSING", "obligations": ["retain exact installed package provenance and MIT notice before any future production use", "a new authority-approved record and B07 declaration are required before restoring invocation"] } }, "adoption": { "rung": "invoke", "ownedBoundary": ["Pipeline retains only a dormant adapter and audited research record; B07 production call site and integration declaration were removed", "Repomix traversal, compression, token counting, secret scanning, remote retrieval, and package behavior are not production capabilities"], "necessityEvidence": { "evidenceIds": ["local:r05:b07-reviewed-deletion", "local:r05:zero-production-call-sites"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "compatibilityEvidence": { "evidenceIds": ["local:r05:adapter-fixture-only", "gap:repomix:installed-package-conformance"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "conformanceEvidence": { "evidenceIds": ["local:r05:deleted-registry-reconciliation", "gap:repomix:production-output-conformance"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "economics": { "benefit": { "metric": "production packaging invocations retained", "value": 0, "unit": "call-sites" }, "cost": { "metric": "audited installed or extracted Repomix executable trees", "value": 0, "unit": "executables" }, "benefitEvidence": { "evidenceIds": ["local:r05:production-call-sites-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "costEvidence": { "evidenceIds": ["local:r05:command-unavailable", "local:r05:npm-registry-metadata-entries-3", "local:r05:npm-package-tarball-entries-3", "local:r05:npm-installed-or-extracted-executable-entries-0"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "assurance": { "maintenanceEvidence": { "evidenceIds": ["local:r05:reviewed-deletion", "gap:repomix:future-update-review"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "securityEvidence": { "evidenceIds": ["local:r05:no-production-process-or-remote-effects", "gap:repomix:future-supply-chain-review"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "update": { "policy": "Do not install or restore implicitly; any future proposal must pin package integrity and license, then rerun representative output, token, latency, security, and alternative measurements", "nextCheckAt": 1791676800, "evidence": { "evidenceIds": ["local:r05:delete-until-justified"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, - "ownedModifications": [{ "path": "orchestration/integrations.json", "description": "Reviewed deletion records zero Repomix production call sites and removes the integration declaration", "evidenceIds": ["local:r05:b07-reviewed-deletion", "local:r05:measurement-sha256:c9fc9a0b4440624c1b8ca83f1068fb4f84b7ecb9d0cb8ce8c3644948d8568091"] }], + "ownedModifications": [{ "path": "orchestration/integrations.json", "description": "Reviewed deletion records zero Repomix production call sites and removes the integration declaration", "evidenceIds": ["local:r05:b07-reviewed-deletion", "local:r05:measurement-sha256:8c9332a2f2da30c36f24192ec70dfbf88941d5315062597b648a0422be5715ef"] }], "rollback": { "strategy": "retain the current summary-first workflow; restoration requires a new pinned record, conformance evidence, measurement, and B07 reconciliation", "evidence": { "evidenceIds": ["local:r05:summary-first-fallback", "local:r05:zero-production-call-sites"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "exit": { "strategy": "delete the dormant adapter and research record when historical audit value is no longer required", "replacementCriteria": ["inventory and Native Code Evidence remain existing repository-orientation alternatives", "a replacement proves bounded artifacts, secret handling, local-only defaults, latency, size, and token value", "production restoration is separately authority approved"], "evidence": { "evidenceIds": ["local:r05:inventory-native-alternatives", "local:r05:reviewed-deletion"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "retirement": { "status": "completed", "triggers": ["no installed/extracted executable tree or verified runnable command", "no production conformance or measured advantage", "B07 reviewed deletion removed the call site"], "evidence": { "evidenceIds": ["local:r05:command-unavailable", "local:r05:npm-registry-metadata-classified", "local:r05:npm-installed-or-extracted-executable-entries-0", "local:r05:deleted-registry-reconciliation"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, diff --git a/orchestration/internalization/repowise.json b/orchestration/internalization/repowise.json index d48a4d69..3eece77e 100644 --- a/orchestration/internalization/repowise.json +++ b/orchestration/internalization/repowise.json @@ -8,7 +8,7 @@ "ownedBoundary": ["Pipeline-owned B01 translation of Repowise status, index, and docs outcomes into the Evidence Provider Port and A04", "Repowise owns provider internals, quota, remote service behavior, index semantics, and generated documentation; this record grants no install, network, or upgrade authority"], "necessityEvidence": { "evidenceIds": ["local:registry:provider.repowise-adapt", "local:registry:memory.repowise", "gap:repowise:upstream-revision", "gap:repowise:license"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "compatibilityEvidence": { "evidenceIds": ["local:b01:status-index-docs-separated", "local:b01:facade-route", "gap:repowise:upstream-cli-matrix"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, - "conformanceEvidence": { "evidenceIds": ["local:b01:repowise-adapter-conformance:bc59f5f22671a14669b7a053f0d075ae64baabc7fabb730d608e3535609a9922", "local:b01:production-operation-trace", "gap:repowise:upstream-exact-revision-conformance"], "checkedAt": 1783900800, "expiresAt": 1791676800 } + "conformanceEvidence": { "evidenceIds": ["local:b01:repowise-adapter-conformance:827b2238e0127a7375c4035bb270faa6bc4ab0eef1d77a589fedc42151332794", "local:b01:production-operation-trace", "gap:repowise:upstream-exact-revision-conformance"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "operationTrace": [ { "integrationId": "provider.repowise-adapt", "operation": "adapt", "command": "target/debug/code-intel.exe provider repowise-adapt --request --artifact-root --evaluated-at --max-age-seconds ", "implementationIdentity": { "providerId": "repowise.external", "implementationId": "repowise.native.v0.9.0", "activation": "primary" }, "source": { "path": "crates/code-intel-cli/src/repowise_adapter.rs", "sha256": "a1ee9b3958746b2506b39be4365b9321e1809cbad6a12681573c616d289bf178" }, "conformance": { "path": "crates/code-intel-cli/tests/repowise_route.rs", "sha256": "4d5927932faf388e93f78f2ab661ffdb991e78bea0010da6553fb93a70e14572", "testName": "public_route_translates_and_a04_validates_success_quota_and_index_only" } }, @@ -19,10 +19,10 @@ "economics": { "benefit": { "metric": "B01 production operations traced through the registered adapter", "value": 2, "unit": "operations" }, "cost": { "metric": "registered B01 command surfaces requiring lifecycle evidence", "value": 2, "unit": "operations" }, "benefitEvidence": { "evidenceIds": ["local:b01:production-operation-trace", "gap:repowise:representative-value-measurement"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "costEvidence": { "evidenceIds": ["local:registry:provider.repowise-adapt", "gap:repowise:quota-latency-cost-measurement"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "assurance": { "maintenanceEvidence": { "evidenceIds": ["local:b01:rollback-compatible", "gap:repowise:maintenance-status"], "checkedAt": 1783900800, "expiresAt": 1791676800 }, "securityEvidence": { "evidenceIds": ["local:b01:snapshot-and-provenance-validation", "gap:repowise:security-review", "gap:repowise:quota-data-handling-review"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "update": { "policy": "Before 2026-10-11, verify canonical source, exact version, license, maintenance/security posture, quota behavior, and representative index/docs measurements; keep this record research-only while any gap remains", "nextCheckAt": 1791676800, "evidence": { "evidenceIds": ["gap:repowise:update-check"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, - "ownedModifications": [ { "path": "crates/code-intel-cli/src/repowise_adapter.rs", "description": "Pipeline-owned B01 provider-neutral adapter; no Repowise implementation vendoring", "evidenceIds": ["local:b01:adapter-source-sha256:a1ee9b3958746b2506b39be4365b9321e1809cbad6a12681573c616d289bf178", "local:b01:repowise-adapter-conformance:bc59f5f22671a14669b7a053f0d075ae64baabc7fabb730d608e3535609a9922"] } ], + "ownedModifications": [ { "path": "crates/code-intel-cli/src/repowise_adapter.rs", "description": "Pipeline-owned B01 provider-neutral adapter; no Repowise implementation vendoring", "evidenceIds": ["local:b01:adapter-source-sha256:a1ee9b3958746b2506b39be4365b9321e1809cbad6a12681573c616d289bf178", "local:b01:repowise-adapter-conformance:827b2238e0127a7375c4035bb270faa6bc4ab0eef1d77a589fedc42151332794"] } ], "rollback": { "strategy": "disable provider.repowise-adapt and return to the declared compatibility route without deleting provider state or changing A04", "evidence": { "evidenceIds": ["local:b01:rollback-compatible"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "exit": { "strategy": "replace Repowise behind the Evidence Provider Port or remove the optional provider without changing core admissibility", "replacementCriteria": ["replacement passes B01 status/index/docs separation and A04 conformance", "snapshot, quota, provenance, and failure semantics remain explicit", "Repowise state export or deliberate abandonment is documented"], "evidence": { "evidenceIds": ["local:b01:provider-port-replaceability", "gap:repowise:state-exit-drill"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, "retirement": { "status": "candidate", "triggers": ["canonical revision or license remains unverifiable", "quota, security, or maintenance evidence remains unavailable", "a replacement passes B01 and the Repowise route has no production callers"], "evidence": { "evidenceIds": ["local:registry:provider.repowise-adapt", "gap:repowise:retirement-proof"], "checkedAt": 1783900800, "expiresAt": 1791676800 } }, - "lifecycle": { "previousStatus": null, "status": "research", "effectiveAt": 1783900800, "replacementRecordId": null, "evidenceIds": ["local:b01:repowise-adapter-conformance:bc59f5f22671a14669b7a053f0d075ae64baabc7fabb730d608e3535609a9922", "local:b01:production-operation-trace", "gap:repowise:upstream-revision", "gap:repowise:license", "gap:repowise:representative-value-measurement"], "authorityEvent": null }, + "lifecycle": { "previousStatus": null, "status": "research", "effectiveAt": 1783900800, "replacementRecordId": null, "evidenceIds": ["local:b01:repowise-adapter-conformance:827b2238e0127a7375c4035bb270faa6bc4ab0eef1d77a589fedc42151332794", "local:b01:production-operation-trace", "gap:repowise:upstream-revision", "gap:repowise:license", "gap:repowise:representative-value-measurement"], "authorityEvent": null }, "provenance": { "recordedAt": 1783900800, "recordedBy": "dependency-expert" } } diff --git a/skills/code-intel-pipeline/SKILL.md b/skills/code-intel-pipeline/SKILL.md index 0b3302da..63e7fd13 100644 --- a/skills/code-intel-pipeline/SKILL.md +++ b/skills/code-intel-pipeline/SKILL.md @@ -107,6 +107,34 @@ Use the Sentrux session wrapper for an Agent coding session: Keep `.sentrux/rules.toml` separate from `.sentrux/baseline.json`. Rules define architecture boundaries; baselines detect change. Never save a new baseline to hide a regression. +## Prefer the MCP query surface over the CLI for single questions + +If the host can register MCP servers, register this one and ask through it instead of shelling out +per question: + +```powershell +code-intel serve --mcp --repo +``` + +It is a stdio server with six tools whose data sources differ — check which one you are reading +before you trust how fresh it is: + +- `get_gate_verdict`, `get_facts`, `get_evidence`, `get_audit_status` project the **last committed + run**. Each answer carries the run, the snapshot identity, and a freshness field. +- `get_change_impact` reads the **committed import graph** but evaluates it against the **current + `--repo-path`**. It answers `stale-advisory` by default, naming both the recorded and the current + snapshot identity; pass `requireCurrent` to get the fail-closed behaviour instead. +- `plan_structural_edit` scans the **current working tree**, not the committed run, and writes + nothing. + +Pass `--repo` explicitly: a worktree's directory name is not the name `run commit` published under. +The surface is read-only and gates nothing — a verdict read here is not a verdict earned, and the +CLI and CI paths remain the only places a gate runs. + +The CLI spellings below stay correct and are the fallback when no MCP host is available. A full +`code-intel --mode normal` run is the deep-inspection mode, not the way to answer one +question. + ## While writing code Run this loop whenever implementing, refactoring, or fixing code in an analyzed repository: