Dynamic Tool Visibility: Session-Scoped Permissions and State-Machine Workflows in MCP

Exposing all tools to every prompt creates security hazards and confusion. NitroStack enables fine-grained tool visibility, state-dependent tool scoping, and coalesced list-changed notifications across stateless sessions.

Hemant Jadhav
Hemant Jadhav
Dynamic Tool Visibility and State-Machine Scoping hero banner with 3D isometric security vault and blueprint background

In traditional API design, endpoints exist statically. A client either holds the authorization header necessary to query an endpoint, or it receives an HTTP 401 Unauthorized or 403 Forbidden response.

In the Model Context Protocol (MCP), static visibility creates a severe vulnerability surface.

When an LLM agent queries tools/list, exposing every administrative, financial, and destructive capability creates two distinct problems:

  1. Prompt Injection and Agent Confusion: If an untrusted end user talks to an agent connected to an MCP server exposing both search_products and refund_credit_card, adversarial prompt injections can manipulate the model into invoking the privileged tool. Even with backend authorization guards, exposing destructive tool definitions gives the attacker explicit hints regarding function names and schema parameters.
  2. State-Machine Incoherence: Complex workflows (such as onboarding, loan origination, or multi-step deployment pipelines) require progressive disclosure. Exposing the submit_final_approval tool before the verify_identity tool has executed invites out-of-order hallucinations.
text
┌─────────────────────────────────────────────────────────────────────────────┐
│                 STATIC TOOL LISTING VS DYNAMIC TOOL VISIBILITY              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│ Static MCP Server (All Tools Exposed to Every Session):                     │
│   Client (Guest Role) ──(tools/list)──► Returns:                            │
│                                         • get_faq                           │
│                                         • search_catalog                    │
│                                         • delete_database_table   ◄── RISK! │
│                                         • refund_transaction      ◄── RISK! │
│                                                                             │
│ NitroStack Dynamic Visibility (Session-Scoped State Machine):               │
│   Client (Guest Role) ──(tools/list)──► Returns:                            │
│                                         • get_faq                           │
│                                         • authenticate_user                 │
│                                                                             │
│   Client invokes authenticate_user()                                        │
│         │                                                                   │
│         ▼                                                                   │
│   Server verifies MFA and calls ctx.enableTools(['view_account', 'transfer'])│
│         │                                                                   │
│         ▼                                                                   │
│   Server emits coalesced notification: notifications/tools/list_changed     │
│                                                                             │
│   Client refreshes palette ──► Returns authenticated operations only        │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

In NitroStack 1.1, we introduced VisibilityTransform and SessionVisibilityStore. This system enables servers to dynamically tailor the visible tool surface on a per-session, per-role, and per-workflow-state basis.


1. The Architecture of VisibilityTransform

The core challenge of session-based tool filtering in modern MCP is protocol alignment. In the 2026-07-28 specification, MCP is stateless at the wire. Requests do not pin persistent server memory.

To support dynamic visibility without sacrificing horizontal scalability, NitroStack decouples visibility state into the SessionVisibilityStore:

typescript
import { McpApp, Module, VisibilityTransform } from '@nitrostack/core';
import { AuthController } from './auth.controller';
import { BankingController } from './banking.controller';

@McpApp({
  module: AppModule,
  server: { name: 'banking-agent-service', version: '1.1.0' },
  transforms: [
    new VisibilityTransform({
      // High-volume tools visible by default to unauthenticated callers
      defaultVisibleTools: ['login', 'get_public_rates'],
    }),
  ],
})
@Module({
  name: 'app',
  controllers: [AuthController, BankingController],
})
export class AppModule {}

When a client queries tools/list:

  1. VisibilityTransform intercepts the request.
  2. It extracts the session identity (from headers, OAuth 2.1 tokens, or client credentials).
  3. It queries the SessionVisibilityStore to resolve the current active toolset for that session.
  4. Only tools explicitly permitted for the caller's current state are serialized into the JSON-RPC response.

If a client attempts to forge a tools/call for an invisible tool, VisibilityTransform rejects the invocation immediately with a standard protocol error:

json
{
  "jsonrpc": "2.0",
  "id": "req-204",
  "error": {
    "code": -32601,
    "message": "Tool 'transfer_funds' is not enabled for the current session state."
  }
}

2. Progressive Tool Disclosure in Controllers

Controllers control visibility through the ExecutionContext. When an operation completes successfully, the controller can enable or disable subsequent tools in the workflow:

typescript
import { Controller, Tool, ExecutionContext } from '@nitrostack/core';
import { z } from 'zod';

