# Remote MCP Server Tool-Surface and Permission Change Ledger (`kingii98/remote-mcp-server-tool-surface-and-permission-change-ledger`) Actor

Watch remote MCP servers for tool-surface and permission changes. The Actor reads the declared tool list with anonymous requests, compares it against a stored digest, and reports each added tool, removed tool, changed input schema, changed destructive-act

- **URL**: https://apify.com/kingii98/remote-mcp-server-tool-surface-and-permission-change-ledger.md
- **Developed by:** [kingii98](https://apify.com/kingii98) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 run\_starts

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Remote MCP Server Tool-Surface and Permission Change Ledger

Watch the remote MCP servers that you connect your agents to, and learn the day a
server adds a tool, changes a tool input schema, changes a destructive-action hint,
or starts to demand authentication. Each of those changes alters what your agent can
do, so each of them is a security event, not a parse break.

The Actor answers one question for the platform owner who connects agents to
third-party MCP servers: **what can my agents do today that they could not do
yesterday?**

The Actor sends **anonymous requests only**. It accepts no credential, it uses no
browser and no proxy, and it calls no tool. It reads the declared surface with the
MCP Streamable HTTP transport: one `initialize` call, one `initialized`
notification, one `tools/list` walk, and — when the server declares those
capabilities — one `resources/list` walk and one `prompts/list` walk. A server that
demands OAuth gets up to two unauthenticated `.well-known` GET requests of RFC 9728,
so that the row reports where the authorization lives.

### The ledger

The Actor stores one digest of each server's tool surface in the **named** key-value
store `mcp-tool-surface-ledger`, under the record key that `ledger_name` names. The
named store outlives the run; the run's own default store does not.

- **First run for a server**: the Actor writes the baseline and records no change.
  The row carries `ledger_status: "created"`.
- **Later run**: the Actor compares the fresh digest against the stored one. When
  the digests differ, it walks the two surfaces field by field and writes one change
  row for each difference. The row carries `ledger_status: "compared"`.
- **A server it could not read**: the stored entry is kept unchanged. A stale
  baseline is better than one that records an error page. The row carries
  `ledger_status: "unavailable"`.

The record is written once, at the end of the run, so an aborted run leaves the
previous ledger intact. Servers that this run did not read keep their entry, and the
record holds at most 500 servers.

### Change classes

| Change class | What it means for a connected agent |
| --- | --- |
| `tool_added` | The server offers an action that your agent did not have. |
| `tool_removed` | An action your agent relies on is gone. |
| `input_schema_changed` | The arguments of a tool changed. A stored prompt or a stored call may now be wrong. |
| `destructive_hint_changed` | A tool now declares (or stops declaring) that it destroys data. |
| `auth_requirement_changed` | The server started or stopped demanding authentication, or stopped allowing the anonymous listing. |
| `read_only_hint_changed` | A tool lost or gained its read-only promise. |
| `open_world_hint_changed` | A tool changed whether it reaches outside the server. |
| `tool_description_changed` | The text that steers your agent's tool choice changed. |
| `server_version_changed` | The server reports a new version. |

The first five are the default. Each recorded change is one billed event, so a class
that you do not select costs nothing.

**Severity.** An authentication change is `high`. A destructive hint that turns on,
or that stops being declared, is `high`; one that turns off is `medium`. A tool that
loses a `readOnlyHint: true` is `high`. An added tool, a removed tool, a changed
input schema and a changed open-world hint are `medium`. Everything else is `low`.

A server that stops the anonymous listing hides every tool at once. That is one
authentication event, not one removal for each tool, so the tool diff is suppressed
for that run and resumes when the listing is readable again.

### Input

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `servers` | array of 1 to 200 URLs | the four public demo servers | Remote MCP endpoint URLs that speak the Streamable HTTP transport. |
| `ledger_name` | string | `DEFAULT` | The record key of the stored baseline set inside `mcp-tool-surface-ledger`. Use one name for each connected server fleet. |
| `alert_on` | array of change classes | the five classes above | Which change classes become a change row. |
| `request_timeout_seconds` | integer 2 to 120 | `20` | Per-request timeout for each MCP call and each metadata request. |
| `concurrency` | integer 1 to 20 | `5` | How many servers are read at the same time. |
| `max_redirects` | integer 0 to 10 | `3` | Redirect hops for each request. Every hop is resolved and checked again. |
| `max_response_bytes` | integer 10000 to 5000000 | `2000000` | Per-response body cap. |
| `count_resources_and_prompts` | boolean | `true` | Also call `resources/list` and `prompts/list` when the server declares those capabilities. |
| `probe_oauth_metadata` | boolean | `true` | Send up to two RFC 9728 `.well-known` GET requests for a server that demands authentication. |
| `max_server_seconds` | integer 5 to 600 | `60` | Wall-clock budget for one server. |
| `max_run_seconds` | integer 30 to 3600 | `900` | Wall-clock deadline for the whole server list. |

Every field has a schema default, so a run with the empty input `{}` reads the four
public demo servers and succeeds.

#### Safety limits

- Only `http` and `https` URLs, and no credential in a URL.
- `localhost`, and any hostname that resolves to a private or reserved address, is
  refused. Every redirect hop is resolved and checked again before it is followed,
  and a chain that repeats a URL is stopped.
- An entry that is not a usable target — a malformed URL, a foreign scheme, a
  `localhost` target, a URL with a credential, or a repeat of an earlier URL —
  becomes **one row with the `BLOCKED_TARGET` reason code**. It never stops the run,
  and it is never charged.
- At most 200 servers, 500 tools for each server, 5 cursor pages for each list
  method, and 5 MB for each response body. Each response body is read by a
  streaming reader that stops at the cap.
- The request timeout does not bound one server on its own, because one server takes
  up to 17 requests and each request may take several redirect hops. The worst-case
  wall clock of a run is therefore `min(ceil(servers / concurrency) *
  max_server_seconds, max_run_seconds)`. With the default fixture that is 55
  seconds, well inside the platform's five-minute window.

### Output

The dataset holds one summary row, one row for each server, and one row for each
recorded change. The `record_type` field separates them.

**Server row** (`record_type: "server"`): `server_url`, `final_url`, `ok`,
`reason_code`, `error`, `http_status`, `transport`, `protocol_version`,
`server_name`, `server_version`, `anonymous_listing_allowed`, `auth_required`,
`oauth_protected_resource_metadata_url`, `oauth_authorization_servers`,
`tools_total`, `resources_total`, `prompts_total`, `tool_digest`,
`previous_tool_digest`, `digest_changed`, `tools[]` (each with `name`,
`description_hash`, `input_schema_hash`, `read_only_hint`, `destructive_hint`,
`open_world_hint`), `tools_truncated`, `ledger_status`, `change_count`,
`first_seen`, `last_changed`, `digest_algorithm`, `bytes_read`, `response_time_ms`
and `observed_at`.

**Change row** (`record_type: "change"`): `server_url`, `change_class`, `tool_name`,
`field`, `old_value_hash`, `new_value_hash`, `old_value`, `new_value`, `severity`
and `observed_at`.

**Summary row** (`record_type: "summary"`): `servers_requested`, `servers_checked`,
`servers_changed`, `servers_unreachable`, `servers_requiring_auth`,
`servers_allowing_anonymous_listing`, `anonymous_listing_share`,
`baselines_created`, `servers_compared`, `changes_total`, `changes_by_class`,
`changes_by_severity`, `alert_on`, `ledger_store`, `ledger_name`,
`digest_algorithm` and `observed_at`.

#### Reason codes

`OK`, `AUTH_REQUIRED`, `TRANSPORT_LEGACY_SSE`, `TRANSPORT_UNSUPPORTED`,
`HTTP_ERROR`, `RPC_ERROR`, `NOT_JSON`, `RESPONSE_TOO_LARGE`, `TOOLS_LIST_FAILED`,
`TIMEOUT`, `DNS_FAIL`, `CONNECT_FAIL`, `BLOCKED_TARGET`, `REDIRECT_LOOP` and
`RUN_DEADLINE`.

A changed surface, a server that demands OAuth, an unusable target and an
unreachable server are all **business verdicts**: the run reports them in the
dataset and in the status message, and the run itself **succeeds**.

### Pricing: pay per event

| Event | Unit | Counted as |
| --- | --- | --- |
| `run_start` | one for each run | Charged once, before any server work. It pays for the container start and the summary row. |
| `server_checked` | one for each server contacted and compared against the ledger | Charged once for each server whose `ledger_status` is `created` or `compared`. A server that could not be reached, and an input entry that is not a usable target, were neither contacted nor compared, so neither is **charged**. |
| `surface_change_recorded` | one for each recorded change of a tool or permission field | Charged once for each change row, that is once for each `change_count` of every server row. |

Before it reads a single server, the run limits the server list to the number of
`server_checked` charges that its maximum total charge still allows. The servers
that fall outside that budget are not read, and they keep their stored ledger entry
unchanged for the next run.

### Repeat use

Tool surfaces change with each vendor release. Run the ledger each day or each week
over the whole connected server list, and again before each agent release. The
value is the cross-run baseline: the first run creates it, and every later run turns
it into a list of events that you can act on.

### Local development

```bash
uv sync
uv run pytest
uv run ruff check .
```

# Actor input Schema

## `servers` (type: `array`):

1 to 200 remote MCP endpoint URLs that speak the Streamable HTTP transport, for example https://mcp.deepwiki.com/mcp. The Actor sends anonymous requests only, and it accepts no credential. If you leave this field empty, the Actor reads the four public example servers shown below.

## `ledger_name` (type: `string`):

Name of the stored baseline set. It is the record key inside the named key-value store 'mcp-tool-surface-ledger'. Use one name for each connected server fleet. The Actor creates the baseline on the first run, and writes the record once, at the end of the run.

## `alert_on` (type: `array`):

Which change classes become a change row. Each recorded change is one billed event, so a class that you do not select costs nothing. The default is the five classes that change what a connected agent can do.

## `request_timeout_seconds` (type: `integer`):

Per-request timeout for each MCP call and each metadata request. A server that exceeds it gets the TIMEOUT reason code.

## `concurrency` (type: `integer`):

Maximum number of servers read at the same time.

## `max_redirects` (type: `integer`):

Maximum redirect hops followed for each request. Every hop is checked again against the private-address rules. A chain that repeats a URL gets the REDIRECT\_LOOP reason code.

## `max_response_bytes` (type: `integer`):

Per-response body cap. A larger answer gets the RESPONSE\_TOO\_LARGE reason code and is not parsed.

## `count_resources_and_prompts` (type: `boolean`):

Also call resources/list and prompts/list when the server declares those capabilities, so that the row reports resources\_total and prompts\_total. Turn it off to send fewer requests. The counts are reported, but they are not compared, because the ledger watches the tool and permission surface.

## `probe_oauth_metadata` (type: `boolean`):

When a server demands authentication, send up to two .well-known GET requests of RFC 9728 to find its protected-resource metadata URL and its authorization servers. No request is sent for a server that allows an anonymous tools/list call.

## `max_server_seconds` (type: `integer`):

Wall-clock budget for one server. The probe sends up to 17 requests to one server, and each request may take several redirect hops, so the request timeout alone does not bound a server. A server that exceeds this budget gets the TIMEOUT reason code and keeps its stored ledger entry.

## `max_run_seconds` (type: `integer`):

Wall-clock deadline for the whole server list. A server that the run does not reach before this deadline gets the RUN\_DEADLINE reason code and keeps its stored ledger entry. The run still writes every row and succeeds.

## Actor input object example

```json
{
  "servers": [
    "https://mcp.deepwiki.com/mcp",
    "https://mcp.context7.com/mcp",
    "https://huggingface.co/mcp",
    "https://api.githubcopilot.com/mcp/"
  ],
  "ledger_name": "DEFAULT",
  "alert_on": [
    "tool_added",
    "tool_removed",
    "input_schema_changed",
    "destructive_hint_changed",
    "auth_requirement_changed"
  ],
  "request_timeout_seconds": 20,
  "concurrency": 5,
  "max_redirects": 3,
  "max_response_bytes": 2000000,
  "count_resources_and_prompts": true,
  "probe_oauth_metadata": true,
  "max_server_seconds": 60,
  "max_run_seconds": 900
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "servers": [
        "https://mcp.deepwiki.com/mcp",
        "https://mcp.context7.com/mcp",
        "https://huggingface.co/mcp",
        "https://api.githubcopilot.com/mcp/"
    ],
    "ledger_name": "DEFAULT",
    "alert_on": [
        "tool_added",
        "tool_removed",
        "input_schema_changed",
        "destructive_hint_changed",
        "auth_requirement_changed"
    ],
    "request_timeout_seconds": 20,
    "concurrency": 5,
    "max_redirects": 3,
    "max_response_bytes": 2000000,
    "count_resources_and_prompts": true,
    "probe_oauth_metadata": true,
    "max_server_seconds": 60,
    "max_run_seconds": 900
};

// Run the Actor and wait for it to finish
const run = await client.actor("kingii98/remote-mcp-server-tool-surface-and-permission-change-ledger").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "servers": [
        "https://mcp.deepwiki.com/mcp",
        "https://mcp.context7.com/mcp",
        "https://huggingface.co/mcp",
        "https://api.githubcopilot.com/mcp/",
    ],
    "ledger_name": "DEFAULT",
    "alert_on": [
        "tool_added",
        "tool_removed",
        "input_schema_changed",
        "destructive_hint_changed",
        "auth_requirement_changed",
    ],
    "request_timeout_seconds": 20,
    "concurrency": 5,
    "max_redirects": 3,
    "max_response_bytes": 2000000,
    "count_resources_and_prompts": True,
    "probe_oauth_metadata": True,
    "max_server_seconds": 60,
    "max_run_seconds": 900,
}

# Run the Actor and wait for it to finish
run = client.actor("kingii98/remote-mcp-server-tool-surface-and-permission-change-ledger").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "servers": [
    "https://mcp.deepwiki.com/mcp",
    "https://mcp.context7.com/mcp",
    "https://huggingface.co/mcp",
    "https://api.githubcopilot.com/mcp/"
  ],
  "ledger_name": "DEFAULT",
  "alert_on": [
    "tool_added",
    "tool_removed",
    "input_schema_changed",
    "destructive_hint_changed",
    "auth_requirement_changed"
  ],
  "request_timeout_seconds": 20,
  "concurrency": 5,
  "max_redirects": 3,
  "max_response_bytes": 2000000,
  "count_resources_and_prompts": true,
  "probe_oauth_metadata": true,
  "max_server_seconds": 60,
  "max_run_seconds": 900
}' |
apify call kingii98/remote-mcp-server-tool-surface-and-permission-change-ledger --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kingii98/remote-mcp-server-tool-surface-and-permission-change-ledger"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/PIXZIgESaZ0ka7JUc/builds/r9QRtNTH4sNFB1sTp/openapi.json
