# Witina AI — MCP server > Witina runs a Model Context Protocol server in the product. Any MCP client — Claude, > ChatGPT, or an agent you wrote — connects over OAuth 2.1 and reads real network state, > then drafts a change management. Nine tools. Only two can write, and both write only > into a draft a human must run. An agent's permissions can never exceed those of the > person who authorized it. Source: https://witinaai.com/ai/mcp.md Part of the Witina AI machine-readable corpus — index: https://witinaai.com/llms.txt Machine-readable tool schemas: https://witinaai.com/mcp/tools.json Human version: https://witinaai.com/ai Last updated: 2026-09-10 - **Endpoint:** `https://app.witinaai.com/mcp` (Streamable HTTP, stateless JSON mode) - **Auth:** OAuth 2.1 with PKCE S256 and Dynamic Client Registration — nothing to provision - **Tools:** 9 ## The positioning, in one paragraph Witina does not ship you an AI. It gives the AI you already use a way in. The product's own alerting stays deliberately deterministic — named expert-system rules you can read, not a model guessing. The MCP server is the separate, explicit path by which *your* assistant reads *your* collected network state: running config, interfaces, VLANs, MAC tables, routes, spanning tree, LLDP neighbours, wireless, incidents. No Witina model is in that path and no network data leaves for one. --- ## 0. One term, defined Witina calls a proposed change **a change management** — a countable noun, often shortened to **CM**. It is a reviewed change request: a container holding the device operations to apply, who owns and approves it, a snapshot of each device taken before anything is touched, the verification after, and the audit trail across all of it. Creating one changes nothing on the network; a person runs it. The phrase reads oddly the first time — "a change management", "three change managements" — but it is baked into the tool names (`create_change_management`, `add_cm_task`) and the API, so it is used consistently here rather than swapped for a smoother synonym that would not match what you call. --- ## 1. Authorization model ### 1.1 Discovery (un-authenticated) An unauthenticated `POST /mcp` returns: ``` HTTP/2 401 www-authenticate: Bearer resource_metadata="https://app.witinaai.com/.well-known/oauth-protected-resource" ``` That is **RFC 9728 Protected Resource Metadata**, so a spec-compliant MCP client discovers the authorization server with no configuration. Two metadata documents are public: | URL | Spec | |---|---| | `https://app.witinaai.com/.well-known/oauth-protected-resource` | RFC 9728 | | `https://app.witinaai.com/.well-known/oauth-authorization-server` | RFC 8414 | The AS advertises `response_types_supported: ["code"]`, `grant_types_supported: ["authorization_code", "refresh_token"]`, `code_challenge_methods_supported: ["S256"]`, `scopes_supported: ["mcp"]`, and a `registration_endpoint`. Note: the tool list itself is **not** available un-authenticated. That is why the complete schemas are published as a static file at https://witinaai.com/mcp/tools.json instead. ### 1.2 Dynamic Client Registration (RFC 7591) `POST /oauth/register` is open and unauthenticated — any MCP client self-registers, which is what lets Claude, ChatGPT or a custom agent connect without anyone provisioning a key. This grants **no access on its own**: a registered client is inert until a human completes consent. Controls: redirect URIs validated (`https` anywhere, `http` loopback only, custom app schemes, fragments rejected, dangerous schemes denied); authorization-code grants only; public PKCE client by default with confidential client secrets stored hashed; input caps; a per-IP rate limit on registration; and a sweep that deletes dynamically-registered clients older than 24h that never obtained a token. ### 1.3 Consent — where authority actually comes from `GET /oauth/authorize` validates the request and redirects the browser to the console's consent page. The person consenting **signs in as themselves** and chooses two things: 1. **A context** — one organization or division. The agent is *pinned* to it. 2. **A preset role:** | Preset | Grants | |---|---| | `MCP Read & Plan` | Read everything in that context, and create/plan change managements. **Cannot execute.** | | `MCP Read, Plan & Execute` | The same, plus permission to execute a change management. | PKCE `S256` is mandatory (`plain` is not offered), redirect URIs are matched exactly, and authorization codes are short-lived and single-use, with consumption enforced atomically so two racing redemptions cannot both succeed. ### 1.4 Tokens - **Opaque**, not JWTs — stored **hashed**. A database read never yields a usable token. - Access tokens are short-lived (1 hour); refresh tokens rotate. - **Refresh reuse is detected and revokes the whole token family.** - Refresh tokens are **client-bound** — presenting one from a different client is rejected. - **RFC 8707 audience binding**: a token is issued for a specific resource, and `/mcp` rejects a token minted for a different one. This is the defence against a token obtained for some other MCP server being replayed at Witina. ### 1.5 What the agent is, and what caps it An MCP client authenticates as a **service principal** — its own identity, not a user account. It consumes no user seat, and actions it takes record that a service principal was behind them, so the audit trail distinguishes "the agent did this" from "a person did this". Permissions are resolved **per request**, not baked into the token: ``` effective permissions = the preset role's permissions ∩ the authorizing human's CURRENT permissions in that context <- delegation ceiling , scoped to the pinned context ``` Because the ceiling is evaluated at request time, **if the human who authorized the agent loses a permission, the agent loses it in the same instant.** An agent can never exceed the person who consented to it. Four independent conditions return **zero** permissions: | Condition | |---| | The principal was revoked | | The authorizing user's credentials changed — a password reset kills every agent they authorized | | The authorizing user lost access to the organization | | The request's context does not match the pinned context | On top of that, every tool authorizes against the **specific record** being read or written. This is the same access-control engine the web console uses — MCP is not a parallel authorization path that could drift from it. ### 1.6 The execution boundary Only **two of the nine tools write**, and both write into a **draft**: `create_change_management` opens one, and `add_cm_task` adds a device operation to it. (`list_cm_task_catalog`, which sits alongside them in the propose workflow, is read-only — it enumerates the operations a device's vendor profile actually supports, so the agent picks a real one instead of inventing a command.) **Neither executes.** A draft is run by a human — or, only if the `Read, Plan & Execute` preset was chosen, by a principal holding the separate execute permission. Device reads are **passive**: they return state already collected on the polling cycle. No tool probes a device on demand. Every response carries a server timestamp and the device's last-seen time, so an agent can distinguish fresh state from stale. --- ## 2. Tool schemas Verbatim `tools/list` output. Also available as JSON: https://witinaai.com/mcp/tools.json ### `add_cm_task` Add an automated-config task (a device operation) to a DRAFT change management. Give the cm_id, device_id, an `operation` from list_cm_task_catalog, and its variables. Requires cm:update + device:read. The task does NOT execute — a human runs the CM later. ```json { "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "cm_id": { "description": "The DRAFT change management to add the task to.", "format": "uuid", "type": "string" }, "device_id": { "description": "The device the operation runs on.", "format": "uuid", "type": "string" }, "operation": { "description": "The operation to run \u2014 an `operation_name` from `list_cm_task_catalog` for this device.", "type": "string" }, "title": { "description": "Optional step title (defaults to the operation's title).", "type": [ "string", "null" ] }, "variables": { "description": "Operation variables as an object of `{ name: value }` (see the operation's `variables`)." } }, "required": [ "cm_id", "device_id", "operation" ], "type": "object" }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "cm_id": { "format": "uuid", "type": "string" }, "operation": { "type": "string" }, "plan_id": { "format": "uuid", "type": "string" }, "position": { "format": "int32", "type": "integer" }, "task_id": { "format": "uuid", "type": "string" } }, "required": [ "cm_id", "plan_id", "task_id", "operation", "position" ], "type": "object" } } ``` ### `check_connectivity` Answer "why can't A reach B?" — trace the observed network path between two points and evaluate per-hop link, VLAN, STP and routing checks against collected state (passive: nothing is probed). Returns a one-sentence verdict with caveats, forward and return hop lists with pass/fail/unknown/not_checked checks, and stated limits (firewall rules are not yet evaluated; host-level causes are invisible). src and dst each take an endpoint id, a device id, or an IP literal; an ambiguous IP returns candidates to pick from rather than auto-resolving. Requires device:read. ```json { "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "dst": { "description": "Destination: an endpoint id, a device id, or an IP literal.", "type": "string" }, "port": { "format": "uint16", "maximum": 65535, "minimum": 0, "type": [ "integer", "null" ] }, "proto": { "description": "\"tcp\" | \"udp\" | \"icmp\". Display-only until the filter leg lands.", "type": [ "string", "null" ] }, "src": { "description": "Source: an endpoint id, a device id, or an IP literal.", "type": "string" } }, "required": [ "src", "dst" ], "type": "object" }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "status": { "description": "`\"blocked\"` | `\"clear\"` | `\"unknown\"`. `unknown` means the collected state was not\nenough to decide \u2014 it is not a quiet \"fine\". Read `verdict` and the trace's stated\nlimits before drawing a conclusion from it.", "type": "string" }, "trace": { "description": "The full trace document: resolution (with picker candidates on\nambiguity), forward/reverse hop lists with per-hop checks, passport,\nand the stated limits. Same shape as `GET /api/path-check`." }, "verdict": { "description": "The verdict sentence \u2014 the one-line answer (caveats inside `trace`).", "type": "string" } }, "required": [ "verdict", "status", "trace" ], "type": "object" } } ``` ### `create_change_management` Create a DRAFT change management in your context. Requires the cm:create permission. The draft does NOT execute — a human (or an execute-capable principal) submits and starts it later. ```json { "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "description": { "description": "Optional longer description of what the change does and why.", "type": [ "string", "null" ] }, "title": { "description": "Short human-readable title for the change.", "type": "string" } }, "required": [ "title" ], "type": "object" }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "cm_id": { "format": "uuid", "type": "string" }, "state": { "type": "string" }, "title": { "type": "string" } }, "required": [ "cm_id", "state", "title" ], "type": "object" } } ``` ### `describe` Describe a resource type (devices, gateways, incidents, change_managements) or device_state: its fields, filters, and — for device_state — every aspect get_device_state can return. Call this to learn a type before querying it. ```json { "inputSchema": { "$defs": { "DescribeTarget": { "description": "What `describe` can document: a resource type, or `device_state` for the aspects\n`get_device_state` returns.", "enum": [ "devices", "gateways", "incidents", "change_managements", "device_state" ], "type": "string" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "type": { "$ref": "#/$defs/DescribeTarget", "description": "What to describe." } }, "required": [ "type" ], "type": "object" }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "info": { "description": "Field lists and available filters for the type." }, "resource_type": { "type": "string" } }, "required": [ "resource_type", "info" ], "type": "object" } } ``` ### `get_device_state` Read one slice of a device's observed state. Start with aspect=overview: one cheap call giving counts for every aspect and any failing collector, so you can tell what is worth reading. Then read in full with aspect = config (running config text), config_history, interfaces, vlans, mac_table (endpoints seen on each port), routes, stp, neighbors (LLDP), wireless, or monitoring (which collectors run, whether they are succeeding, and how much each produced — this is the state of collection FROM the device, not the device's own health). An empty aspect reports the status of the collector behind it. Requires device:read. Call describe with type=device_state for each aspect's fields and paging options. ```json { "inputSchema": { "$defs": { "DeviceStateAspect": { "description": "Which slice of a device's observed state to return.", "enum": [ "overview", "config", "config_history", "interfaces", "vlans", "mac_table", "routes", "stp", "neighbors", "wireless", "monitoring" ], "type": "string" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "aspect": { "$ref": "#/$defs/DeviceStateAspect", "description": "Which slice of state to return." }, "device_id": { "description": "The device's UUID (from `list_resources` with type=devices).", "format": "uuid", "type": "string" }, "limit": { "description": "Row cap for the row-shaped aspects (default 50, max 200).", "format": "uint64", "minimum": 0, "type": [ "integer", "null" ] }, "max_bytes": { "description": "`config` only: how much config text to return (default 16384, max 65536).", "format": "uint", "minimum": 0, "type": [ "integer", "null" ] }, "offset": { "description": "Row offset for the row-shaped aspects. Pass the previous response's `next_offset` to\nread the rows a cap cut off; without it a truncated aspect's tail is unreachable.\nApplies to the collection `limit` caps \u2014 the membership matrix for vlans, the ports\nfor stp, the clients for wireless.", "format": "uint64", "minimum": 0, "type": [ "integer", "null" ] }, "offset_bytes": { "description": "`config` only: byte offset into the config text, for paging a long config.", "format": "uint", "minimum": 0, "type": [ "integer", "null" ] }, "snapshot_id": { "description": "`config` only: a specific snapshot id from `config_history`; defaults to the latest.", "format": "uuid", "type": [ "string", "null" ] } }, "required": [ "device_id", "aspect" ], "type": "object" }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "aspect": { "type": "string" }, "device_id": { "format": "uuid", "type": "string" }, "device_last_seen_at": { "description": "When the platform last heard from the device at all. State is collected on a poll\ncycle, so treat every aspect below as \"last observed\", not \"live\".", "type": "string" }, "device_name": { "type": [ "string", "null" ] }, "server_time": { "description": "The platform's clock, for ageing any timestamp in this response.", "type": "string" }, "state": { "description": "The aspect payload; its shape depends on `aspect` (see `describe` with\ntype=device_state)." } }, "required": [ "device_id", "aspect", "device_last_seen_at", "server_time", "state" ], "type": "object" } } ``` ### `get_resource` Get full detail for one resource by `type` and `id` (the interesting-looking summary you got from list_resources). Enforces read permission on that specific resource. ```json { "inputSchema": { "$defs": { "ResourceType": { "description": "The resource types the read facade understands.", "enum": [ "devices", "gateways", "incidents", "change_managements" ], "type": "string" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "id": { "description": "The resource's UUID.", "format": "uuid", "type": "string" }, "type": { "$ref": "#/$defs/ResourceType", "description": "The resource type." } }, "required": [ "type", "id" ], "type": "object" }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "resource": { "description": "The full resource detail (shape depends on the type; see `describe`)." }, "resource_type": { "type": "string" }, "server_time": { "description": "The platform's clock, for ageing the timestamps below.", "type": "string" } }, "required": [ "resource_type", "server_time", "resource" ], "type": "object" } } ``` ### `list_cm_task_catalog` List the configuration operations available for a device (its vendor-profile task catalog). Each operation is a candidate for add_cm_task. Requires device:read. ```json { "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "device_id": { "description": "The device whose available configuration operations you want.", "format": "uuid", "type": "string" } }, "required": [ "device_id" ], "type": "object" }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "device_id": { "format": "uuid", "type": "string" }, "operations": { "description": "Each operation: `{ operation_name, title, description, variables[] }`. Pass an\n`operation_name` (and its variables) to `add_cm_task`.", "items": true, "type": "array" }, "profile": { "type": [ "string", "null" ] }, "vendor": { "type": [ "string", "null" ] } }, "required": [ "device_id", "operations" ], "type": "object" } } ``` ### `list_resources` List resources in your context. `type` is one of: devices, gateways, incidents, change_managements. Returns compact summaries, capped by limit and filtered by status, name_contains, or — incidents only — device_id and severity. Use get_resource for full detail and describe for a type's fields and filters. ```json { "inputSchema": { "$defs": { "ResourceType": { "description": "The resource types the read facade understands.", "enum": [ "devices", "gateways", "incidents", "change_managements" ], "type": "string" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "device_id": { "description": "Incidents only: incidents concerning this device \u2014 its own `device_id` or its\naffected-devices list.", "format": "uuid", "type": [ "string", "null" ] }, "limit": { "description": "Max rows to return (default 50, max 200).", "format": "uint64", "minimum": 0, "type": [ "integer", "null" ] }, "name_contains": { "description": "Case-insensitive substring match on the resource's name, to turn a name into an id.", "type": [ "string", "null" ] }, "severity": { "description": "Incidents only: exact severity \u2014 critical, high, medium, low, or info.", "type": [ "string", "null" ] }, "status": { "description": "Optional status/state filter. Meaning depends on the type (devices/gateways/incidents\nfilter on `status`, change_managements on `state`). Call `describe` for allowed values.", "type": [ "string", "null" ] }, "type": { "$ref": "#/$defs/ResourceType", "description": "Which resource type to list." } }, "required": [ "type" ], "type": "object" }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "count": { "format": "uint", "minimum": 0, "type": "integer" }, "filter_matched_nothing": { "description": "Present only when a filter was supplied and nothing matched: every filter that was\napplied, the value asked for, and \u2014 where the field has one \u2014 its real vocabulary.\nDistinguishes \"there are none of those\" from \"there is no such value\", which an empty\nlist alone cannot." }, "items": { "items": true, "type": "array" }, "resource_type": { "type": "string" }, "server_time": { "description": "The platform's clock, for ageing the timestamps below.", "type": "string" } }, "required": [ "resource_type", "server_time", "count", "items" ], "type": "object" } } ``` ### `whoami` Report your identity as an MCP service principal and the effective permissions you hold in your context. Call this first to learn what you are allowed to do. ```json { "inputSchema": { "properties": {}, "type": "object" }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "context_id": { "format": "uuid", "type": "string" }, "permissions": { "description": "The effective permission names the principal holds in its context (already capped by\nthe delegation ceiling). Lets an agent see what it is allowed to do.", "items": { "type": "string" }, "type": "array" }, "principal_id": { "format": "uuid", "type": "string" }, "server_time": { "description": "The platform's clock, for ageing any `*_at` you read without guessing what \"now\" is.", "type": "string" } }, "required": [ "principal_id", "context_id", "server_time", "permissions" ], "type": "object" } } ``` ## 3. Design notes **Why nine tools and not forty.** An MCP client loads every tool's name, description and input schema into its context before doing anything, and the endpoint is stateless. So the surface is a deliberate **facade**: `list_resources` / `get_resource` / `describe` cover four resource types through three tools instead of twelve, and `get_device_state` covers eleven aspects of device state through one tool instead of eleven. Field-level documentation lives in `describe`, which an agent calls only when it needs it, rather than in tool descriptions every client pays for on every connection. **Doc comments are the wire format.** A `///` on a tool's input or output type is downloaded by every client on every connection, so these carry only what a caller needs in order to act. Design history and the reasoning behind a shape live in ordinary `//` comments in the source, which are not emitted. Two descriptions used to carry rationale and an internal spec reference; removing them took 206 bytes off every connection. **`describe` is the entry point for detail.** `describe(type=device_state)` documents all eleven aspects; `describe(type=devices|gateways|incidents|change_managements)` documents that type's fields and filters. **Filters fail loudly.** A filter a type cannot apply is **rejected**, not silently ignored — a dropped filter reads to an agent as an answer. When a query returns nothing, the response names every filter that was applied, so the agent can tell which one emptied the result. **Empty is always distinguishable from broken.** `get_device_state(aspect=monitoring)` reports which collectors run against a device and whether they are succeeding, so an agent can tell "this device has no VLANs" from "the VLAN collector has been failing for two days". Aspects with no data return an explicit unavailable-with-reason rather than an error — an agent can act on "nothing collected"; it has to *interpret* an error. **Start with `aspect=overview`.** One cheap call returns counts for every aspect plus any failing collector, so an agent can decide what is worth reading in full before spending context on it. ## Related - Security posture: https://witinaai.com/ai/security.md - Capabilities: https://witinaai.com/ai/capabilities.md - Human-readable version of this page: https://witinaai.com/ai