tomato·bazeldocs v0 · latest
Docs/Reference/Modules/rules_agentic_ide

rules_agentic_ide

Project a normalized RDF knowledge graph into per-IDE agent configs (Claude Code / Copilot / Cursor) — skills, rulesets, MCP — via Bazel + Apache Jena.

Latest0.0.4
Versions4
CategoryBazel rules
MaintainersMatt Marshall
Registryhttps://registry.tbzl.dev/modules/rules_agentic_ide/
Sourcegithub.com/tomato-bazel/rules_agentic_ide
MODULE.bazelstarlark
bazel_dep(name = "rules_agentic_ide", version = "0.0.4")

View source & releases on GitHub ↗

Project a normalized RDF knowledge graph into per-IDE agent configs — skills, rulesets, and MCP servers for Claude Code, Copilot, and Cursor — with Bazel + Apache Jena (rules_jena).

Use it

# MODULE.bazel — resolves from the fastverk registry (registry.fastverk.com)
bazel_dep(name = "rules_agentic_ide", version = "0.0.3")

See the package BUILD.bazel / defs.bzl for the available rules. Part of the tomato-bazel distribution.

Rules & providers#

Generated with Stardoc from the module's .bzl sources.

from docs/consumer-setup.md

Consuming rules_agentic_ide (wiring .agents/)

A consumer repo keeps its agent-config knowledge graph in .agents/graph/ and lets the framework project it into per-IDE files. Three steps.

1. Depend on the framework

MODULE.bazel:

bazel_dep(name = "rules_agentic_ide", version = "0.0.2")

Resolve it from the fastverk registry (pin the registry commit, or BCR once published). rules_rdf + rules_jena come transitively; register the Jena toolchains if you don’t already.

2. .agents/BUILD.bazel

load("@rules_rdf//rdf:dataset.bzl", "rdf_dataset")
load("@rules_agentic_ide//rules:defs.bzl", "agent_bundle", "agent_validate")

## The authored KG — the single source of truth.
rdf_dataset(name = "graph", srcs = glob(["graph/*.ttl"]), in_format = "turtle")

## Schema gate (SHACL): bazel test //.agents:validate
agent_validate(name = "validate", graph = ":graph")

## Project to every IDE you target. Adding an IDE = adding a query.
agent_bundle(
    name = "agents",
    graph = ":graph",
    projections = [
        "@rules_agentic_ide//rdf/projections:claude_code/mcp.rq",
        "@rules_agentic_ide//rdf/projections:claude_code/settings.rq",
        "@rules_agentic_ide//rdf/projections:claude_code/skills.rq",
        "@rules_agentic_ide//rdf/projections:claude_code/rulesets.rq",
        "@rules_agentic_ide//rdf/projections:cursor/skills.rq",
        "@rules_agentic_ide//rdf/projections:cursor/rulesets.rq",
        "@rules_agentic_ide//rdf/projections:copilot/skills.rq",
        "@rules_agentic_ide//rdf/projections:copilot/rulesets.rq",
    ],
)