@Controller()
export class AuthController {
  @Tool({
    name: 'login',
    description: 'Authenticates user with username and one-time password',
    schema: z.object({
      username: z.string(),
      otp: z.string(),
    }),
  })
  async login(input: { username: string; otp: string }, ctx: ExecutionContext) {
    const user = await this.authService.verifyOtp(input.username, input.otp);

    // Dynamically unlock authenticated operations
    await ctx.enableTools(['view_balances', 'initiate_transfer']);

    // Hide the login tool to prevent redundant execution
    await ctx.disableTools(['login']);

    return {
      status: 'authenticated',
      role: user.role,
      message: 'Authentication successful. Account tools are now available.',
    };
  }
}

By removing the login tool from the visible surface once the session is established, the agent's prompt context remains clean, preventing redundant authentication calls.


3. Reactive Tool Updates via notifications/tools/list_changed

When an application unlocks new tools, how does the client know that its tool palette has changed?

Under standard MCP, the client caches the initial response from tools/list. If the server unlocks a new tool on the backend, the client would remain unaware unless it polls.

NitroStack implements reactive updates by emitting the protocol notification:

json
{
  "jsonrpc": "2.0",
  "method": "notifications/tools/list_changed"
}

Coalescing Events to Prevent Notification Thrashing

If a controller enables four tools and disables two tools within a single transaction:

typescript
await ctx.enableTools(['tool_a', 'tool_b', 'tool_c']);
await ctx.disableTools(['tool_d']);

Emitting individual notification events for every state change would flood the client connection and trigger redundant tools/list round-trips.

NitroStack's pipeline runner implements Notification Coalescing. State mutations scheduled during a request execution cycle are collected in an in-memory change set. Once the parent request completes, NitroStack emits exactly one coalesced notifications/tools/list_changed frame to the client.

text
┌─────────────────────────────────────────────────────────────────────────────┐
│                    COALESCED NOTIFICATION DISPATCH FLOW                     │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   Controller Execution Phase:                                               │
│   ├── ctx.enableTools(['step_2_verify'])                                    │
│   ├── ctx.enableTools(['step_2_upload'])                                    │
│   └── ctx.disableTools(['step_1_initiate'])                                 │
│                                                                             │
│   Pipeline Runner Event Queue:                                              │
│   [ Mutation Buffer: +step_2_verify, +step_2_upload, -step_1_initiate ]     │
│                                                                             │
│   Request Completes ──► Exactly 1 Notification Emitted:                     │
│   Client ◄── { method: "notifications/tools/list_changed" }                 │
│                                                                             │
│   Client triggers single refresh: tools/list ──► receives updated palette   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

4. Multi-Tenant Session Stores

For development and local CLI usage, NitroStack defaults to an in-memory MemoryVisibilityStore. In production environments with multiple distributed server replicas, session state can be backed by a persistent store:

typescript
import { McpApp, Module, VisibilityTransform } from '@nitrostack/core';
import { RedisVisibilityStore } from '@nitrostack/storage-redis';

@McpApp({
  module: AppModule,
  server: { name: 'distributed-mcp-cluster', version: '1.1.0' },
  transforms: [
    new VisibilityTransform({
      store: new RedisVisibilityStore({
        connectionString: process.env.REDIS_URL,
        keyPrefix: 'mcp:session:visibility:',
        defaultTtlMs: 86400000, // 24 hours
      }),
      defaultVisibleTools: ['public_search'],
    }),
  ],
})
export class AppModule {}

Because visibility state is read from Redis using the session key, any server replica behind your load balancer can immediately serve subsequent tools/list or tools/call invocations with consistent permissions.


5. Security Checklist: Preventing Indirect Prompt Injection

Dynamic tool visibility provides an essential defense layer against prompt injection attacks:

  1. Principle of Least Privilege: Never expose write, delete, or money-moving tools until an explicit authentication handshake has completed.
  2. Hidden Schema Defense: An attacker cannot exploit parameters or subtle validation bugs in tools they do not know exist. Keep sensitive administrative utilities hidden until high-privilege credentials have been validated.
  3. Session Revocation: If suspicious behavior or prompt injection is detected, call ctx.disableTools(...) to strip tool access immediately without terminating the client connection.

Next Steps

Dynamic visibility and sandboxed code execution give backend engineers granular control over how models interact with APIs. But what about the human end user who needs to see the results?

In the final post of our NitroStack 1.1 series, we will explore Generative UI: how to use our bundled Next.js CLI presets to build interactive widgets that render live dashboards directly inside agent chat windows.

About the Author

Hemant Jadhav

Hemant Jadhav

Author