TL;DR
- A Rovo-backed context agent is feasible with read-only tools: atlassianmcp_getjiraissue, atlassianmcp_getjiraissueremoteissuelinks, atlassianmcp_getteamworkgraphcontext, atlassianmcp_getconfluencepage, and atlassianmcp_searchconfluenceusingcql cover the issue, its links, and the pages it references.
- The Atlassian Rovo MCP connector exposes 39 tools, 15 of which write. A context brief needs four. allowed_tools in the Claude Agent SDK pre-approves calls; it does not remove tools from the session, so the capability ceiling has to come from what you register with @tool plus disallowed_tools, not from an allowlist.
- Effective read access is the intersection of five enforcement planes: the org-level Rovo Permissions tab, the domain allowlist, the IP allowlist, the per-user OAuth 2.1 grant, and object-level controls (Jira issue security levels, Confluence space permissions and page restrictions). Four of the five sit outside your code.
- Jira returns 404 for an issue the requesting engineer cannot see, not 403. A wrapper that maps 404 to "no such issue" turns an authorization boundary into a silent content gap, which is the worst failure this agent can produce.
- cloudId is a model-visible parameter on almost every Rovo tool and Rovo tokens are not bound to a site. Resolve it once per engineer from atlassianmcp_getaccessibleatlassianresources, then inject it in your wrapper so the model never selects an Atlassian instance.
- Scalekit's atlassianmcp connector runs the OAuth 2.1 Dynamic Client Registration (DCR) flow, vaults the per-engineer token, refreshes it, and resolves it at call time from an identifier, so the agent process holds no Atlassian credential and every execute_tool call carries one engineer's identity.
An engineer picks up PAY-2841, "Idempotency keys on refund retries." Before writing a line of code they read the issue, three linked issues (PAY-2790, PAY-2802, RISK-411), and two Confluence pages: the payments retry contract and the refund state machine. That is 25 minutes of tab-switching, and it happens again for every ticket, for every engineer, every sprint.
So you build it. Scalekit's Atlassian Rovo MCP connector for delegated access, the Claude Agent SDK for the loop, one prompt: "given an issue key, return a consolidated context brief." It works on your machine on the first try, because you are an org admin with access to every project and every space.
Then Maya runs it.
What breaks the second time someone runs it
Maya is on the Payments team and is not a member of the SEC space. Two things can happen, and both are wrong.
Over-read. If the agent runs on a shared credential (a service account API token, or your own OAuth grant reused for the whole team), the brief includes the security-review page from SEC that Maya cannot open in a browser. The agent did not escalate anything; it simply ran as an identity with broader access than the person reading the output. atlassianmcp_atlassianuserinfo resolves to the integration identity, so anything the agent derives from "the current user" describes the bot, not Maya.
Silent under-read. If the agent runs as Maya, the retry-contract page returns 404 and the linked RISK-411 returns 404. Jira and Confluence deliberately do not distinguish "does not exist" from "you cannot see it," because leaking existence is itself a disclosure. A naive wrapper logs "not found," drops the entry, and the brief reads as if PAY-2841 had no retry contract. Maya then writes code against a contract she never saw and had no signal was missing.
Both failures have the same root: identity and tool surface were treated as setup details instead of as the design.
Recommended reading: Access Control for Multi-Tenant AI Agents covers why the shared-credential shortcut survives the demo and dies in the second tenant.
Five planes decide what the agent can read, and you own one
Rovo MCP is not a thin proxy over the Jira and Confluence REST APIs. Atlassian layered its own admin plane on top, and it takes precedence over older controls. Before you write a wrapper, know which plane will deny you and with what.
Rovo MCP Permissions tab (Read / Write / Search groups, per app)
Access denied: Your organization admin has not authorized the permission
Consent never completes for your redirect URI
You don't have permission to connect from this IP address
Token issuance and scope check
401, often reported as a scope mismatch
Object-level controls (issue security level, space permission, page restriction)
404 on Jira issues, empty results on CQL
Atlassian's Rovo MCP permission docs state that permissions configured on the Permissions tab take precedence over Connected Apps and individual Marketplace app permissions, and that each tool inherits the access of its parent permission group. That has a consequence worth internalizing: an admin can revoke your agent's Confluence read group without touching your OAuth scopes, and your token will still validate.
Two community threads show what that drift looks like from inside a running agent. In Rovo MCP: all Confluence tools return 401 "scope does not match", every Confluence tool fails while getAccessibleAtlassianResources keeps succeeding, and the generic Rovo search tool returns 403 where the rest return 401. In Rovo MCP tools/call fails with a generic error, the resolution is an admin setting Read and Search to Allowed on the Permissions tab, not a code change.
The one plane you own is the tool surface: which of the 39 connector tools exist in the session, and what arguments the model is allowed to choose. That is where the rest of this build spends its effort.
One grant, one connection, per-user connected accounts
The Rovo connector uses OAuth 2.1 with Dynamic Client Registration (DCR), so there is no Atlassian OAuth app to create and no client_id or client_secret to paste. Per the Atlassian Rovo MCP connector docs, setup is one dashboard step plus one Atlassian admin step.
- In the Scalekit dashboard, open AgentKit > Connections > Create Connection, find Atlassian Rovo MCP, click Create, and copy the redirect URI (https://<SCALEKIT_ENVIRONMENT_URL>/sso/v1/oauth/<CONNECTION_ID>/callback).
- In admin.atlassian.com, go to Rovo > Rovo access > Rovo MCP server > Domains, click Add domain, and paste that redirect URI. Org admin access is required.
- On the Permissions tab of the same screen, confirm Read and Search are allowed for Jira and Confluence. A read-only brief needs neither Write group.
The connection_name you use in code must match the dashboard connection name exactly, including any suffix added at creation. A mismatch is the most common integration error and it surfaces as a tool-not-found rather than an auth failure.
python3 -m venv .venv && source .venv/bin/activate
npm install -g @anthropic-ai/claude-code # the Agent SDK drives the Claude Code runtime
pip install claude-agent-sdk scalekit-sdk-python python-dotenv
# access.py
import os
from dataclasses import dataclass
from scalekit import ScalekitClient
# Must match the connection name in AgentKit > Connections exactly.
CONNECTION_NAME = "atlassianmcp"
# Healthy connected accounts report one of these; log the raw value on first
# run if your environment returns a different label.
ACTIVE_STATUSES = {"ACTIVE", "CONNECTED_ACCOUNT_STATUS_ACTIVE"}
scalekit = ScalekitClient(
env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"],
client_id=os.environ["SCALEKIT_CLIENT_ID"],
client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],
)
@dataclass(frozen=True)
class ConnectionState:
"""Result of the pre-run gate. `reauth_link` is None only when ready is True."""
ready: bool
status: str
reauth_link: str | None
def check_connection(identifier: str) -> ConnectionState:
"""Fail closed before the agent starts, with a link the engineer can act on.
get_connected_account_details returns metadata only; it never returns the
access or refresh token, so this check is safe to run on a request path.
"""
details = scalekit.actions.get_connected_account_details(
connection_name=CONNECTION_NAME,
identifier=identifier,
)
raw = details.connected_account.status
status = getattr(raw, "name", str(raw)).upper()
if status in ACTIVE_STATUSES:
return ConnectionState(ready=True, status=status, reauth_link=None)
# Expired, revoked, or never authorized: hand back a consent link rather
# than letting the first tool call fail with an opaque 401.
link = scalekit.actions.get_authorization_link(
connection_name=CONNECTION_NAME,
identifier=identifier,
)
return ConnectionState(ready=False, status=status, reauth_link=link.link)
identifier is your application's stable user key, resolved server-side from the engineer's session. It never arrives from a prompt, a tool argument, or a client-supplied header. Scalekit resolves the vaulted Atlassian token from that identifier at call time, which is what keeps Maya's run scoped to Maya.
Pin the site before the model can pick one
Almost every Rovo tool takes a cloudId, and Rovo tokens are not bound to one Atlassian site. That combination is convenient for cross-site workflows and dangerous for agents, because the site becomes a parameter the model can get wrong.
Resolve it once, per engineer, per site, and keep it out of the model's reach.
# access.py (continued)
_CLOUD_ID_CACHE: dict[tuple[str, str], str] = {}
def tool_payload(result):
"""Normalise ExecuteToolResponse: collection tools return a list under
.data, single-object tools return a dict. Do this once, at the boundary."""
return getattr(result, "data", result)
def resolve_cloud_id(identifier: str, site_url: str) -> str:
"""Map one engineer plus one site URL to a cloudId, then cache it.
Selecting by exact site URL is what stops a two-site engineer from getting
a brief assembled out of the wrong Atlassian instance.
"""
key = (identifier, site_url.rstrip("/").lower())
if key in _CLOUD_ID_CACHE:
return _CLOUD_ID_CACHE[key]
result = scalekit.actions.execute_tool(
tool_input={},
tool_name="atlassianmcp_getaccessibleatlassianresources",
connection_name=CONNECTION_NAME,
identifier=identifier,
)
sites = tool_payload(result) or []
for site in sites:
if str(site.get("url", "")).rstrip("/").lower() == key[1]:
_CLOUD_ID_CACHE[key] = site["id"]
return site["id"]
# Fail loudly. Falling back to sites[0] is how a brief gets built from the
# wrong tenant's Jira.
raise LookupError(
f"{identifier} has no accessible Atlassian site matching {site_url}. "
f"Reachable: {[s.get('url') for s in sites]}"
)
Four tools, not thirty-nine
Discovery comes before execution. list_scoped_tools returns the tools the current engineer's connected account is authorized to call, filtered to one connection and one explicit name list. It is not an exploration step; it is an assertion that the surface you designed is the surface this engineer actually has, run before the loop rather than discovered inside it.
# preflight.py
from scalekit.v1.tools.tools_pb2 import ScopedToolFilter
from access import CONNECTION_NAME, scalekit
REQUIRED_TOOLS = (
"atlassianmcp_getaccessibleatlassianresources",
"atlassianmcp_getjiraissue",
"atlassianmcp_getjiraissueremoteissuelinks",
"atlassianmcp_getteamworkgraphcontext",
"atlassianmcp_getconfluencepage",
"atlassianmcp_searchconfluenceusingcql",
)
def assert_authorized_surface(identifier: str) -> set[str]:
"""Return the authorized subset, and name what is missing.
A missing tool here is an admin-plane problem (Permissions tab, scope
group), not a code problem, so surface it before the agent starts.
"""
response = scalekit.tools.list_scoped_tools(
identifier,
filter=ScopedToolFilter(
connection_names=[CONNECTION_NAME],
tool_names=list(REQUIRED_TOOLS),
),
page_size=50,
)
# ScopedTool wraps a ToolDefinition, same path as the Node SDK.
authorized = {entry.tool.definition.name for entry in response.tools}
missing = set(REQUIRED_TOOLS) - authorized
if missing:
raise PermissionError(
"Rovo tools not authorized for this connected account: "
+ ", ".join(sorted(missing))
)
return authorized
Scope matters here for a reason that is not only security. The connector publishes 39 tools including atlassianmcp_createjiraissue, atlassianmcp_transitionjiraissue, atlassianmcp_updateconfluencepage, and atlassianmcp_addworklogtojiraissue. Handing that catalog to the model costs tokens on every turn and degrades selection accuracy, which is why Atlassian's Rovo MCP v2 moved dozens of tools behind lazy-loaded discover and execute wrappers and reported a context reduction of more than 50%. Surface reduction is the lever. Model upgrades help; they are not the lever.
Now the part that senior engineers get wrong on this SDK. The Claude Agent SDK documents allowed_tools as tools to auto-approve without prompting, and states plainly that it does not restrict Claude to only these tools; unlisted tools fall through to permission_mode and can_use_tool. So an allowlist is not a ceiling. The ceiling is the set of tools you register with @tool, plus disallowed_tools for the built-ins.
Four wrappers, one identity, cloudId injected, arguments validated.
# tools.py
import json
import re
from dataclasses import dataclass
from claude_agent_sdk import ToolAnnotations, tool
from access import CONNECTION_NAME, scalekit, tool_payload
ISSUE_KEY = re.compile(r"^[A-Z][A-Z0-9_]{1,19}-\d{1,8}$")
PAGE_ID_IN_URL = re.compile(r"/pages/(\d+)")
SAFE_QUERY = re.compile(r"[^A-Za-z0-9 ._\-/]")
MAX_PAGE_CHARS = 12_000 # a single Confluence page can exceed a context window
MAX_LINKS = 20
MAX_SEARCH_HITS = 5 # Atlassian's own guidance caps JQL and CQL result sets
BASE_FIELDS = [
"summary", "status", "issuetype", "priority", "assignee",
"description", "labels", "parent", "issuelinks",
]
@dataclass(frozen=True)
class RunContext:
"""Bound once per run. identifier comes from your server-side session."""
identifier: str
cloud_id: str
site_url: str
default_space_key: str
acceptance_criteria_field: str | None = None # e.g. "customfield_10038"
def _ok(text: str) -> dict:
return {"content": [{"type": "text", "text": text}]}
def _err(text: str) -> dict:
# The documented error shape for @tool handlers. Note that GitHub issue
# #247 tracks is_error not surfacing on ToolResultBlock, so do not build
# telemetry on that field; log inside the wrapper instead.
return {"content": [{"type": "text", "text": text}], "is_error": True}
def _call(ctx: RunContext, tool_name: str, tool_input: dict):
"""Every Rovo call goes through here: one identity, one site, one audit id."""
result = scalekit.actions.execute_tool(
tool_input={"cloudId": ctx.cloud_id, **tool_input},
tool_name=tool_name,
connection_name=CONNECTION_NAME,
identifier=ctx.identifier,
)
# execution_id is the audit anchor tying this Atlassian read to this engineer.
return tool_payload(result), getattr(result, "execution_id", None)
def build_tools(ctx: RunContext) -> list:
"""Construct a fresh tool set closed over one RunContext.
Binding identity at construction time, rather than reading it from module
state inside the handler, is what keeps two concurrent runs from sharing an
identifier. If you reuse a long-lived server instead, carry the context in a
ContextVar set before the client is created.
"""
@tool(
"get_ticket",
"Fetch one Jira issue by key: summary, status, type, priority, assignee, "
"description, labels, parent, and its Jira-side issue links.",
{"issue_key": str},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def get_ticket(args: dict) -> dict:
key = str(args.get("issue_key", "")).strip().upper()
if not ISSUE_KEY.match(key):
return _err(f"'{key}' is not a Jira issue key. Expected form: PAY-2841.")
fields = list(BASE_FIELDS)
if ctx.acceptance_criteria_field:
# Resolved once per project, not guessed per run. Find the id with
# atlassianmcp_getjiraissuetypemetawithfields and
# requiredFieldsOnly=false, then pin it in config.
fields.append(ctx.acceptance_criteria_field)
try:
data, _ = _call(ctx, "atlassianmcp_getjiraissue", {
"issueIdOrKey": key,
"fields": fields,
# markdown, not ADF: Atlassian Document Format JSON in context
# costs several times the tokens for the same prose.
"responseContentFormat": "markdown",
})
except Exception as exc:
message = str(exc)
if "404" in message:
# Do not translate this to "no such issue". Jira returns 404 for
# issues hidden by an issue security level.
return _err(
f"{key} is not visible to this engineer, or does not exist. "
"Record it as unreachable; do not infer its contents."
)
return _err(f"Jira read failed for {key}: {message}")
return _ok(json.dumps(data, ensure_ascii=False))
@tool(
"list_context_links",
"List everything linked to a Jira issue across three surfaces: Jira issue "
"links, remote web links on the issue, and Teamwork Graph relationships. "
"Returns a coverage flag naming any surface that did not answer.",
{"issue_key": str},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def list_context_links(args: dict) -> dict:
key = str(args.get("issue_key", "")).strip().upper()
if not ISSUE_KEY.match(key):
return _err(f"'{key}' is not a Jira issue key.")
links: list[dict] = []
degraded: list[str] = []
# Surface 1: Jira's own issue links, already on the issue payload.
try:
data, _ = _call(ctx, "atlassianmcp_getjiraissue", {
"issueIdOrKey": key,
"fields": ["issuelinks"],
"responseContentFormat": "markdown",
})
for link in (data.get("fields", {}) or {}).get("issuelinks", []) or []:
other = link.get("outwardIssue") or link.get("inwardIssue") or {}
if other.get("key"):
links.append({
"kind": "jira",
"ref": other["key"],
"title": (other.get("fields", {}) or {}).get("summary"),
"relation": (link.get("type", {}) or {}).get("name"),
"via": "issuelinks",
})
except Exception as exc:
degraded.append(f"issuelinks: {exc}")
# Surface 2: remote links, where Confluence URLs usually live.
try:
data, _ = _call(ctx, "atlassianmcp_getjiraissueremoteissuelinks", {
"issueIdOrKey": key,
})
for remote in data or []:
obj = remote.get("object", {}) or {}
if obj.get("url"):
links.append({
"kind": "confluence" if "/wiki/" in obj["url"] else "web",
"ref": obj["url"],
"title": obj.get("title"),
"relation": "remote-link",
"via": "remotelinks",
})
except Exception as exc:
degraded.append(f"remotelinks: {exc}")
# Surface 3: Teamwork Graph. Beta, and gated by the Search permission
# group, so treat its absence as partial coverage rather than an error.
try:
data, _ = _call(ctx, "atlassianmcp_getteamworkgraphcontext", {
"objectIdentifier": key,
"objectType": "JiraWorkItem",
"targetObjectTypes": ["ConfluencePage", "JiraWorkItem"],
"detailLevel": "MINIMAL",
"first": MAX_LINKS,
})
for node in (data or {}).get("nodes", []) or []:
ref = node.get("url") or node.get("id")
if ref:
links.append({
"kind": "confluence" if node.get("type") == "ConfluencePage" else "jira",
"ref": ref,
"title": node.get("title"),
"relation": node.get("relationshipType"),
"via": "teamworkgraph",
})
except Exception as exc:
degraded.append(f"teamworkgraph: {exc}")
# Dedupe on ref, first writer wins, then bound the fan-out.
seen: set[str] = set()
unique = []
for link in links:
if link["ref"] not in seen:
seen.add(link["ref"])
unique.append(link)
return _ok(json.dumps({
"links": unique[:MAX_LINKS],
"coverage": "partial" if degraded else "complete",
"degraded_surfaces": degraded,
}, ensure_ascii=False))
@tool(
"read_confluence_page",
"Read one Confluence page by numeric page id or by its full page URL. "
"Returns markdown, truncated, with a flag when truncation happened.",
{"page_ref": str},
annotations=ToolAnnotations(readOnlyHint=True, maxResultSizeChars=MAX_PAGE_CHARS),
)
async def read_confluence_page(args: dict) -> dict:
ref = str(args.get("page_ref", "")).strip()
if not ref:
return _err("page_ref is required: a numeric page id or a page URL.")
page_id = ref if ref.isdigit() else None
if page_id is None:
match = PAGE_ID_IN_URL.search(ref)
page_id = match.group(1) if match else None
try:
if page_id:
data, _ = _call(ctx, "atlassianmcp_getconfluencepage", {
"pageId": page_id,
"contentFormat": "markdown",
})
else:
# Short links and ARIs have no page id to parse; fetch resolves
# any Atlassian object from its URL or ARI.
data, _ = _call(ctx, "atlassianmcp_fetch", {"id": ref})
except Exception as exc:
message = str(exc)
if "404" in message or "403" in message:
return _err(
f"{ref} is restricted or absent for this engineer. Record it "
"as unreachable and name it in the brief."
)
return _err(f"Confluence read failed for {ref}: {message}")
body = json.dumps(data, ensure_ascii=False)
truncated = len(body) > MAX_PAGE_CHARS
return _ok(json.dumps({
"page_ref": ref,
"truncated": truncated,
"body": body[:MAX_PAGE_CHARS],
}, ensure_ascii=False))
@tool(
"search_docs",
"Search Confluence pages by keywords when a ticket names a document "
"without linking it. Searches one space and returns at most five hits.",
{"query": str},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def search_docs(args: dict) -> dict:
raw = str(args.get("query", "")).strip()
terms = SAFE_QUERY.sub(" ", raw).strip()
if len(terms) < 3:
return _err("query must be at least three usable characters.")
# The model supplies terms; the wrapper writes the CQL. Letting a model
# author query language is both a correctness and an injection surface.
cql = (
f'type = page AND space = "{ctx.default_space_key}" '
f'AND text ~ "{terms}"'
)
try:
data, _ = _call(ctx, "atlassianmcp_searchconfluenceusingcql", {
"cql": cql,
"limit": MAX_SEARCH_HITS,
})
except Exception as exc:
return _err(f"Confluence search failed: {exc}")
return _ok(json.dumps({"cql": cql, "results": data}, ensure_ascii=False))
return [get_ticket, list_context_links, read_confluence_page, search_docs]
atlassianmcp_getjiraissue
Pins cloudId, forces markdown over ADF, converts 404 into an explicit visibility statement
getjiraissue, getjiraissueremoteissuelinks, getteamworkgraphcontext
Merges three link surfaces, dedupes, and reports partial coverage instead of a short list
Accepts ids or URLs, truncates the body, distinguishes restriction from absence
Builds and escapes the CQL so the model never writes query language
The brief is a schema, not a paragraph
A brief that reads well and omits an unreachable page is worse than no brief. Bind the output to a schema whose required fields include what the agent could not see, and let the SDK validate it.
# schema.py
BRIEF_SCHEMA = {
"type": "object",
"additionalProperties": False,
"required": [
"issue_key", "summary", "status", "acceptance_criteria",
"acceptance_criteria_source", "linked_issues", "documents",
"unreachable", "coverage",
],
"properties": {
"issue_key": {"type": "string"},
"summary": {"type": "string"},
"status": {"type": "string"},
"acceptance_criteria": {"type": "array", "items": {"type": "string"}},
"acceptance_criteria_source": {
"type": "string",
"enum": ["description", "custom_field", "not_found"],
},
"linked_issues": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["key", "relation", "retrieved"],
"properties": {
"key": {"type": "string"},
"relation": {"type": "string"},
"summary": {"type": "string"},
"retrieved": {"type": "boolean"},
},
},
},
"documents": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["ref", "retrieved"],
"properties": {
"ref": {"type": "string"},
"title": {"type": "string"},
"why_relevant": {"type": "string"},
"retrieved": {"type": "boolean"},
"truncated": {"type": "boolean"},
},
},
},
# The payoff. An empty array is a claim; a populated one is a warning.
"unreachable": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["ref", "reason"],
"properties": {
"ref": {"type": "string"},
"reason": {
"type": "string",
"enum": ["restricted_or_absent", "read_failed", "budget_exceeded"],
},
},
},
},
"coverage": {"type": "string", "enum": ["complete", "partial"]},
},
}
The loop, with the ceiling set in code
The Claude Agent SDK runs the reasoning: which links to follow, when to fall back to search_docs, when the brief is complete. Everything that decides capability is set on ClaudeAgentOptions before the first turn.
# agent.py
import asyncio
from claude_agent_sdk import (
ClaudeAgentOptions,
ClaudeSDKClient,
HookMatcher,
ResultMessage,
create_sdk_mcp_server,
)
from access import check_connection, resolve_cloud_id
from preflight import assert_authorized_surface
from schema import BRIEF_SCHEMA
from tools import RunContext, build_tools
SURFACE = (
"mcp__rovo__get_ticket",
"mcp__rovo__list_context_links",
"mcp__rovo__read_confluence_page",
"mcp__rovo__search_docs",
)
SYSTEM_PROMPT = """You assemble a pre-work context brief for one Jira issue.
Sequence: call get_ticket for the issue, then list_context_links. Read at most
four documents with read_confluence_page, choosing the ones the issue actually
depends on. Use search_docs only when the ticket names a document that no link
resolves to.
Rules that override helpfulness:
- Never infer the contents of anything you could not read. Put it in
`unreachable` with the reason the tool gave you.
- A tool reporting a page as restricted or absent is not evidence the document
does not exist. Set `coverage` to "partial" whenever `unreachable` is not empty.
- Acceptance criteria come only from the issue description or the configured
acceptance-criteria field. If neither carries them, set
`acceptance_criteria_source` to "not_found" and leave the array empty.
- Do not attempt writes. You have no write tools.
"""
async def audit_gate(input_data: dict, tool_use_id: str | None, context) -> dict:
"""Deny-by-default gate plus the per-call audit line.
An empty HookMatcher fires for every tool, so this also catches anything a
future config change adds to the session. can_use_tool would not: it runs
only when the permission flow falls through to a prompt, and allowlisted
calls never reach it.
"""
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {}) or {}
if tool_name not in SURFACE:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"{tool_name} is outside the read-only context surface."
),
}
}
# Emit to your audit sink. Pair this with the execution_id returned by
# execute_tool to tie the Atlassian read to the engineer who authorized it.
print({
"event": "tool_call",
"tool_use_id": tool_use_id,
"tool": tool_name,
"args": {k: v for k, v in tool_input.items() if k != "query"},
})
return {} # empty output allows the call
async def build_brief(identifier: str, site_url: str, space_key: str, issue_key: str):
state = check_connection(identifier)
if not state.ready:
return {"needs_authorization": True, "link": state.reauth_link, "status": state.status}
assert_authorized_surface(identifier)
cloud_id = resolve_cloud_id(identifier, site_url)
ctx = RunContext(
identifier=identifier,
cloud_id=cloud_id,
site_url=site_url,
default_space_key=space_key,
acceptance_criteria_field=None, # pin the customfield id once per project
)
# One server instance per run, closed over one identity.
server = create_sdk_mcp_server(
name="rovo-context",
version="1.0.0",
tools=build_tools(ctx),
)
options = ClaudeAgentOptions(
model="sonnet",
mcp_servers={"rovo": server},
# Pre-approves these four so an unattended run does not stall on a prompt.
# It does not make them the only tools in the session.
allowed_tools=list(SURFACE),
# Availability control. A bare name removes the tool from Claude's context.
disallowed_tools=["Bash", "Read", "Write", "Edit", "WebFetch", "WebSearch"],
# Deny anything not pre-approved instead of prompting.
permission_mode="dontAsk",
# Ignore ~/.claude/settings.json, .claude/settings.json, and
# .claude/settings.local.json. On Python SDK 0.1.59 and earlier an empty
# list was treated as omitted, so check your version.
setting_sources=[],
# Ignore project .mcp.json, plugin servers, and claude.ai connectors.
strict_mcp_config=True,
system_prompt=SYSTEM_PROMPT,
max_turns=14,
output_format={"type": "json_schema", "schema": BRIEF_SCHEMA},
hooks={"PreToolUse": [HookMatcher(matcher="", hooks=[audit_gate])]},
)
async with ClaudeSDKClient(options=options) as client:
# Assert the live surface. Do not infer it from the options you passed.
status = await client.get_mcp_status()
for entry in status["mcpServers"]:
print(entry["name"], entry["status"], [t["name"] for t in entry.get("tools", [])])
await client.query(f"Build the context brief for {issue_key}.")
brief = None
async for message in client.receive_response():
if isinstance(message, ResultMessage):
brief = {
"structured_output": message.structured_output,
"coverage_turns": message.num_turns,
"terminal_reason": message.terminal_reason,
"permission_denials": message.permission_denials,
"cost_usd": message.total_cost_usd,
}
return brief
if __name__ == "__main__":
print(asyncio.run(build_brief(
identifier="maya@acme.com",
site_url="https://acme.atlassian.net",
space_key="ENG",
issue_key="PAY-2841",
)))
permission_denials on ResultMessage is the assertion to keep in your tests: a run where the model tried to reach outside the four tools should show up there, and a deployment that silently widened the surface will show an empty list where you expected an entry.
Recommended reading: How to implement least privilege for AI agent tool calls and token-efficient tool calling.
The failures that show up in week two
What the wrapper should do
Issue hidden by an issue security level
404 from atlassianmcp_getjiraissue
Return "not visible or absent"; record in unreachable, never as "no such issue"
Confluence page restriction
404 or 403, or a CQL result set that is short rather than empty
Record the specific ref; set coverage to partial
Admin revokes the Confluence Read group
401 reported as a scope mismatch, while getaccessibleatlassianresources keeps working
Fail the preflight in assert_authorized_surface, not the fourth tool call
Refresh token revoked (engineer disconnected, offboarding)
Connected account leaves ACTIVE
Return the get_authorization_link URL; do not retry the tool
Two Atlassian sites for one engineer
Wrong site's data in the brief, no error
Match on exact site URL in resolve_cloud_id; raise rather than take sites[0]
IP allowlist blocks the runtime
You don't have permission to connect from this IP address on every call
Treat as infrastructure, not auth; allowlist the agent's egress range
Confluence body larger than the context budget
Silent truncation or a blown turn
MAX_PAGE_CHARS in the wrapper plus maxResultSizeChars on the annotation
is_error not reflected on ToolResultBlock
Telemetry shows every call as successful
Log outcomes inside the wrapper; do not read ToolResultBlock.is_error
Stream closed on in-process MCP calls after roughly 70 seconds
Reported on Python SDK 0.1.47 with bundled CLI 2.1.70
Keep each wrapper fast, bound the fan-out with MAX_LINKS, and pin SDK versions
The pattern across the first four rows: every one of them is an authorization event that arrives looking like missing data. That is why unreachable is a required field in the schema rather than an optional nicety.
A second tenant moves the ceiling out of your process
Everything above puts the capability ceiling inside your Python process. That is the right call while the surface is four read tools and argument control is the correctness lever. It stops being the right call when the same agent role needs write tools, or when a customer's security team asks where the ceiling is enforced and the answer is "in our application code."
Scalekit's Virtual MCP Servers move it. You declare which connections and which tools exist for an agent role once, then mint a short-lived session token per run, scoped to one engineer's connected accounts.
# virtual_mcp.py
from datetime import timedelta
from access import scalekit
MCP_CONFIG_ID = "cfg_01abc123" # from actions.mcp.create_config, once per agent role
def rovo_mcp_server(identifier: str, mcp_server_url: str) -> dict:
"""Return an McpHttpServerConfig for ClaudeAgentOptions.mcp_servers.
The endpoint is static across every engineer; the identity is not. The
ceiling lives in the config's connection_tool_mappings, enforced server-side.
"""
# Fail closed with an actionable link before the run starts.
state = scalekit.actions.mcp.list_mcp_connected_accounts(
config_id=MCP_CONFIG_ID,
identifier=identifier,
include_auth_link=True,
)
for account in state.connected_accounts:
if account.connected_account_status != "active":
raise PermissionError(
f"{account.connection_name} needs authorization: "
f"{account.authentication_link}"
)
session = scalekit.actions.mcp.create_session_token(
mcp_config_id=MCP_CONFIG_ID,
identifier=identifier,
expiry=timedelta(minutes=15),
)
return {
"type": "http",
"url": mcp_server_url,
"headers": {"Authorization": f"Bearer {session.token}"},
}
The tradeoff is real and worth stating: routing through the hosted endpoint gives up the deterministic wrapper. cloudId becomes a model-chosen parameter again, page bodies arrive whole, and the model writes its own CQL. For a read-only brief, keep the in-process wrappers. Reach for Virtual MCP when the ceiling needs to be auditable outside your deployment, and accept that you then owe the model tighter instructions in place of tighter arguments.
Three rules carry over regardless of which path you pick:
- identifier is derived server-side from the engineer's session. Never from a prompt, a tool argument, or a client header.
- One cloudId per identifier per site, resolved before the loop. Cross-tenant Jira reads require per-tenant authorization; there is no shortcut.
- Every Atlassian read carries an execution_id. Join it to your tool_use_id audit line so a compliance question about who read RISK-411 has a single answer.
Recommended reading: Single vs multi-tenant tool calling, and audit trails for agent auth.
FAQs
Should I use the Rovo MCP connector or the separate Jira and Confluence connectors?
Rovo gives one grant, one connection, and Teamwork Graph relationships that the REST connectors do not expose, which is why it fits a cross-product context brief. The REST connectors give deeper tool coverage per product and no dependency on Atlassian's admin plane for the MCP server. The Atlassian Rovo MCP vs Atlassian API breakdown covers the decision, and Build a Jira and Confluence context agent with the Claude Agent SDK builds the same agent on the REST path.
Does allowed_tools stop the agent calling anything else?
No. It auto-approves the named tools so an unattended run does not stall. Unlisted tools fall through to permission_mode and can_use_tool. Use disallowed_tools with bare names to remove built-ins from context, and register only the tools you want to exist.
Why does the brief come back thin for one engineer and complete for another?
Object-level controls. Jira issue security levels and Confluence space permissions and page restrictions are evaluated against the requesting engineer's own token, which is the correct behavior; what the user cannot do, the agent cannot do. The bug is a brief that does not say so. That is what the unreachable array and the coverage flag are for.
Can I run this on an Atlassian API token instead of per-user OAuth?
Only if an org admin enables API token authentication, and it changes your tool surface: Jira Service Management and Bitbucket tools are reachable only on API token sessions, Compass tools only on OAuth 2.1, and API token sessions are not bound to a cloudId. It also collapses every engineer's reads onto one identity, which is exactly the over-read failure this build exists to avoid.
Do I need to create an Atlassian OAuth app?
No. The connector uses Dynamic Client Registration (DCR). You copy the Scalekit redirect URI into Rovo > Rovo access > Rovo MCP server > Domains as an allowed domain, and Scalekit registers the client.
How do I stop Confluence pages from eating the context window?
Three controls, in order of reliability: truncate in the wrapper (MAX_PAGE_CHARS), set maxResultSizeChars on the tool annotation so Claude Code offloads oversized results instead of inlining them, and cap search result sets at five per Atlassian's own guidance for JQL and CQL.
Next steps to build the ticket-context agent
- Create the Atlassian Rovo MCP connection in AgentKit > Connections, copy the redirect URI, and add it under Rovo > Rovo access > Rovo MCP server > Domains in Atlassian admin. Confirm Read and Search are allowed for Jira and Confluence on the Permissions tab.
- Run get_authorization_link for your own identifier, complete consent, then call atlassianmcp_getaccessibleatlassianresources and record the cloudId for your site.
- Resolve your project's acceptance-criteria field once with atlassianmcp_getjiraissuetypemetawithfields and requiredFieldsOnly set to false, then pin the id in RunContext.
- Register the four wrappers, start the client, and print get_mcp_status() before the first query to confirm the live tool list matches the four.
- Run the same issue key for a second engineer whose project and space access differs from yours, and diff the unreachable arrays. If they are identical, your identity plumbing is not per-user yet.
- Extend the same pattern with the tools in the connector catalog, the Python SDK reference, and the Anthropic code samples. Related builds on the same two hinges: the DevOps assistant agent, the engineering standup agent, and the auto release notes agent.
Start for free or talk to an engineer.