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.
| Latest | 0.0.4 |
|---|---|
| Versions | 4 |
| Category | Bazel rules |
| Maintainers | Matt Marshall |
| Registry | https://registry.tbzl.dev/modules/rules_agentic_ide/ |
| Source | github.com/tomato-bazel/rules_agentic_ide |
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.jsonenv 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.jsoncollapses 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
| Concern | Design |
|---|---|
| Gateway start | bazel 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 spawn | Lazy: 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). |
| Warmth | Keep the stdio pipe open in a pool; reuse across calls and across IDE reconnects. |
| Idle reap | Close backends idle > T (configurable) to bound resource use; re-spawn on next use. |
| Build freshness | Launchers 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
postgrestoolquery→postgres__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/listunion the backends (lazily probed once, cached). - Passthrough:
tools/callroutes 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 gatewaydoes). - 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
- Direct launchers (Phase 1,
mcp-hosts.md) — the foundation; both modes need the lock-pinned launcher targets. - 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: host | Backed by | Pin source | Projected command |
|---|---|---|---|
BazelHost | native | source | bazel run <target> [-- args] |
NpmHost | aspect_rules_js | pnpm-lock.yaml | bazel run <js_binary launcher> |
PypiHost | rules_uv | uv.lock | bazel run <uv launcher> |
OciHost | digest pin / rules_oci | image digest | docker run -i --rm <image>@<digest> |
NetworkHost | — | — | {type, url} (no launch) |
SystemHost | — | — | raw 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
| Host | Servers |
|---|---|
| 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) |
| NetworkHost | grafana, 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.
- Pinned launchers already beat
pnpx— no per-spawn package resolution/fetch (the slow, flaky part). - Keep
bazel runout of the hot path: pre-build launchers withbazel run --script_path=.agents/mcp/bin/<name> //…:<name>_launcher(a standalone runfiles launch script, no bazel-server roundtrip)..mcp.jsonpoints at the script; amcp_synctarget / git hook rebuilds on lockfile change. Direct exec → fast session start. - 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.lockdiff in the PR shows exactly which files drifted, even though the files themselves stay ignored. --checkre-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.” (Likegofmt -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;validatewarns but still works), so a minor bump never breaks a consumer’s.ttl. migrate(major bumps): ship a codemod (a SPARQLUPDATEor transform) that rewrites the old graph shape to the new — a reviewable mechanical PR.
The upgrade flow, end to end
- Consumer pins
0.0.1;.agents/graph/*+.agents/generated.lockcommitted. - Framework releases
0.0.2(CHANGELOG entry). - Consumer opens a bump-only PR: MODULE/registry pin →
0.0.2. - CI:
validate(graph still valid) →generate→--checkupdates the lock. - The lock diff in the PR is purely the framework’s effect (no graph change in this PR). Reviewer inspects, adopts.
- Merge; devs
generatelocally — 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.
| version | toolchain |
|---|---|
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 |
| repo | extension |
|---|---|
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.
| Atom | Resolved here | Elsewhere |
|---|---|---|
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#
Depends on
Versions#
4 published versions, newest first. Each resolves to an immutable, integrity-checked archive.
| Version | Integrity (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 authoredaide:McpServerto Cursor’s.cursor/mcp.json(the sameMcpConfigJSON shape Cursor and Claude Code share), so a singleagent_bundlecan emit.mcp.json+.cursor/mcp.jsonfrom one server set. Closes the gap where Cursor hadrulesets.rq/skills.rqprojections but no MCP one. Wired into theall_surfaces+from_rulesexample bundles and acursor_mcp_nttarget.
0.0.3
Additive — no migration needed.
Fixed
aide:BazelTargetMCP flavor now projectsenv. A bazel-run MCP server withaide:envVarentries previously dropped them from.mcp.json(onlyNetwork/Binaryrenderedenv). 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 abstraction —
mcp_bazel_server/mcp_network_server/mcp_oci_server/mcp_system_server(+aide:McpHostvocab). Every runnable host projects tobazel run <launcher>; onlyNetworkHoststays 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:SettingsConfigJSON shape +claude_code/settings.rq). - Multi-body concat —
aide:bodyFragment/aide:fragmentHeader: oneCLAUDE.md/copilot-instructions.mdfrom a root + N topic rules. - Per-IDE frontmatter reshape —
aide:stripBodyFrontmatter+aide:description: proper Cursor.mdc/ Copilot shim frontmatter over a frontmatter-free body. - Upgrade toolkit:
generated.lockmanifest (content hashes) +generate --checkdrift gate..gitignoreauto-management (git check-ignore).- SHACL
validate—rdf/shapes/aide_shapes.ttl+ theagent_validatemacro. - deprecation/migrate scaffold (
migrations/).
exports_filesonrdf/projections+rdf/shapesso 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.