Blog

August 15, 2026 · 15 min read

MCP Server Role Scoping: Block at Tool Registration, Not in the Prompt

Six support agents shared one MCP server, and two read-only roles held write tools. Enforcing scope where tools are registered, so the model never learns the tool exists.

  • MCP
  • AI agents
  • authorization
  • ops automation

When I extracted the tool list from the live system, I found that write tools were also included for the supervisor and metrics roles. Both roles were intended to be read-only.

The prompt stated that they were read-only. The setup was testing whether the model would follow that sentence. It was not a question to ask a model that got half of 200 trials wrong.

So I moved the boundary. Instead of blocking calls immediately before execution, I discarded names outside the role when registering the tools. A tool that does not exist cannot be called.

Where write permissions diverged

I did not count the six agents and ten operational components as the same number

JustSend Care’s agent roster has six members. 하람 (Haram) handles overall coordination, 리아 (Ria) reviews, 다온 (Daon) manages the community, 우진 (Woojin) handles email, 세나 (Sena) handles metrics, and 태산 (Taesan) handles operations. All use claude-sonnet-5, and their output language is fixed to Korean only.

The 16 that appears alongside them in the documentation is a different classification. There is a “16-role” table that combines the six agents with ten operational components: the relay, MCP server, registration gate, DB, external domains, and metrics. Sixteen is also the number of App Store launch languages. I did not combine these three figures as the number of agents.

Category Identifier Responsibility Writing behavior
Agent haram / 하람 (Haram) Overall coordination · escalation · KPI briefing Approval coordination
Agent ria / 리아 (Ria) Review responses in 16 languages · ratings Approved review responses
Agent daon / 다온 (Daon) Discourse · FAQ · reports Approved community replies
Agent woojin / 우진 (Woojin) Email tickets · response SLA Approved email sending
Agent sena / 세나 (Sena) Analytics · KPI · CSAT Reading and recording
Agent taesan / 태산 (Taesan) Cluster status · first-line incident response Approved workload actions

The ten operational components are care-buzz, care-mcp, scopedServer, care-db, App Store Connect, Discourse, Stalwart, Kubernetes, KPI, and CSAT·SLA. I did not divide them this way to make the numbers match. I divided them this way because I needed to identify each target to which permissions would be granted.

The relay runs six Deployments, and care-mcp executes stdio MCP servers through role-specific headless wrappers. In live, the review-role container has BUZZ_ACP_MCP_COMMAND=/usr/local/bin/care-mcp-reviews. All six use the same image, ghcr.io/<org>/justsend-care-agent:0.3.0. What changes are the prompt file path, wrapper name, and CARE_ROLE value.

lead and insight are read-only. Review, community, email, and cluster actions each pass through the approval boundary of their respective domain.

The absence of role gating first surfaced as write-tool leakage

In the initial design, I registered write tools for every role. Merely writing “this role must not send replies” in the prompt did not hide the registered tools. While fixing defect (5), where write tools leaked to lead and insight in production, I moved the boundary.

The tool registration point was the moment the server put a tool name into the actual MCP list. By discarding names outside the allowlist at that point, I prevented the agent prompt from receiving a target it could call to bypass the boundary.

// care-mcp/src/envelope.ts:28-42 원문
export function scopedServer(target: ToolServer, allowed: readonly string[]): ToolServer {
  const allow = new Set(allowed);
  const gate = <T extends unknown[]>(name: unknown, apply: (...args: T) => unknown, args: T): unknown => {
    if (typeof name === 'string' && !allow.has(name)) return undefined;
    return apply(...args);
  };
  return {
    registerTool: target.registerTool
      ? (name, config, handler) => gate(name, (...a: [string, Record<string, unknown>, (input: unknown) => Promise<unknown>]) => target.registerTool?.(...a), [name, config, handler])
      : undefined,

When it encounters a string not in allow, registerTool returns undefined. This function does not check permission immediately before invocation. It does not register the tool in the first place.

The tool path passes through the same gate.

// care-mcp/src/envelope.ts:36-42 원문
    tool: target.tool
      ? (...args: unknown[]) => gate(args[0], (...a: unknown[]) => target.tool?.(...a), args)
      : undefined,
  };
}

ROLE_TOOLSETS and registerRoleTools build the per-role allowlists and wrap every domain registration with const target = scopedServer(server as unknown as ToolServer, allowed);. asc_review_reply is included only in ASC_ALL and is not exposed to lead or insight.

I counted the per-role allowlists.

Role Tool count Tools that leave traces externally
lead 25 None
reviews 15 asc_review_reply
insight 12 None
community 11 discourse_post_reply
mail 10 mail_send
ops 9 cluster_restart_workload

Together, the six roles had 31 distinct tools.

The order in the table was the opposite of what I initially expected. The role with the most tools was the lead, and none of those 25 were external writes. Read-only did not mean fewer permissions. It meant having the broadest visibility and nothing it could leave behind.

The writing roles were the opposite. ops had the fewest, at nine, and one of them restarted workloads. Each role had exactly one external write tool. Counting what was opened took one line.

