Giving an Agent Access to Janela
Frames and panes are rows (ADR 012), so an agent could always arrange a dashboard by writing them from a console. This page is for giving it tools instead: read a frame, read a pane’s values, and, if you choose, add, change, remove and move panes. The reasoning is in ADR 053.
The short version: Janela gives you the tool definitions as plain Ruby. You build them with the answer to “what may this caller read”, and you register them with whatever MCP library your application already runs. Janela registers nothing, detects nothing and serves nothing, because it cannot know who is asking and your application can.
Setting it up
bin/rails generate janela:mcp
This writes bin/janela-mcp, a stdio server built on the official Ruby SDK, and
adds a janela entry to .mcp.json, which Claude Code and other MCP clients
read to find it. Both are yours from then on. It leaves an existing server or
entry alone, and it does not touch your Gemfile: if mcp is not in your bundle
it says which line to add (gem "mcp").
Open bin/janela-mcp and write the scope. It is the one thing the generator
cannot know: who the agent acts for, and what that caller may read. It cannot be
your controller’s policy_scope, which needs a request, and called without one
it does not fail but answers a different question and reads the wrong rows
(ADR 057). Until you write it, every tool refuses to read and says so. The file
also refuses to run outside development, so an agent never reaches real data
until you decide it should.
The rest of this page explains what is in that file, for a host that would rather write it by hand or register the tools with another library.
Building the tools
tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) })
scope: is required. It takes a model class and returns a relation: the same
answer your policy_scope gives a Janela controller (ADR 032). Leave it out
and Janela::Unscoped is raised when the tools are built, because a tool with
no scope would read every row of every model. Every read the tools make passes
that relation, and frames and panes are found through it too, so a caller can
change only what it can see.
Build the tools where you know who is asking, which for an MCP server is when a tool is called. They are cheap to construct.
What they do
| Tool | Does | Needs write: true |
|---|---|---|
describe_vocabulary |
The models, measures, dimensions, renderers and granularities the janela blocks declare. An agent should offer nothing else. |
no |
list_frames |
The frames the scope returns: id, name, owner, pane count. | no |
get_frame |
One frame with its panes in position order. | no |
read_pane |
A pane’s values, through the scope. Filters are Ransack predicates on declared dimensions, bounded as a reader’s are (ADR 025). | no |
add_pane |
Add a pane to the end of a frame. | yes |
update_pane |
Change a pane. An invalid change leaves it as it was. | yes |
remove_pane |
Remove a pane and close the gap. | yes |
move_pane |
Move a pane one place up or down. | yes |
update_frame |
Change a frame’s columns and gap. Its name, key and owner are not an agent’s to change. |
yes |
The write tools are off unless you build the tools with write: true. Their
inputs are the pane’s own attributes, the ones the README
names, and nothing else: an attribute that is not a pane’s is refused rather
than dropped. A pane Janela would refuse is refused with its own reason, for
example Measure "nonsense" is not a measure of Order.
There is no tool to create a frame or take a snapshot, and none to change who owns
one. Your code finds or makes a frame with Janela::Frame.for (ADR 041), and a snapshot needs an owner and a
scope you have named (ADR 033, ADR 034).
Registering them
Janela::Tools.all is the list of definitions, and needs no scope: each has a
name, a description, an input_schema (JSON Schema) and read_only.
tools.call(name, arguments) runs one and returns a Hash, or raises a
Janela::Error (NotFound, BadRequest or Unscoped) that an adapter reports
to the agent as the tool’s own error.
For the official Ruby SDK, the mcp gem, an adapter looks like this. It is run
by Janela’s own tests, so it cannot drift from the code. server_context is
whatever your server was built with, and is where you keep the caller; the
scope is built from it at the moment of the call.
def janela_server(write: false)
tools = Janela::Tools.all(write: write).map do |tool|
MCP::Tool.define(name: tool.name, description: tool.description, input_schema: tool.input_schema,
annotations: { read_only_hint: tool.read_only }) do |server_context:, **arguments|
# The scope is built per call, because who is asking is known only now.
scope = ->(model) { server_context[:scope].call(model) }
result = Janela::Tools.new(scope: scope, write: write).call(tool.name, arguments)
MCP::Tool::Response.new([ { type: "text", text: result.to_json } ])
rescue Janela::Error => error
MCP::Tool::Response.new([ { type: "text", text: error.message } ], error: true)
end
end
MCP::Server.new(name: "janela", tools: tools, server_context: { scope: yield })
end
mcp is not a dependency of Janela, and a host using another library writes the
same few lines against it.
What an agent cannot reach
A server run with bin/janela-mcp reads and changes the database of the
environment it starts in, which is development. It cannot reach a dashboard on a
deployed host: that needs an authenticated path only your application can give,
because Janela serves no endpoint and does not know who is calling (ADR 053).
What Janela does not do
It does not serve MCP, detect a server, edit your configuration or install anything. It holds no credentials and has no idea who your agent is. Whether an agent may reach your MCP server at all is your server’s authentication, which Janela never sees. Everything it adds is the question it already asks: what may this caller read?