Your .agents/graph/*.ttl author aide:Skill / aide:Ruleset / aide:McpServer / aide:Settings; bodies live at the aide:bodyPath targets (.agents/skills/**, rule .md files, AGENTS.md).

3. Generate / check / validate

## Schema gate
bazel test //.agents:validate

## Regenerate the per-IDE config files (writes the working tree; also adds
## them to .gitignore and rewrites .agents/generated.lock).
bazel build //.agents:agents
bazel run @rules_agentic_ide//crates/agentic_ide_generate:agentic-ide-generate -- \
    --filespec bazel-bin/.agents/agents.nt          # --out defaults to the workspace

## Drift gate (CI): exit 1 if generated files are stale vs .agents/generated.lock
bazel run @rules_agentic_ide//crates/agentic_ide_generate:agentic-ide-generate -- \
    --filespec bazel-bin/.agents/agents.nt --check

A one-line wrapper (tools/agents.sh building the bundle then running generate) keeps the daily command short; the canonical targets above are what CI runs.

What’s committed vs generated

Committed (source)Gitignored (generated)
.agents/graph/*.ttl, .agents/skills/**, AGENTS.md, rule bodies.claude/**, .cursor/rules/**, .mcp.json, CLAUDE.md, .github/agents/**, copilot-instructions.md
.agents/generated.lock (the drift review surface)

Never hand-edit a generated file — change the graph/body and regenerate. See upgrading.md for the version-bump flow.

from docs/mcp-gateway.md

MCP gateway daemon (Phase 2) — warm, multiplexed, hermetic

A long-lived daemon that fronts all of a repo’s MCP servers behind one warm endpoint. The IDE connects once; backends are launched lazily from their lock-pinned Bazel targets and kept warm. This is the responsive, cross-session counterpart to the per-server direct launchers in mcp-hosts.md — and it’s the existing mcp-gateway (today KG-only) extended from “serve the knowledge graph” to “serve the KG plus multiplex external backends.” It is the hermetic, bazel-native, KG-aware analogue of docker mcp gateway run (which savvi already uses).

Why a daemon

MCP stdio clients spawn each server once per session and hold the pipe — so per-call latency isn’t the issue; session-start and cross-session warmth are. A daemon:

  • the IDE connects once (no N-spawn at session start),
  • keeps backends warm across IDE restarts (survives the session),
  • pays each backend’s cold start once, lazily (first use),
  • centralizes secret/env injection (AWS creds in one place, not 17 .mcp.json env blocks) and observability,
  • unifies the KG resources/tools with the external servers behind one endpoint.

Architecture

   IDE (Claude/Cursor/Copilot)
        │  one MCP connection over streamable-HTTP, loopback

   ┌─────────────────────────────────────────────┐
   │  mcp-gateway  (long-lived; bazel run or OCI) │
   │   • MCP server  (http://127.0.0.1:PORT/mcp)  │
   │   • backend manager: lazy-spawn + pool + reap│
   │   • aggregator: namespaced tools/resources   │
   │   • KG tools/resources (the original role)   │
   └───────┬───────────────┬───────────────┬──────┘
           │ stdio         │ stdio         │ http
           ▼               ▼               ▼
   bazel run //…:postgres  …:git      grafana (remote, proxied)
   (pre-built launcher)  (uv launcher)
  • To the IDE it is a NetworkHost.mcp.json collapses to one entry ({"type":"http","url":"http://127.0.0.1:PORT/mcp"}). Projection picks this when “gateway mode” is selected.
  • Backends are the same lock-pinned launcher targets from the host rules. The gateway holds their stdio pipes in a connection pool.

Lifecycle

ConcernDesign
Gateway startbazel run //.agents/mcp:gateway once, the OCI image via the compose stack, or a launchd/devcontainer unit. Stateless re: backends → restart-on-failure is safe.
Backend spawnLazy: on the first tools/call or resources/read routed to a backend, spawn its pre-built launcher (no bazel run roundtrip — uses the --script_path launcher).
WarmthKeep the stdio pipe open in a pool; reuse across calls and across IDE reconnects.
Idle reapClose backends idle > T (configurable) to bound resource use; re-spawn on next use.
Build freshnessLaunchers rebuilt on lockfile change (git hook / mcp_sync); the gateway can refuse-stale + trigger a rebuild with a “building…” response.

Aggregation (the MCP-proxy part)

  • Namespacing: backend postgres tool querypostgres__query; resource URIs prefixed (mcp://postgres/…) to avoid collisions. The KG’s own tools/resources keep their native names.
  • Capability merge: tools/list / resources/list / prompts/list union the backends (lazily probed once, cached).
  • Passthrough: tools/call routes to the owning backend; notifications (resource updates, progress) forwarded both ways.
  • Secrets/env injected per-backend from a central config (the graph), not the projected file.

Config = the same graph

The gateway reads the same aide:McpServer manifest the projection emits — host, launcher label, args, env per server. So there is one source of truth: the graph defines the servers; the projection renders either N direct entries (no daemon) or one gateway entry; the gateway, given the manifest, launches the same pinned targets. Mixed mode is fine (gateway for the chatty/cross-session ones, direct for the rare ones).

Trade-offs (honest)

  • The gateway must implement MCP proxying correctly (namespacing, capability negotiation, notification passthrough) — real work, but well-trodden (it’s what docker mcp gateway does).
  • A background service to supervise (start/stop/health) — fits the existing compose stack / launchd.
  • Single process — mitigated by statelessness + restart-on-failure; a crashed backend is re-spawned, not fatal to the gateway.

Build order

  1. Direct launchers (Phase 1, mcp-hosts.md) — the foundation; both modes need the lock-pinned launcher targets.
  2. Gateway multiplex (this doc) — extend the existing mcp-gateway: backend manager + pool + aggregator, reading the graph manifest. Pure addition; no rework, since it launches the same targets.

from docs/mcp-hosts.md

MCP hosts — hermetic, lock-pinned MCP servers as Bazel targets

The doctrine: an MCP server is either a remote endpoint or a runnable target. A “host” is the pluggable mechanism that produces (and pins) the runnable target. The projection then renders every runnable uniformly as bazel run <label>; only a NetworkHost stays a URL. This replaces non-hermetic pnpx/uvx launches (which resolve + fetch on every spawn) with launchers pinned by the repo’s existing lockfiles.

Host taxonomy

aide: hostBacked byPin sourceProjected command
BazelHostnativesourcebazel run <target> [-- args]
NpmHostaspect_rules_jspnpm-lock.yamlbazel run <js_binary launcher>
PypiHostrules_uvuv.lockbazel run <uv launcher>
OciHostdigest pin / rules_ociimage digestdocker run -i --rm <image>@<digest>
NetworkHost{type, url} (no launch)
SystemHostraw command (non-hermetic, flagged)

Core rules (package-manager-agnostic) — @rules_agentic_ide//rules:defs.bzl

These are proven and need no third-party rules:

## Any runnable bazel target → an MCP server. THIS is the keystone: every
## host reduces to "a launcher target wrapped by mcp_bazel_server".
mcp_bazel_server(name = "studio", target = "//packages/studio-dev-mcp:mcp",
                 args = ["${workspaceFolder}"])      # → bazel run //…:mcp -- ${workspaceFolder}

mcp_network_server(name = "grafana", url = "http://localhost:8000/mcp")

mcp_oci_server(name = "docker-gw", image = "docker/mcp-gateway",
               digest = "sha256:…")                  # → docker run -i --rm …@sha256:…

mcp_system_server(name = "serena", command = "serena",
                  args = ["start-mcp-server"])        # escape hatch (non-hermetic)

Each emits the resolved aide:McpServer TTL (McpServerInfo + RdfDatasetInfo), so it composes straight into the projection graph.

Package-manager hosts (npm / pypi)

The launcher itself is built with the native rules the consumer already has (rules_js / rules_uv) — pinned by the existing lockfile — then wrapped with mcp_bazel_server. The framework deliberately does not re-wrap every package manager; the bridge is mcp_bazel_server.

NpmHost (aspect_rules_js + pnpm-lock.yaml)

## .agents/BUILD.bazel — savvi already has aspect_rules_js + pnpm-lock.yaml.
## 1. add the server packages to package.json, re-lock (pnpm install).
## 2. run the package's bin via the generated bin loader (hermetic, pinned):
load("@npm//:@modelcontextprotocol/server-postgres/package_json.bzl", postgres = "bin")
postgres.mcp_server_postgres_binary(name = "postgres_launcher")
## 3. bridge it to an MCP server:
mcp_bazel_server(name = "postgres", target = ":postgres_launcher")
##    → bazel run //.agents:postgres_launcher

The per-package bin loader path is package-specific, so this stays an explicit two-liner rather than a magic macro. (A thin mcp_npm_server convenience can be added once the bin-loader pattern is uniform across savvi’s servers.)

PypiHost (rules_uv + uv.lock)

## savvi already has pyproject.toml + uv.lock. Add the server to the deps,
## re-lock, expose its console-script as a runnable target, then:
mcp_bazel_server(name = "git", target = "//.agents:mcp_server_git")
##    → bazel run //.agents:mcp_server_git

savvi’s 17 servers → hosts

HostServers
NpmHost (pnpm-lock)linear, postgres, playwright, chrome-devtools, filesystem, aws-sso
PypiHost (uv.lock)git, arxiv-mcp-server, awslabs.aws-api-mcp-server
BazelHost (first-party)studio (//packages/studio-dev-mcp)
OciHost (digest)docker (the docker MCP gateway)
NetworkHostgrafana, tempo, neon, studio-http, Vercel
SystemHost (flag)serena (until packaged)

10 of 17 move from non-hermetic pnpx/uvx to pinned bazel run, reusing savvi’s existing pnpm-lock.yaml + uv.lock. The lockfile is the pin (same pattern as the image digest lock): bumping a server = a reviewable lockfile change, reproducible across the team.

Performance: spawn cost + the daemon

MCP stdio clients spawn each server once per session and hold the pipe open — they do not respawn per call. So the hot cost is session-start.

  1. Pinned launchers already beat pnpx — no per-spawn package resolution/fetch (the slow, flaky part).
  2. Keep bazel run out of the hot path: pre-build launchers with bazel run --script_path=.agents/mcp/bin/<name> //…:<name>_launcher (a standalone runfiles launch script, no bazel-server roundtrip). .mcp.json points at the script; a mcp_sync target / git hook rebuilds on lockfile change. Direct exec → fast session start.
  3. Gateway daemon (Phase 2) for cross-session warmth + one connection — see mcp-gateway.md.

Both modes ride the same lock-pinned launcher targets — the projection just chooses N direct entries vs one gateway entry.

from docs/upgrading.md

Upgrading & evolving a consumer (the graceful path)

Every generated file is a pure, deterministic, idempotent function:

output = f(rules_agentic_ide version, .agents/graph + bodies)

So drift has exactly two axes — you edit the graph/bodies, or you bump the framework version — and graceful evolution means making each axis’s effect reviewable and non-breaking. The toolkit below does that.

Source vs generated (the discipline)

Committed source (hand-edited)Gitignored generated (never hand-edited)
.agents/graph/*.ttl (the KG).claude/**, .cursor/rules/**, .mcp.json, CLAUDE.md, .github/agents/**, copilot-instructions.md
.agents/skills/**, AGENTS.md, rule bodies (bodyPath targets).agents/generated.lock is committed (the review surface)

All changes flow through the source; you never edit a generated file (it’s overwritten on the next generate, which surfaces the mistake). generate adds the generated paths to .gitignore automatically (git check-ignore).

1. generated.lock + --check — reviewable drift

The generated files are gitignored, so a version bump would otherwise change them with no PR diff. The fix is a committed content-hash snapshot (agentic_ide.v1.GeneratedManifest, on-disk as textproto):

bazel run //.agents:generate                 # writes files + rewrites .agents/generated.lock
bazel run //.agents:generate -- --check       # CI gate: exit 1 if the tree is stale
  • A graph edit or a version bump changes hashes → the .agents/generated.lock diff in the PR shows exactly which files drifted, even though the files themselves stay ignored.
  • --check re-renders and diffs against the lock without writing — the CI gate that catches “edited the graph but didn’t regenerate” and “bumped the version but didn’t regenerate.” (Like gofmt -l / terraform plan.)

2. validate — no silent breakage

SHACL shapes (@rules_agentic_ide//rdf/shapes:aide_shapes.ttl) check the consumer’s graph against the floor every projection relies on, with fix-it messages:

bazel run @rules_jena//jena/shacl:jena_shacl -- \
    --shapes=$(location @rules_agentic_ide//rdf/shapes:aide_shapes.ttl) \
    --in-format=turtle < .agents/graph/*.ttl     # conforms → 0, violations → 1

So a bump incompatible with the current graph (a renamed/removed property, a missing required field) fails loudly, not silently. (generate also errors on a dangling bodyPath; lockfile-coverage for MCP hosts is a planned check.)

3. Version policy — pin, semver, deprecate, migrate

  • The consumer pins rules_agentic_ide (registry/MODULE) and bumps deliberately, reading the CHANGELOG.
  • Ontology semver: additive (new property/host) = minor; rename/remove = major. Deprecate within a major (owl:deprecated; validate warns but still works), so a minor bump never breaks a consumer’s .ttl.
  • migrate (major bumps): ship a codemod (a SPARQL UPDATE or transform) that rewrites the old graph shape to the new — a reviewable mechanical PR.

The upgrade flow, end to end

  1. Consumer pins 0.0.1; .agents/graph/* + .agents/generated.lock committed.
  2. Framework releases 0.0.2 (CHANGELOG entry).
  3. Consumer opens a bump-only PR: MODULE/registry pin → 0.0.2.
  4. CI: validate (graph still valid) → generate--check updates the lock.
  5. The lock diff in the PR is purely the framework’s effect (no graph change in this PR). Reviewer inspects, adopts.
  6. Merge; devs generate locally — deterministic, identical output everywhere.

Keep version bumps and graph edits in separate PRs so each diff is attributable to one axis.

Evolving the graph alongside the framework’s library

The framework can ship a library graph (rdf/defs/ — well-known skills / standard MCP servers/hosts). The consumer’s graph composes with it via named graphs and wins on conflict (Forge override), so a library improvement is inherited without clobbering customizations — and the generated.lock diff shows exactly what it changed.

Conformance#

6 findings across 2 invariants. 8 contested atoms. See how gating works or the full report.

D2 a non-dev register_toolchains propagates to every transitive consumer why this matters ↗
versiontoolchain
0.0.4@rules_jena//jena:jena_reasoner_toolchain_def
0.0.4@rules_jena//jena:jena_riot_toolchain_def
0.0.4@rules_jena//jena:jena_shacl_toolchain_def
0.0.4@rules_jena//jena:jena_sparql_toolchain_def
0.0.4@rust_toolchains//:all
D3 a repo name CHOSEN on a SHARED extension must be namespaced why this matters ↗
repoextension
crates@rules_rust//crate_universe:extension.bzl

Contested atoms

Third-party modules where this module resolves a different version than others do. Not a violation of anything this module did — it is the actionable form of a registry-level convergence finding, and the sentence a maintainer can act on.

AtomResolved hereElsewhere
apple_support 1.24.2 2.2.0 ×1
bazel_skylib 1.8.2 1.9.0 ×2
nlohmann_json 3.6.1 3.12.0.bcr.1 ×1
protobuf 33.4 34.0.bcr.1 ×2
rules_jvm_external 6.7 6.8 ×4
rules_python 1.7.0 2.0.1 ×1
rules_swift 3.1.2 3.6.1 ×1
upb 0.0.0-20220923-a547704 0.0.0-20230516-61a97ef ×1

Dependencies#

rules_agentic_ide in the registry graph — what it depends on (left) and what depends on it (right).

Depends on

platforms1.0.0bazel_skylib1.8.2rules_rust0.70.0rules_rdf0.3.0rules_jena0.3.2

Versions#

4 published versions, newest first. Each resolves to an immutable, integrity-checked archive.

VersionIntegrity (sha256)Source archive
0.0.4 latest Dy0t//3FMPXFdptZ… tag archive ↗
0.0.3 XNQ7BWje0D6XprCi… tag archive ↗
0.0.2 P2NhUFy42yylN5jL… tag archive ↗
0.0.1 WTnf8xHoZ+phd6Mj… tag archive ↗

Changelog#

All notable changes to rules_agentic_ide. Ontology changes follow the versioning convention in docs/upgrading.md: additive = minor; deprecate-then-remove (with a migration) = major.

0.0.4

Additive — no migration needed.

Added

  • Cursor MCP projection (cursor/mcp.rq). Projects every authored aide:McpServer to Cursor’s .cursor/mcp.json (the same McpConfig JSON shape Cursor and Claude Code share), so a single agent_bundle can emit .mcp.json + .cursor/mcp.json from one server set. Closes the gap where Cursor had rulesets.rq / skills.rq projections but no MCP one. Wired into the all_surfaces + from_rules example bundles and a cursor_mcp_nt target.

0.0.3

Additive — no migration needed.

Fixed

  • aide:BazelTarget MCP flavor now projects env. A bazel-run MCP server with aide:envVar entries previously dropped them from .mcp.json (only Network/Binary rendered env). Hermetic launchers that need environment (e.g. AWS_REGION/AWS_PROFILE) now carry it through.

0.0.2

Additive — no migration needed; a 0.0.1 graph still validates and projects.

Added

  • MCP host abstractionmcp_bazel_server / mcp_network_server / mcp_oci_server / mcp_system_server (+ aide:McpHost vocab). Every runnable host projects to bazel run <launcher>; only NetworkHost stays a URL. npm/pypi launcher patterns in docs/mcp-hosts.md; gateway-daemon design in docs/mcp-gateway.md.
  • Settings projection.claude/settings.json (aide:SettingsConfig JSON shape + claude_code/settings.rq).
  • Multi-body concataide:bodyFragment / aide:fragmentHeader: one CLAUDE.md / copilot-instructions.md from a root + N topic rules.
  • Per-IDE frontmatter reshapeaide:stripBodyFrontmatter + aide:description: proper Cursor .mdc / Copilot shim frontmatter over a frontmatter-free body.
  • Upgrade toolkit:
    • generated.lock manifest (content hashes) + generate --check drift gate.
    • .gitignore auto-management (git check-ignore).
    • SHACL validaterdf/shapes/aide_shapes.ttl + the agent_validate macro.
    • deprecation/migrate scaffold (migrations/).
  • exports_files on rdf/projections + rdf/shapes so consumers can reference the queries/shapes.

0.0.1

Initial public release: project a normalized RDF knowledge graph into per-IDE agent configs (skills / rulesets / MCP) for Claude Code, Copilot, and Cursor, via per-IDE CONSTRUCT queries + a dependency-light serializer.

← All modules