I registered approval_request and approval_status only for the four writing roles. Those roles could request approvals and change their status. I registered only approval_list for lead and insight.

The intent was written in a comment above the registration code.

// care-mcp/src/index.ts:47-49
// 도메인 모듈은 자기 도구 전부를 등록한다. 역할 화이트리스트를 등록 지점에서 강제하지 않으면
// lead/insight 같은 읽기 역할에 asc_review_reply·cluster_restart_workload 가 새어 나간다.
const target = scopedServer(server as unknown as ToolServer, allowed);

The domain modules did not know about roles. registerAsc attempted to register all seven ASC tools. The one line wrapping that module was what knew about the role. When adding modules, I did not need to put role checks into each module.

The method for selecting modules to register also checked names. If the allowlist contained no name beginning with discourse_, I did not call the Discourse module at all. The process for the reviews role did not create a Discourse client.

Boundary method State visible to the agent Possibility of bypass
Prompt instruction Tools appear in the list The model can attempt to call them
Check immediately before invocation Tools appear in the list Leakage during registration, description, and inference
Gate at the registration point Only allowed tools appear in the list Names outside the role are not registered

After this change, role gating matched 6/6 in production. Every tool that accepted arguments also exposed its schema. I had to examine the list and the input contract together to distinguish between “the tool does not exist” and “the tool exists but the call fails.”

I did not tie external writes and internal ledger recording to the same switch

Actions that leave traces externally are review replies, community replies, sending mail, and restarting workloads. When CARE_WRITE_ENABLED != "1", these tools return {ok:true,data:{dry_run:true,...}} instead of making actual external calls.

KPI and CSAT recording is outside this gate. I did not use the same switch to write samples or satisfaction scores to the internal DB ledger and to send replies to customers. This distinction allowed me to observe metric flows during dry runs while stopping only external writes.

Action CARE_WRITE_ENABLED != "1" Recording location
asc_review_reply Returns a dry-run envelope No external ASC call
discourse_post_reply Returns a dry-run envelope No external Discourse call
mail_send Returns a dry-run envelope No external SMTP delivery
cluster_restart_workload Returns a dry-run envelope No workload restart
KPI·CSAT recording Not subject to the gate care schema ledger

During the live check, the cluster restart ended with WRITE_DISABLED. Mail delivery also followed the dry-run gate, and I confirmed the IMAP connection, the RFC2047 subject, and preview_decoded: true. For the community, I confirmed topic 5, search 11, and category 7, then passed the locale gate in dry-run mode.

The KPI was reported as 93.33% under_target by comparing targets 4.2 and 4.5. I also confirmed the CSAT summary and SLA status, and sla_status had 0 records.

This boundary separates “opening write permission” from “actually allowing write calls.” When I reread the live container today, it showed CARE_WRITE_ENABLED=0, CARE_CLUSTER_WRITE=0. Opening these together with the approval procedure remained as the next step for starting operations.

Approval was a ledger row, not one person's reaction

Even if the gate is opened, that alone does not send anything externally. Nothing goes out without approval.

Exactly four actions require approval: asc_review_reply, discourse_post_reply, mail_send, and cluster_restart_workload. This was the same list as the four external-write tools by role.

An approval request posts a card in the channel and creates one row in the DB. That row has five possible states.

State Meaning If called in this state
pending A person has not seen it yet APPROVAL_REQUIRED
approved Approved but not yet written Passes
rejected Rejected APPROVAL_REJECTED
expired The deadline has passed APPROVAL_EXPIRED
consumed Already used once APPROVAL_CONSUMED

The reason for consumed was to prevent reuse. If I sent twice with a card approved once, the customer would receive the same reply twice. After sending, I changed the state to consumed, and added status='approved' AND consumed_at IS NULL to the condition so that only one of two concurrent processes could pass.

I also blocked changes to the body. When saving an approval request, I stored payload_hash alongside it.

// care-mcp/src/approval.ts — verifyApproval()
if (row.action !== input.action ||
    row.payload_hash !== approvalPayloadHash({ action, target, body, locale }))
  return err('APPROVAL_MISMATCH', '승인 요청의 작업 또는 본문이 일치하지 않습니다.');

The sentence the person saw had to match the sentence actually sent. If I edited the body after approval, the hash changed and it ended with APPROVAL_MISMATCH. The approval target was not “this action,” but “this sentence.”

The default deadline was 1,440 minutes. One day. It could be extended when requested, with a maximum of 10,080 minutes, or 7 days. This prevented an approval card from remaining in the channel indefinitely and being used a month later.

Approvers were the public keys in CARE_APPROVERS. If that list was empty, requesting approval itself ended with APPROVAL_REQUIRED. I did not allow the process to proceed with self-approval when there were no approvers. When determining the state, I counted only reactions from keys in the list that were not my own public key.

Fifteen error codes were defined in the envelope, five of them approval-related. A single code distinguished where the request was blocked. WRITE_DISABLED and APPROVAL_REQUIRED described different facts. The former meant the switch was closed, while the latter meant that a person had not seen it yet.

I Had to Examine MCP stdio More Strictly for Application Logs and Binary Resources

The MCP stdio transport exchanges messages framed over standard output. When the server wrote diagnostic logs to stdout, the client misinterpreted them as MCP messages. I grouped the recurring structural categories from the 16 actual defects as follows.

Defect Observation Fix
(1) ASC ES256 DER→JOSE overflow 401 on every call 32-byte alignment, 3/3 200
(2) postgres.js NOTICE on stdout MCP framing corrupted Switched to stderr
(3) schema.sql missing from compiled binary /$bunfs ENOENT, database permanently unavailable Embedded via text import
(4) Dynamic import failure Community, mail, and cluster tools all missing Static import
(5) Missing role gating Write tools leaked to lead·insight scopedServer
(6) --role argument in MCP_COMMAND 0 MCP tools Argument-free wrappers by role

The first defect involved external API authentication, while the second through sixth involved execution boundaries and packaging. For people using stdio, stdout contamination, missing single-binary resources, and dynamic loading failures could appear as different symptoms but recur in the same deployment format.

After passing --role directly to MCP_COMMAND resulted in 0 tools, I switched to argument-free wrappers for each role. The role also accepted the CARE_ROLE environment variable, but the CLI took precedence. If the role was missing or unknown, the process wrote the reason to stderr and exited with 2.

These fixes were not about changing the model. I corrected how the MCP server communicated through framing, which files it included in the binary, and under what names it registered tools. The final live result was 31 tests passing/0 failing and a successful linux-x64 compilation.

Even when the figures matched, I recorded the access scope and operational state together

The Care operations table included external systems and internal ledgers. I verified what had been entrusted to the agent through the actual tools and states rather than by name.

Area Observed live value Meaning
KPI 4.2 / 4.5, 93.33% under_target Report against the target
workload 11 ready Number of workloads ready
Cluster aggregation 5 ns, 1 postal issue Namespace and issue detection
Community 5 topics, 11 searches, 7 categories Read-tool results
Verification 31 pass / 0 fail Automated test results

These figures were not evidence that the agent had made every decision automatically. They were verification results showing what had been read, which external writes had been stopped in dry-run mode, and which database records had been retained.

In particular, even after I made lead and insight read-only, the KPI and CSAT ledgers were recorded outside the gate. Narrowing the permission boundary did not mean losing metrics; it separated external side effects from internal observability.

I looked at this status table before the feature list. Whether a tool had been registered, whether an actual call had been blocked, and whether a record had been left in the ledger were separate questions.

I Recorded the Approachable Boundaries in the Deployment Result

The live configuration consists of the care-buzz relay, six Deployments, the care-mcp stdio server, and the care-db ledger. The external domains are ASC, Discourse, Stalwart, and Kubernetes.

The scope visible to operational roles was also specified in the environment variables. CARE_KUBE_NAMESPACES contained five namespaces: justsend-care, justsend-platform, mail, postal, and monitoring. The cluster aggregation shown as 5 namespaces in the table above came from this list.

I counted the namespaces in today's cluster and found eighteen. I opened access to five of them. The omitted ones included argocd, auth, cert-manager, and kube-system: the places where deployments are changed, authentication is evaluated, certificates are issued, and the cluster itself is managed. Initial incident response required the status of product pods; people handled changes to deployments and authentication.

I could not open reads for nodes. Nodes do not belong to a namespace, so a RoleBinding could not grant access to them; a ClusterRoleBinding was required. As a result, cluster_overview returned partial success with nodes set to null and included the reason. I did not treat it as a failure; I recorded what was missing.

I also narrowed what could be answered. When I reread the live configuration today, I found that BUZZ_ACP_SUBSCRIBE had changed from mentions to config. The subscription scope was determined by role-specific TOML rule files rather than by a single environment variable. BUZZ_ACP_RESPOND_TO=allowlist remained unchanged.

The assigned channel's ordinary messages were received without a mention, while approval requests and reminders were received only when the name was called. I documented the contents of the rule file in Part 24.

CARE_APPROVERS also contained one public key. The principals allowed to approve were held in an environment variable rather than in the prompt.

Counting one line for each layer showed four.

Layer What it blocked Where it was specified
Tool registration Tools outside the role were absent from the list ROLE_TOOLSETS and scopedServer
External calls A dry-run envelope was used instead of the actual call CARE_WRITE_ENABLED
Approval principals Who could change the approval status CARE_APPROVERS
Subscription scope What could be answered Role-specific rules.toml

All four were outside the model. They were code, environment variables, and files—not sentences written in the prompt.

The server did not start if the role was missing or contained an unknown value. parseConfig wrote the reason to stderr and exited with 2. This prevented a state in which the server ran quietly with zero tools. A defect in which passing the --role argument incorrectly resulted in zero tools had been the opposite case.

In this structure, there was no place to ask the model to “not do that.” Tools outside the role were absent from the list, external writes stopped at the environment gate and became dry-runs, and KPI and CSAT data were recorded in a separate ledger.