fix(skills-catalog): validate server-supplied discovery URLs before fetching

The authoring workflow told the reader to curl two URLs an unauthenticated
MCP endpoint chooses: resource_metadata out of the WWW-Authenticate
challenge, and the issuer out of the document that URL returns. A hostile
endpoint could point either at 169.254.169.254, at a loopback service, or
at a public hostname whose DNS record is 127.0.0.1, and a bare curl would
go there with the author's network position.

references/safe-discovery.md adds safe_curl, and step 2 now uses it for
both fetches. It is an allowlist, not a denylist, and it fails closed:
https only, no userinfo, port 443 only, and every resolved address checked
against loopback, private, CGNAT, link-local, unique-local, multicast,
benchmarking, documentation and reserved space. IPv4-mapped, NAT64 and 6to4
forms are unwrapped and the embedded IPv4 address is checked under the IPv4
rules. One bad answer in a round-robin set refuses the whole host.

Two details that decide whether this works rather than looks like it does.
The fetch pins --resolve to exactly the addresses that were validated, so a
second DNS answer cannot move the request between the check and the
connection. And no redirect is ever followed: a 3xx is reported and
refused, and going there means re-running safe_curl on the Location so it
is validated on its own merits.

Executed against adversarial metadata rather than a URL list: a hostile
challenge and two hostile protected-resource documents, parsed the way step
2 says to parse them. localtest.me is the case a string check cannot catch
-- an ordinary public hostname with a real public record pointing at
loopback. Twenty refusal cases, one per class; the redirect refusal against
a live 301; and the documented flow still returns Enterpret's 401 challenge
and protected-resource document unchanged. The two scripts in the document
were extracted and diffed against the tested copies: byte-identical.

Also the four Enterpret acceptance findings that belong in this package:

- Every pinned count in catalog-contract.md was stale. APP_STORE_DEFINITIONS
  went 46 -> 56 and SELF_SERVE_MCP_CANDIDATES 43 -> 48 since the file was
  written, so following it wrote a failing assertion. The counts are now a
  command that reads them out of the checkout in front of you.
- Every line-number citation in both skills is gone, in favour of a file
  plus a symbol to grep. Four playbook citations landed on unrelated text;
  every claim behind them survives and was re-read at 18dac1e1, which both
  SKILL.md files now record.
- The visibility contract has three states, not two. Hiding a slug is not
  withholding it -- a hidden slug is still reachable by direct URL or slug
  lookup. availability: { available: false, reason } is the state that
  refuses setup, and it is where a connector authored without a live
  provider proof belongs.
- Installed catalog skills materialize as connect-agent-tools--<hash>, so
  the three bare-slug sibling paths were dead for any installed reader.
  They now name the skill, with the command to resolve the directory.

skills-catalog: manifest regenerated (19 skills), validate clean, 20 tests.
Both packages still derive markdown_only; descriptions 291 and 293. Server
company-skills suites 161/161 against the regenerated manifest. Seventeen
packaged relative links, none broken, no private references.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
nguyenm7andPaperclip committed 2026-09-24 21:09:02 +00:00
1 parent 066a4e8019
commit c46092f202
9 files changed
+662 -167

No files matched your search

+2 -1
View File
@@ -252,7 +252,8 @@ that is precisely the protection a pasted URL lacks.
## 4. The nine production-validation scenarios
Runbook matrix at `CONNECTOR-PLAYBOOK.md:1445-1455`. **Every row that names
Runbook matrix at `CONNECTOR-PLAYBOOK.md`, **Step 9: Align With Production
Validation**. **Every row that names
Enterpret itself is `not run`, for one reason: there is no authorized
credential, and registration, consent and tool calls are out of bounds.** The
mirror result is recorded beside it, because the mirror exercises Paperclip's
@@ -31,12 +31,12 @@ slug allowlist, branding validation, the exact-count test assertions, and the
offline verification loop. It does not own the process around them.
**A catalog entry is a convenience layer, not a prerequisite.** The runbook is
explicit: an operator can connect any standards-compliant remote HTTP MCP server
from **Connect your own MCP server** or **Paste a config** with no Paperclip
code change at all, including servers that need browser sign-in
(`CONNECTOR-PLAYBOOK.md:23-26`). Check that the requester actually needs a
catalog entry before spending a single edit here. `connect-agent-tools` owns
that decision.
explicit: an operator can connect any standards-compliant remote HTTP MCP
server from **Connect your own MCP server** or **Paste a config** with no
Paperclip code change at all, including servers that need browser sign-in
(`CONNECTOR-PLAYBOOK.md`, opening section). Check that the requester actually
needs a catalog entry before spending a single edit here.
`connect-agent-tools` owns that decision.
**Self-hosted first.** Everything this skill produces is authored and verified
against an App checkout and an instance you run yourself. Phases 8 and 9 of the
@@ -46,6 +46,12 @@ is genuinely Cloud-gated — the curated `platform_shared` Paperclip-managed OAu
profile is the one current case — record it as `unsupported` for self-hosted
rather than treating Cloud as the baseline.
**How this skill cites the product.** A file and a symbol you can grep for,
never a line number: line numbers drift between the read and the reader, and
this skill has already been caught citing four that had moved. Every source and
runbook citation below was re-read at App commit `18dac1e1` (24 September 2026).
If the commit in front of you disagrees, the commit wins — record the drift.
## Use This When
Trigger on a request that names a provider and asks for a catalog connector,
@@ -69,13 +75,14 @@ Hand the request to the right owner instead:
| "The provider has no MCP server" | Out of scope. A manifest cannot wrap an arbitrary REST API, run an arbitrary local command, or register an OAuth client in a console Paperclip does not control. Report the boundary. |
| "Ship it" / "install it" / "release it" | Not authorized here. See **Authorization boundaries**. |
PR #13675 made the runbook self-contained so that "contributors can implement a
connector without access to an internal issue tracker", and removed the separate
private validation issue ("No separate private validation issue is required",
`CONNECTOR-PLAYBOOK.md:1443`). Treat `prepare-mcp-integration` as an internal
convenience for people working inside Paperclip Content, not as a gate a public
contributor must pass. When it does apply, that skill drives and calls this one
for the definition itself.
PR #13675 made the runbook self-contained so that "contributors can implement
a connector without access to an internal issue tracker", and removed the
separate private validation issue ("No separate private validation issue is
required", `CONNECTOR-PLAYBOOK.md`, **Step 9: Align With Production
Validation**). Treat `prepare-mcp-integration` as an internal convenience for
people working inside Paperclip Content, not as a gate a public contributor
must pass. When it does apply, that skill drives and calls this one for the
definition itself.
## Required Inputs
@@ -90,7 +97,13 @@ Collect these before step 1. Ask only for what you cannot determine safely.
the runbook no longer requires a private research approval. Inside Paperclip
Content, that decision is `prepare-mcp-integration`'s gate; if it applies and
has not been taken, stop and route there.
4. **Intended visibility**: store-visible, or connectable-but-hidden.
4. **Intended visibility**, which is a three-way choice, not a toggle:
store-visible; connectable but unlisted (`APP_STORE_HIDDEN_SLUGS` — still
reachable by direct URL or slug, so hiding is not withholding); or withheld
(`availability: { available: false, reason }`, which refuses setup and
renders the reason instead of a Connect action). A connector authored
without a live provider proof belongs in the third state, not the second.
See the visibility chain in `references/catalog-contract.md`.
5. **Location of the ingestion corpus** (`paperclip-content`), or the value for
`PAPERCLIP_CONTENT_TEMPLATES`. This corpus is in a non-public repository; the
generator refuses to run without it. If you do not have it, this skill's
@@ -151,8 +164,8 @@ Read from the recorded commit, not from memory and not from a stale worktree:
instructions, provider handoffs, personal identity linking, the optional
message test, and management states. Do not apply Slack's steps or credential
types to a provider that does not work that way, and do not skip it when they
apply. `connect-agent-tools/references/chat-and-email.md` is the short routing
layer over it.
apply. The `connect-agent-tools` skill's `references/chat-and-email.md` is
the short routing layer over it.
- `doc/connections/README.md` — the identity/connections boundary. Every
connector is a plane P2 resource credential, never a sign-in authenticator.
Sign-in tokens are never reused as resource tokens; `id.paperclip.ing` never
@@ -165,6 +178,20 @@ Read from the recorded commit, not from memory and not from a stale worktree:
- `packages/shared/src/app-definitions.test.ts` — the assertions your change
must satisfy.
**Finding the sibling skill's files.** Installed catalog skills do not
materialize under their bare slug. A run sees
`connect-agent-tools--<hash>/`, where the suffix is generated per package, so
`../connect-agent-tools/references/…` resolves to nothing and a relative link
written that way is dead the moment the skill is installed rather than read out
of a checkout. Resolve the directory before you read it:
```sh
find "$(dirname "$PWD")" -maxdepth 1 -name 'connect-agent-tools*' -type d
```
Inside an App checkout the bare name is correct; both forms appear, and only
one of them is the one an installed reader has.
Then read `references/catalog-contract.md` in this skill for the file-by-file
map and the assertions that carry exact counts. Treat that reference as a
starting index, not as a substitute: if it disagrees with the commit you read,
@@ -179,18 +206,36 @@ do not invent hosted OAuth for a same-machine, unauthenticated server. If the
current transport or egress policy cannot support it, report the runtime gap.
For OAuth providers, these unauthenticated requests inspect the transport and
auth axes. Skip OAuth discovery for a provider that does not use OAuth:
auth axes. Skip OAuth discovery for a provider that does not use OAuth.
**Do not `curl` these URLs directly.** After the first request, the server
picks where you go next: `resource_metadata` comes out of its challenge, and
the issuer comes out of the document that URL returns. A hostile endpoint can
point either at cloud metadata at `169.254.169.254`, at a loopback service, or
at a public hostname whose DNS record is `127.0.0.1` — and a bare `curl` will
go there with your network position. Set up `safe_curl` from
[references/safe-discovery.md](references/safe-discovery.md) first. It
validates scheme, port, userinfo and every resolved address before anything is
sent, pins the connection to the addresses it validated so DNS cannot move the
request afterwards, and refuses redirects. Refusal is exit 2 and means nothing
left the machine.
```sh
curl -s -i -X POST "$SERVER_URL" \
source ./safe-fetch.sh # see references/safe-discovery.md
safe_curl "$SERVER_URL" -X POST \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
# Read resource_metadata out of the WWW-Authenticate challenge, then:
curl -s "$RESOURCE_METADATA_URL" # RFC 9728 -> authorization_servers
curl -s "$ISSUER/.well-known/oauth-authorization-server" # RFC 8414
safe_curl "$RESOURCE_METADATA_URL" # RFC 9728 -> authorization_servers
safe_curl "$ISSUER/.well-known/oauth-authorization-server" # RFC 8414
```
A refusal is a finding, not an obstacle to route around. Record the URL the
provider supplied and what it resolved to, and escalate it — a catalog
connector whose discovery points inside the authoring network does not ship.
Record, with the date you read it: the endpoint and its trailing-slash
behaviour; the unauthenticated status and challenge; the protected-resource and
authorization-server metadata URLs; the exact issuer; authorize, token,
@@ -273,14 +318,16 @@ Then split your evidence into three explicit buckets and never blur them:
Then answer the runbook's nine production-validation scenarios — setup and
consent, authentication, catalog and configuration, allowed execution, denied
execution, runtime delivery, refresh and recovery, revoke and reconnect,
activity and secret handling (`CONNECTOR-PLAYBOOK.md:1445-1455`) — with
**pass / fail / not run / not applicable** and a reason for the last two, plus
environment, method key, commit, and accessible redacted evidence. Offline
authoring legitimately produces a column of "not run"; what it must never
produce is a blank or an optimistic one.
activity and secret handling (`CONNECTOR-PLAYBOOK.md`, **Step 9: Align With
Production Validation** — the scenario table) — with **pass / fail / not run /
not applicable** and a reason for the last two, plus environment, method key,
commit, and accessible redacted evidence. Offline authoring legitimately
produces a column of "not run"; what it must never produce is a blank or an
optimistic one.
Report those results per deployment, self-hosted first, using the four labels
in `connect-agent-tools/references/deployment-support-matrix.md`. A pass on a
in the `connect-agent-tools` skill's `references/deployment-support-matrix.md`.
A pass on a
self-hosted instance makes that row `verified` for self-hosted and leaves Cloud
`untested`; the reverse is equally true.
@@ -340,7 +387,8 @@ Deliver exactly this, and nothing that implies more:
5. **A deployment support matrix** — self-hosted same-machine, self-hosted
server/VPS, and Cloud, each capability labelled `verified`, `untested`,
`unsupported` or `deferred` with its evidence or named follow-up owner. Use
`connect-agent-tools/references/deployment-support-matrix.md` as the shape.
the `connect-agent-tools` skill's `references/deployment-support-matrix.md`
as the shape.
6. **Remaining gaps** — unprobed constraints, the account-bound lifecycle, and
anything a reviewer must authorize before release.
7. **An explicit statement** of what did not happen: no push, no PR, no deploy,
@@ -2,22 +2,27 @@
Index of the files and assertions a catalog-connector change has to satisfy.
**Verified against Paperclip App commit `e558f25e` (15 September 2026), by
re-reading each cited file and by running the command ladder below through the
isolated harness.** Previously verified at `728f7185` (14 September 2026); every
claim below survived that move, but several line numbers did not, so this
revision cites symbols and landmarks you can grep for and keeps line numbers
only where they are load-bearing. If the commit you read disagrees, the commit
wins — and record the drift in your report so this file gets corrected.
**Verified against Paperclip App commit `18dac1e1` (24 September 2026), by
re-reading every cited file at that commit and running the command ladder below
through the isolated harness.**
**How this file cites things.** A file and a symbol, never a line number, and
never a transcribed count. Both were tried and both rotted: between `728f7185`
and `e558f25e` — nine days — several line numbers moved, and between `e558f25e`
and `18dac1e1` the store-visible count went from 46 to 56 and the candidate
count from 43 to 48. A reader who trusted a printed number would have written a
failing assertion. So the counts below are commands that read the number out of
the repository in front of you. Run them. If a symbol has moved or gone, the
commit wins — record the drift in your report so this file gets corrected.
## Files a minimal store-visible connector touches
| File | Role | Editable? |
| --- | --- | --- |
| `scripts/ingest-app-definitions.mjs` | Human-authored provider source. The `apps` array starts at line 180; the tuple mapper follows it (grep for `schemaVersion: 1,` inside the `.map((`, around line 795). | Yes — this is the source. |
| `scripts/ingest-app-definitions.mjs` | Human-authored provider source. The `apps` array and, after it, the tuple mapper — grep `const apps = [` and `schemaVersion: 1,`. | Yes — this is the source. |
| `ui/public/brands/apps/<slug>.svg` | Official mark. | Yes. |
| `ui/public/brands/apps/manifest.json` | Branding provenance: slug, provider name, `catalogVisible`, `localAsset`, optional `darkAsset`, optional `aliases`. | Yes. |
| `packages/shared/src/app-definitions.ts` | `CONNECTABLE_APP_SLUGS` (line 6) and `APP_STORE_HIDDEN_SLUGS` (line 44). | Yes. |
| `packages/shared/src/app-definitions.ts` | `CONNECTABLE_APP_SLUGS` and `APP_STORE_HIDDEN_SLUGS`. | Yes. |
| `packages/shared/src/types/app-definition.ts` | The field contract the generator output has to satisfy. | Read-only for authoring. |
| `packages/shared/src/app-definitions/<slug>.json` | Generated definition. | **Generated** — see the exception below. |
| `packages/shared/src/app-definitions.generated.ts` | Generated positional registry. | **Generated.** |
@@ -31,7 +36,7 @@ required for a store-visible provider.
## The generated-file exception
`scripts/ingest-app-definitions.mjs:1379-1396` (`reviewedGoogleSlugs`) reads nine
`scripts/ingest-app-definitions.mjs` (grep `reviewedGoogleSlugs`) reads nine
definitions back from the output directory and re-emits them verbatim:
```js
@@ -48,15 +53,15 @@ change the next run reverts.
## Generator preconditions
- **Corpus.** `scripts/ingest-app-definitions.mjs:4-9` resolves
- **Corpus.** `scripts/ingest-app-definitions.mjs` resolves
`PAPERCLIP_CONTENT_TEMPLATES`, defaulting to
`../../paperclip-content/research/connections/vercel/templates`. Line 1541-1542
throws `Expected 99 captures, found N` unless exactly 99 `.md` files
(excluding `INDEX.md`) are present. A new provider needs no capture of its
own; the corpus still has to be complete. The corpus lives in the
non-public `paperclip-content` repository: if you do not have it, this whole
path is closed to you and you report that rather than stubbing the guard out.
- **Branding.** `brandingFor` (declared line 20, throws line 29) throws
`../../paperclip-content/research/connections/vercel/templates`. It throws
`Expected 99 captures, found N` unless exactly 99 `.md` files (excluding
`INDEX.md`) are present. A new provider needs no capture of its own; the
corpus still has to be complete. The corpus lives in the non-public
`paperclip-content` repository: if you do not have it, this whole path is
closed to you and you report that rather than stubbing the guard out.
- **Branding.** `brandingFor` throws
`<slug>: missing local branding provenance` unless the slug has a manifest row
(only `oauth-generic` and `api-key-generic` are exempt). Branding precedes
generation.
@@ -73,7 +78,7 @@ change the next run reverts.
`content`, `data`, `developer`, `productivity`, `other`.
- The 5th element (`domain`) is discarded by the mapper. `docsUrl` must go in
`extra`, or it is only backfilled for providers that also appear in the
research ledger (`ingest-app-definitions.mjs:1315`).
research ledger (`ingest-app-definitions.mjs`, grep `docsUrl`).
- `method(key, transport, auth, defaults, riskTier, guidanceMd, extra)` fills
`ownershipModes` as `["customer", "dcr"]` for `oauth` and `["customer"]`
otherwise, plus a default `whenToUse`.
@@ -89,28 +94,70 @@ APP_DEFINITIONS (generated, all providers)
```
`GET /api/companies/:companyId/tools/apps` returns `APP_STORE_DEFINITIONS`
directly (`server/src/routes/tool-access.ts:831`). There is no company-scoped
definition store, and `connectToolAppSchema` accepts a `galleryKey` or a `link`
and nothing else (`packages/shared/src/validators/tool-access.ts:472-474`). A
catalog connector is therefore always a shared-source change.
directly (`server/src/routes/tool-access.ts`, grep `tools/apps`). There is no
company-scoped definition store, and `connectToolAppSchema` accepts a
`galleryKey` or a `link` and nothing else (`connectToolAppSchema` in
`packages/shared/src/validators/tool-access.ts`). A catalog connector is
therefore always a shared-source change.
### Three states, not two
The chain above is about *listing*. It is not the whole visibility contract,
and reading it as though it were is the mistake this file used to invite.
| State | How you set it | What a user can do |
| --- | --- | --- |
| **Store-visible** | In `CONNECTABLE_APP_SLUGS`, not in `APP_STORE_HIDDEN_SLUGS` | Sees the card, connects. |
| **Connectable but unlisted** | Add the slug to `APP_STORE_HIDDEN_SLUGS` | Does **not** see the card in the gallery, and can still connect by direct URL or by slug lookup. Hiding is not withholding. |
| **Withheld** | `availability: { available: false, reason }` on the definition | Cannot connect. The reason renders where the Connect action would be. |
The third one is the one to reach for when a connector is authored but not yet
proven against the provider — offline authoring legitimately produces a column
of "not run", and a definition in that state must not offer a Connect button
that cannot work.
`available: false` is enforced server-side, not just in the UI. Grep these
before trusting the label:
```sh
git grep -n 'availability?\.available === false' server/src ui/src
```
At `18dac1e1` that is refused by the metadata preflight (`notFound("App not
found")` in `server/src/services/tool-access.ts`), filtered out of agent
connection intents (`server/src/services/connection-intents.ts`), and rendered
as a disabled card with the reason in `ConnectionSetupFlow.tsx` and
`Browse.tsx`.
## Assertions with exact counts or sets
These fail on any addition. Update them deliberately.
| Assertion | Location | What breaks |
Read the current numbers before you touch anything. This prints every count
the catalog pins, out of the checkout in front of you:
```sh
grep -nE 'toHaveLength\(|verifiedAt\)\.toBe' packages/shared/src/app-definitions.test.ts
```
At `18dac1e1` that reports `APP_STORE_DEFINITIONS` 56, `SELF_SERVE_MCP_CANDIDATES`
48, `SELF_SERVE_MCP_RESEARCH.entries` 51, `verifiedAt` `"2026-08-26"`. Those four
are here to show you what the output looks like, not to be copied into an edit —
three of the four have already changed once since this file was written.
| Assertion | Where | What breaks |
| --- | --- | --- |
| `expect(APP_STORE_DEFINITIONS).toHaveLength(46)` | `app-definitions.test.ts:689`, in `it("withholds unverified and reserved providers …")` | Any new store-visible provider. Bump the count. |
| `catalogVisible` manifest set must equal the store-visible definition set, and asset paths must equal `branding` values | same test, from line 716 (`manifest.providers.filter(… catalogVisible)`) | A manifest row without a definition, or the reverse. Also enforces PNG ≥ 128×128 and rejects script/`foreignObject`/event handlers in SVG. |
| Required non-advanced tenant/extension fields enumerated in a short allowlist | `app-definitions.test.ts` around line 905 (`field.required && field.advanced !== true && !field.hidden`) | Any visible required field on the default path. Prefer making the field optional or advanced with a default. |
| `expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(43)` | `app-definitions.test.ts:280` | Adding a provider to the research ledger. |
| Ledger entry count with a fixed `verifiedAt` (`"2026-08-26"` at this commit) | `app-definitions.test.ts:434-435` | Same. Also requires HTTPS `docsUrl`/`serverUrl`, a non-empty `authMode`, a prerequisite longer than 10 characters, and a valid tier. |
| `APP_STORE_HIDDEN_SLUGS` exact sorted list | `app-definitions.test.ts:666-688` | Hiding a provider. Hidden slugs must still be connectable. |
| Method and field invariants across the whole catalog | `it("enforces method and field invariants")`, `app-definitions.test.ts:961` | A missing `keyPlacement`, empty `ownershipModes`, or a required credential field with no placeholder. |
| `expect(APP_STORE_DEFINITIONS).toHaveLength(N)` | `app-definitions.test.ts`, in `it("withholds unverified and reserved providers …")` | Any new store-visible provider. Set the count to what the command above reports, plus one. |
| `catalogVisible` manifest set must equal the store-visible definition set, and asset paths must equal `branding` values | same test — grep `manifest.providers.filter` | A manifest row without a definition, or the reverse. Also enforces PNG ≥ 128×128 and rejects script/`foreignObject`/event handlers in SVG. |
| Required non-advanced tenant/extension fields enumerated in a short allowlist | `app-definitions.test.ts` — grep `field.advanced !== true` | Any visible required field on the default path. Prefer making the field optional or advanced with a default. |
| `expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(N)` | `app-definitions.test.ts` | Adding a provider to the research ledger. |
| Ledger entry count with a fixed `verifiedAt` | `app-definitions.test.ts` — grep `SELF_SERVE_MCP_RESEARCH` | Same. Also requires HTTPS `docsUrl`/`serverUrl`, a non-empty `authMode`, a prerequisite longer than 10 characters, and a valid tier. |
| `APP_STORE_HIDDEN_SLUGS` exact sorted list | `app-definitions.test.ts` — grep `APP_STORE_HIDDEN_SLUGS` | Hiding a provider. Hidden slugs must still be connectable. |
| Method and field invariants across the whole catalog | `it("enforces method and field invariants")` | A missing `keyPlacement`, empty `ownershipModes`, or a required credential field with no placeholder. |
The provider-slug membership check above those uses `arrayContaining`, so an
addition does not break it. Confirmed at `e558f25e`: adding one store-visible
provider failed exactly one assertion, the count at line 689.
addition does not break it. Measured at `e558f25e`: adding one store-visible
provider failed exactly one assertion, the `APP_STORE_DEFINITIONS` count.
## Network and deployment guard
@@ -119,7 +166,7 @@ is *not* both authenticated and publicly exposed; link-local egress is denied in
every mode.
```ts
// server/src/services/tool-access.ts:3175-3180 (and tool-gateway.ts:3132-3137)
// allowPrivateRemoteEndpoints, in server/src/services/tool-access.ts
function allowPrivateRemoteEndpoints() {
return (
options.deploymentMode !== "authenticated" ||
@@ -130,7 +177,7 @@ function allowPrivateRemoteEndpoints() {
`DEPLOYMENT_MODES` is `["local_trusted", "authenticated"]` and
`DEPLOYMENT_EXPOSURES` is `["private", "public"]`
(`packages/shared/src/constants.ts:4-8`). The check runs inside
(`packages/shared/src/constants.ts`). The check runs inside
`guardedRemoteHttpFetch` at dial time rather than as a standalone pre-flight,
which is what closes the DNS-rebinding window — so a same-machine desktop MCP
endpoint is a local-deployment capability, not a configuration flag to widen.
@@ -163,7 +210,7 @@ manifest does nothing.
| Transport | Manifest-only? | Boundary |
| --- | --- | --- |
| `mcp_remote` | Yes | First-class: discovery, health, catalog, gateway, OAuth, credential projection. |
| `local_stdio` | Only via a registered template | Reported unsupported outside `local_trusted` mode or a configured trusted runtime host (`server/src/services/tool-access.ts:4788-4789`). Never put a bare command in a definition. |
| `local_stdio` | Only via a registered template | Reported unsupported outside `local_trusted` mode or a configured trusted runtime host (`server/src/services/tool-access.ts`, grep `local_stdio`). Never put a bare command in a definition. |
| `rest_api` | No | Not exposed through the connected MCP gateway. Needs an execution adapter first. |
`api_key` is an authentication mode, not a transport. Most API-key catalog
@@ -13,9 +13,9 @@ checkout is untouched.
This used to ship as `scripts/make-harness.sh` inside the skill. It is inlined
here on purpose: a skill package containing anything under `scripts/` derives
the `scripts_executables` trust level (`deriveTrustLevel` in
`packages/skills-catalog/src/catalog-builder.ts:763-767`), and the shipped
`packages/skills-catalog/src/catalog-builder.ts`), and the shipped
catalog pins that set to exactly one key
(`packages/skills-catalog/src/shipped-catalog.test.ts:132-136`). Carrying the
(`packages/skills-catalog/src/shipped-catalog.test.ts`). Carrying the
recipe as documentation keeps this package `markdown_only` and installable
without an audit-allowlist change. Save it to a file yourself and `chmod +x` it
if you prefer to run it as a script.
@@ -0,0 +1,377 @@
# Safe discovery fetches
Step 2 of this skill reads OAuth metadata out of an MCP server you do not
control and have not authenticated to. Two of the URLs you follow are chosen by
that server:
- `resource_metadata` in the `WWW-Authenticate` challenge (RFC 9728), and
- each entry of `authorization_servers` in the protected-resource document,
which becomes the issuer you build the RFC 8414 URL from.
Both arrive before any trust is established. A hostile or compromised endpoint
can point either one at `http://169.254.169.254/latest/meta-data/`, at a service
on `127.0.0.1`, at a name it published in public DNS with a loopback `A` record,
or at a host that answers the resolver honestly once and answers again with an
internal address a moment later. A plain `curl` of those values turns the
authoring machine into the attacker's HTTP client, inside whatever network the
author happens to be on.
So the two fetches go through `safe_curl` below instead of bare `curl`. It
validates the destination *before* anything is sent, pins the connection to the
addresses it validated, and follows no redirect.
## The rules
| Input | What happens |
| --- | --- |
| Any scheme other than `https:` | Refused. It is an allowlist, so `http:`, `file:`, `ftp:`, `gopher:` and `data:` all land here without being enumerated. |
| A URL with userinfo (`https://a:b@host/`) | Refused. Credentials never belong on an unauthenticated discovery fetch, and userinfo is the classic way to make a host look like something it is not. |
| Any port other than 443 | Refused. A provider that genuinely publishes discovery on another port is a gap to record and escalate, not a default to widen. |
| A host that resolves to loopback, private, CGNAT, link-local, unique-local, multicast, benchmarking, documentation or reserved space | Refused, and the reason names the address. This is on resolved addresses, so a public DNS name with a `127.0.0.1` record is caught. |
| An IPv4-mapped (`::ffff:a9fe:a9fe`), NAT64 (`64:ff9b::`) or 6to4 (`2002::`) address wrapping a blocked IPv4 address | Refused. The embedded address is extracted and run through the IPv4 rules. |
| A host with several `A`/`AAAA` records where **one** is blocked | Refused. One bad answer in a round-robin set refuses the whole host. |
| Anything resolvable and allowed | Fetched with `--resolve` pinned to exactly the addresses that were validated, so a second DNS answer cannot move the request after the check. |
| Any `3xx` response | Refused. The redirect is reported and not followed. To go there you re-run `safe_curl` on the `Location`, which validates it on its own merits. |
| A response over 1 MiB, or 15 seconds | Refused by `--max-filesize` / `--max-time`. |
Refusal is exit code 2 and means **nothing was sent**. The guard resolves names
but never opens a connection to the target.
### What this does not protect against
Say these out loud rather than letting the guard imply more than it does.
- **A publicly routable host that is still internal to you.** Split-horizon DNS,
a corporate proxy, or a VPN can make a globally-addressable answer reach
something private. Run discovery from a network position with no privileged
internal access; the guard cannot see your routing table.
- **A lying resolver.** The guard checks what `getaddrinfo` returns. If the
resolver itself is hostile, it can return an allowed address that fronts an
internal service.
- **The provider being malicious at the application layer.** Refusing internal
destinations says nothing about whether the metadata itself is honest. The
`issuer` cross-check in Step 2 — discard an authorization-server document
whose `issuer` disagrees with the issuer you built the URL from — is a
separate control and still required.
- **Anything after discovery.** This covers the two unauthenticated metadata
fetches. Registration, consent and token exchange run through the product's
own guarded path, not through this recipe.
## Why it is inlined here
Same reason as `offline-verification.md`: a skill package with anything under
`scripts/` derives the `scripts_executables` trust level
(`deriveTrustLevel` in `packages/skills-catalog/src/catalog-builder.ts`), and
the shipped catalog pins that set to one key. Carrying the guard as
documentation keeps this package `markdown_only` and installable with no
audit-allowlist change. Save both files yourself; the Node file needs no
`chmod`.
## The guard
Save as `mcp-discovery-guard.mjs`. It requires Node 18 or newer and no
dependencies. It prints `ALLOW <host> <port> <addr[,addr...]>` or
`REFUSE <reason>`, exiting 0 or 2.
<details>
<summary><code>mcp-discovery-guard.mjs</code></summary>
```javascript
// Resolve an untrusted discovery URL and decide whether it may be fetched.
// Prints "ALLOW <host> <port> <addr[,addr...]>" or "REFUSE <reason>", and
// exits 0 or 2. It never opens a connection to the target itself.
import { isIP } from "node:net";
import { lookup } from "node:dns/promises";
const refuse = (why) => {
process.stdout.write(`REFUSE ${why}\n`);
process.exit(2);
};
const raw = process.argv[2];
if (!raw) refuse("no URL given");
let url;
try {
url = new URL(raw);
} catch {
refuse(`not a URL: ${raw}`);
}
// Scheme allowlist, not a denylist: file:, gopher:, ftp:, data: and plain
// http: all land here.
if (url.protocol !== "https:") refuse(`scheme is not https: ${url.protocol}`);
if (url.username || url.password) refuse("URL carries userinfo");
const port = url.port === "" ? "443" : url.port;
if (port !== "443") refuse(`port is not 443: ${port}`);
const host = url.hostname.replace(/^\[|\]$/g, "");
if (!host) refuse("URL has no host");
const v4Blocks = [
["0.0.0.0", 8, "this-network"],
["10.0.0.0", 8, "private"],
["100.64.0.0", 10, "carrier-grade NAT"],
["127.0.0.0", 8, "loopback"],
["169.254.0.0", 16, "link-local / cloud metadata"],
["172.16.0.0", 12, "private"],
["192.0.0.0", 24, "IETF protocol assignments"],
["192.0.2.0", 24, "documentation"],
["192.88.99.0", 24, "6to4 relay anycast"],
["192.168.0.0", 16, "private"],
["198.18.0.0", 15, "benchmarking"],
["198.51.100.0", 24, "documentation"],
["203.0.113.0", 24, "documentation"],
["224.0.0.0", 4, "multicast"],
["240.0.0.0", 4, "reserved / broadcast"],
];
const v4ToInt = (a) =>
a.split(".").reduce((acc, oct) => acc * 256 + Number(oct), 0);
const classifyV4 = (addr) => {
const n = v4ToInt(addr);
for (const [base, bits, label] of v4Blocks) {
const mask = bits === 0 ? 0 : (-1 << (32 - bits)) >>> 0;
if ((n & mask) >>> 0 === (v4ToInt(base) & mask) >>> 0) return label;
}
return null;
};
// Expand any IPv6 form to 16 bytes.
const v6Bytes = (addr) => {
let head = addr;
let tailV4 = null;
const lastColon = addr.lastIndexOf(":");
const maybeV4 = addr.slice(lastColon + 1);
if (maybeV4.includes(".")) {
if (isIP(maybeV4) !== 4) return null;
tailV4 = maybeV4.split(".").map(Number);
head = addr.slice(0, lastColon + 1) + "0:0";
}
const [left, right] = head.split("::");
const parse = (part) =>
part && part.length ? part.split(":").map((h) => parseInt(h, 16)) : [];
const l = parse(left);
const r = right === undefined ? [] : parse(right);
const groups =
right === undefined
? l
: [...l, ...Array(8 - l.length - r.length).fill(0), ...r];
if (groups.length !== 8) return null;
const bytes = [];
for (const g of groups) bytes.push((g >> 8) & 0xff, g & 0xff);
if (tailV4) bytes.splice(12, 4, ...tailV4);
return bytes;
};
const bytesToV4 = (b) => b.join(".");
const classifyV6 = (addr) => {
const b = v6Bytes(addr);
if (!b) return "unparseable IPv6 address";
const zero = (from, to) => b.slice(from, to).every((x) => x === 0);
if (zero(0, 16)) return "unspecified address";
if (zero(0, 15) && b[15] === 1) return "loopback";
// ::ffff:0:0/96 — IPv4-mapped. Check the embedded address under v4 rules.
if (zero(0, 10) && b[10] === 0xff && b[11] === 0xff) {
const inner = bytesToV4(b.slice(12));
return classifyV4(inner) ? `IPv4-mapped ${inner} (${classifyV4(inner)})` : null;
}
// 64:ff9b::/96 — NAT64. Same treatment.
if (b[0] === 0x00 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && zero(4, 12)) {
const inner = bytesToV4(b.slice(12));
return classifyV4(inner) ? `NAT64 ${inner} (${classifyV4(inner)})` : null;
}
// 2002::/16 — 6to4. The embedded v4 is bytes 2-5.
if (b[0] === 0x20 && b[1] === 0x02) {
const inner = bytesToV4(b.slice(2, 6));
return classifyV4(inner) ? `6to4 ${inner} (${classifyV4(inner)})` : null;
}
if (b[0] === 0x01 && b[1] === 0x00 && zero(2, 8)) return "discard-only";
if (b[0] === 0x20 && b[1] === 0x01 && (b[2] & 0xfe) === 0x0d && b[3] === 0xb8)
return "documentation";
if (b[0] === 0x20 && b[1] === 0x01 && b[2] < 0x02) return "IETF protocol assignments";
if ((b[0] & 0xfe) === 0xfc) return "unique local";
if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) return "link-local";
if (b[0] === 0xff) return "multicast";
return null;
};
const classify = (addr) => (isIP(addr) === 4 ? classifyV4(addr) : classifyV6(addr));
let addresses;
const literal = isIP(host);
if (literal) {
addresses = [host];
} else {
try {
// getaddrinfo, the same resolution curl performs.
addresses = (await lookup(host, { all: true, verbatim: true })).map(
(r) => r.address,
);
} catch (err) {
refuse(`DNS lookup failed for ${host}: ${err.code ?? err.message}`);
}
}
if (!addresses.length) refuse(`no addresses for ${host}`);
// Every address must pass. One bad answer in a round-robin set is enough to
// refuse the whole host.
for (const addr of addresses) {
const bad = classify(addr);
if (bad) refuse(`${host} resolves to ${addr} (${bad})`);
}
process.stdout.write(`ALLOW ${host} ${port} ${addresses.join(",")}\n`);
```
</details>
## The fetch wrapper
Save as `safe-fetch.sh` next to the guard and `source` it. It needs curl 7.59
or newer for the comma-separated `--resolve` form.
<details>
<summary><code>safe-fetch.sh</code></summary>
```bash
#!/usr/bin/env bash
# safe_curl <url> [extra curl args...]
#
# Fetch a URL that an unauthenticated remote server chose for you.
#
# * validates the destination before anything is sent,
# * pins the connection to the addresses it validated, so a second DNS
# answer cannot move the request after the check,
# * follows no redirect, ever.
#
# The body goes to stdout. The status line and response headers go to stderr,
# so `safe_curl "$URL" | jq .` and `safe_curl "$URL" 2>&1 | grep -i www-auth`
# both work. Exit 2 means refused and nothing was sent.
# The Node guard that classifies the destination. Defaults to the file next to
# this one; override with MCP_DISCOVERY_GUARD.
MCP_DISCOVERY_GUARD=${MCP_DISCOVERY_GUARD:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/mcp-discovery-guard.mjs}
safe_curl() {
local url=$1; shift
local verdict host port addrs body headers status rc
[ -f "$MCP_DISCOVERY_GUARD" ] || {
printf 'safe_curl: guard not found: %s\n' "$MCP_DISCOVERY_GUARD" >&2; return 2; }
verdict=$(node "$MCP_DISCOVERY_GUARD" "$url")
if [ $? -ne 0 ]; then
printf 'safe_curl: %s\n' "${verdict#REFUSE }" >&2; return 2
fi
read -r _ host port addrs <<<"$verdict"
body=$(mktemp); headers=$(mktemp)
status=$(curl -sS \
--proto '=https' --tlsv1.2 \
--resolve "$host:$port:$addrs" \
--max-redirs 0 --max-time 15 --max-filesize 1048576 \
-D "$headers" -o "$body" -w '%{http_code}' \
"$@" "$url")
rc=$?
if [ $rc -ne 0 ]; then
printf 'safe_curl: transport error (curl exit %s) for %s\n' "$rc" "$url" >&2
rm -f "$body" "$headers"; return 3
fi
case $status in
3??)
printf 'safe_curl: %s answered %s -> %s\n' "$url" "$status" \
"$(awk 'tolower($1)=="location:"{print $2}' "$headers" | tr -d '\r')" >&2
printf 'safe_curl: redirect not followed. Run safe_curl against that URL to validate it on its own merits.\n' >&2
rm -f "$body" "$headers"; return 2 ;;
esac
cat "$headers" >&2
cat "$body"
rm -f "$body" "$headers"
return 0
}
```
</details>
## Executed evidence
Run 2026-09-24 on Linux aarch64, Node v24.20.0, curl 8.5.0.
### Adversarial metadata, end to end
Not a synthetic URL list: a hostile `WWW-Authenticate` challenge and two hostile
protected-resource documents, parsed exactly the way Step 2 tells you to parse
them, then fetched.
```
resource_metadata from challenge: https://localtest.me/.well-known/oauth-protected-resource
safe_curl: localtest.me resolves to ::1 (loopback)
-> exit 2
issuer from protected-resource doc: http://169.254.169.254/latest/meta-data/
safe_curl: scheme is not https: http:
-> exit 2
issuer from protected-resource doc: https://[::ffff:169.254.169.254]
safe_curl: ::ffff:a9fe:a9fe resolves to ::ffff:a9fe:a9fe (IPv4-mapped 169.254.169.254 (link-local / cloud metadata))
-> exit 2
```
`localtest.me` is the case a string check cannot catch: an ordinary-looking
public hostname with a real public DNS record pointing at loopback. Nothing in
the URL text is suspicious. Only resolution reveals it.
### Every refusal class
```
http://wisdom-api.enterpret.com/x REFUSE scheme is not https: http:
file:///etc/passwd REFUSE scheme is not https: file:
gopher://wisdom-api.enterpret.com/ REFUSE scheme is not https: gopher:
https://127.0.0.1/.well-known/oauth-protected-resource REFUSE 127.0.0.1 resolves to 127.0.0.1 (loopback)
https://localhost/x REFUSE localhost resolves to 127.0.0.1 (loopback)
https://[::1]/x REFUSE ::1 resolves to ::1 (loopback)
https://169.254.169.254/latest/meta-data/iam/security-credentials/ REFUSE 169.254.169.254 resolves to 169.254.169.254 (link-local / cloud metadata)
https://[fd00::1]/x REFUSE fd00::1 resolves to fd00::1 (unique local)
https://[fe80::1]/x REFUSE fe80::1 resolves to fe80::1 (link-local)
https://10.0.0.5/x REFUSE 10.0.0.5 resolves to 10.0.0.5 (private)
https://192.168.1.1/x REFUSE 192.168.1.1 resolves to 192.168.1.1 (private)
https://172.16.0.1/x REFUSE 172.16.0.1 resolves to 172.16.0.1 (private)
https://100.64.0.1/x REFUSE 100.64.0.1 resolves to 100.64.0.1 (carrier-grade NAT)
https://[::ffff:127.0.0.1]/x REFUSE ::ffff:7f00:1 resolves to ::ffff:7f00:1 (IPv4-mapped 127.0.0.1 (loopback))
https://[64:ff9b::7f00:1]/x REFUSE 64:ff9b::7f00:1 resolves to 64:ff9b::7f00:1 (NAT64 127.0.0.1 (loopback))
https://[2002:7f00:1::]/x REFUSE 2002:7f00:1:: resolves to 2002:7f00:1:: (6to4 127.0.0.1 (loopback))
https://attacker:pw@wisdom-api.enterpret.com/x REFUSE URL carries userinfo
https://wisdom-api.enterpret.com:8443/x REFUSE port is not 443: 8443
https://localtest.me/x REFUSE localtest.me resolves to ::1 (loopback)
not-a-url REFUSE not a URL: not-a-url
```
### Redirects are refused, not followed
```
$ safe_curl "https://google.com/"
safe_curl: https://google.com/ answered 301 -> https://www.google.com/
safe_curl: redirect not followed. Run safe_curl against that URL to validate it on its own merits.
exit=2
```
### The documented flow still works
The same two fetches Step 2 asks for, against a real public provider:
```
$ safe_curl "https://wisdom-api.enterpret.com/server/mcp" -X POST \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}'
HTTP/2 401
www-authenticate: Bearer realm="mcp", resource_metadata="https://wisdom-api.enterpret.com/server/mcp/.well-known/oauth-protected-resource", scope="mcp:read mcp:write"
$ safe_curl "https://wisdom-api.enterpret.com/server/mcp/.well-known/oauth-protected-resource"
{"resource":"https://wisdom-api.enterpret.com/server/mcp","authorization_servers":["https://oauth.enterpret.com"],"scopes_supported":["email","mcp:read","mcp:write"]}
```
A guard that refused the normal path too would just get switched off. It does
not.
@@ -38,6 +38,12 @@ behind it — commands, observed output, and the correlated agent run — is in
[`references/verification-log.md`](references/verification-log.md), so you can
redo it rather than take the label on trust.
**How this skill cites the product.** A file and a symbol you can grep for,
never a line number: line numbers drift between the read and the reader, and
this skill has already been caught citing four that had moved. Every source and
runbook citation below was re-read at App commit `18dac1e1` (24 September 2026).
If the commit in front of you disagrees, the commit wins — record the drift.
## Use This When
- An agent cannot reach a tool it needs, and nobody has established why.
@@ -210,17 +216,17 @@ Self-hosted does not imply desktop-compatible. A locally launched MCP server
that only calls a vendor's hosted API is portable; one that drives an open
desktop app, local files or a private network needs those where the runtime is.
Two configuration facts decide most of what follows, both at App commit
`9335b7db10`:
Two configuration facts decide most of what follows, both re-read at
`18dac1e1`:
- `local_trusted` **forces** exposure to `private`
(`server/src/config.ts:187-190`). A shape-A instance can never reach the
- `local_trusted` **forces** exposure to `private` (`deploymentExposure` in
`server/src/config.ts`). A shape-A instance can never reach the
`authenticated` + `public` combination that the private-endpoint guard
refuses.
- Paperclip refuses private, loopback and reserved MCP addresses only on a
deployment that is both authenticated and publicly exposed
(`allowPrivateRemoteEndpoints` at `server/src/services/tool-access.ts:3195-3200`,
mirrored in `tool-gateway.ts:3135-3140`), and refuses link-local addresses in
(`allowPrivateRemoteEndpoints` at `server/src/services/tool-access.ts`,
mirrored in `tool-gateway.ts`), and refuses link-local addresses in
every mode (`server/src/services/remote-http-endpoint-guard.ts`).
Read those at the commit in front of you and record it. Never propose a tunnel,
@@ -231,10 +237,11 @@ always can.** `GET /api/companies/:companyId/tools/runtime-health` returns the
live `supportMatrix` for the instance and requires board access: a run-scoped
agent credential gets `403 {"error":"Board access required"}`, while a board
actor on an instance they own reads it fine (both executed 19 September 2026;
route at `server/src/routes/tool-access.ts:2500-2504`). So if you are the agent,
ask the operator to run it and paste the `supportMatrix` block, or read the
configuration directly. Do not guess it, and do not report the `403` as though
the surface were unavailable — it is available to the person you are asking.
route at the `tools/runtime-health` handler in
`server/src/routes/tool-access.ts`). So if you are the agent, ask the operator
to run it and paste the `supportMatrix` block, or read the configuration
directly. Do not guess it, and do not report the `403` as though the surface
were unavailable — it is available to the person you are asking.
Label every capability you establish **verified**, **untested**, **unsupported**
or **deferred**, each with its reason. Fill in a copy of
@@ -254,7 +261,7 @@ governing principle: "A catalog entry is a convenience layer, not a
prerequisite. An operator can connect any standards-compliant remote HTTP MCP
server from **Connect your own MCP server** or **Paste a config** with no
Paperclip code change at all — including servers that need browser sign-in."
(`CONNECTOR-PLAYBOOK.md:23-26`.)
(`CONNECTOR-PLAYBOOK.md`, opening section.)
1. **An existing connector.** Check the catalog and the provider pages first. If
a connector exists and the workflow still fails, this is a configuration,
@@ -268,13 +275,14 @@ Paperclip code change at all — including servers that need browser sign-in."
Name which condition failed rather than improvising.
**None of those four conditions needs Paperclip Cloud.** DCR is
instance-local: each instance registers its own public client against its own
`/api/tools/oauth/callback`, and "Cloud-hosted and self-hosted instances use
the SAME path — the only per-instance difference is the hostname inside the
redirect URI" (`CONNECTOR-PLAYBOOK.md:1569-1578`). CIMD is the one tier that
needs a public HTTPS `PAPERCLIP_PUBLIC_URL`, and a deployment without one
falls through to DCR rather than failing. A self-hoster whose provider cannot
do DCR registers their own client and sets
instance-local: each instance registers its own public client against its
own `/api/tools/oauth/callback`, and "Cloud-hosted and self-hosted
instances use the SAME path — the only per-instance difference is the
hostname inside the redirect URI" (`CONNECTOR-PLAYBOOK.md`, **Dynamic
client registration (RFC 7591)** — grep `use the SAME path`). CIMD is the
one tier that needs a public HTTPS `PAPERCLIP_PUBLIC_URL`, and a deployment
without one falls through to DCR rather than failing. A self-hoster whose
provider cannot do DCR registers their own client and sets
`PAPERCLIP_TOOL_OAUTH_<PROVIDER>_CLIENT_ID` / `_SECRET`. The only genuinely
Cloud-gated path is the curated `platform_shared` Paperclip-managed OAuth
profile; if that is the only option, say so and stop rather than implying a
@@ -316,22 +324,23 @@ anything:
- Start writes at **Off** on a server nobody has reviewed and promote
deliberately. Re-run **Refresh actions** after the server changes.
**Then re-check the profile, because promotion is not durable.** At App commit
`9335b7db10`, a connection created from a pasted URL is flagged
`unverifiedServer: true` and `quarantineNewEntries: false` in the same
expression (`server/src/services/tool-access.ts:12721`), so every catalog
refresh auto-allows whatever new tools the provider has started advertising —
including ones classified `destructive`. Verified by execution; see **F1** in
**Then re-check the profile, because promotion is not durable.** A
connection created from a pasted URL is flagged `unverifiedServer: true` and
`quarantineNewEntries: false` in the same expression — grep
`unverifiedServer` in `server/src/services/tool-access.ts` — so every catalog
refresh auto-allows whatever new tools the provider has started advertising,
including ones classified `destructive`. Verified by execution at
`9335b7db10` and still present at `18dac1e1`; see **F1** in
[`references/deployment-support-matrix.md`](references/deployment-support-matrix.md).
On a server you do not control, the tool set is provider-controlled, not
operator-controlled. Say that out loud in your report.
- **Check that the connection is installed on the agent, not merely permitted.**
Finishing the wizard with `access: {agentIds: [...]}` writes the tool-profile
binding but no install row, and readiness needs both
(`server/src/services/connection-intents.ts:241-248`). The symptom is a green
**Connected** badge and an allowed tool the agent cannot see. Verified on two
instances — **F10** in the same reference.
- **Check that the connection is installed on the agent, not merely
permitted.** Finishing the wizard with `access: {agentIds: [...]}` writes
the tool-profile binding but no install row, and readiness needs both (the
`usable` predicate in `server/src/services/connection-intents.ts`). The
symptom is a green **Connected** badge and an allowed tool the agent cannot
see. Verified on two instances — **F10** in the same reference.
```sh
curl -s -H "Authorization: Bearer $TOKEN" \
@@ -402,7 +411,8 @@ so contributors no longer need a private validation issue for it. Its nine
scenarios are setup and consent, authentication, catalog and configuration,
allowed execution, denied execution, runtime delivery, refresh and recovery,
revoke and reconnect, and activity and secret handling
(`CONNECTOR-PLAYBOOK.md:1445-1455`).
(`CONNECTOR-PLAYBOOK.md`, **Step 9: Align With Production Validation** — the
scenario table).
Record **pass**, **fail**, **not run** or **not applicable** per scenario with a
reason for the last two, plus environment, method key, commit, reproduction
@@ -38,7 +38,7 @@ instance's configuration, not from a product tier name.
| Deployment mode | `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted`, `authenticated` | `local_trusted` |
| Exposure | `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private`, `public` | `private` |
Source: `packages/shared/src/constants.ts:4-8` and `server/src/config.ts:170-190`
Source: `packages/shared/src/constants.ts` and `server/src/config.ts`
at Paperclip App commit `9335b7db10`. Both are also settable as
`config.server.deploymentMode` / `config.server.exposure` in the instance's
`config.json`, which is what `config.ts` reads after the environment.
@@ -46,7 +46,8 @@ at Paperclip App commit `9335b7db10`. Both are also settable as
One consequence is easy to miss and worth writing down, because it is what makes
the same-machine self-hosted path so capable:
> `local_trusted` **forces** exposure to `private`. `server/src/config.ts:187-190`
> `local_trusted` **forces** exposure to `private`. `deploymentExposure` in
`server/src/config.ts`
> ignores `PAPERCLIP_DEPLOYMENT_EXPOSURE` when the mode is `local_trusted`.
So a same-machine self-hosted instance can never land in the
@@ -75,7 +76,7 @@ Both were hit while filling this matrix, on `authenticated` + `public`:
A public self-hosted instance needs an external PostgreSQL.
2. **An explicit, resolvable public base URL.** `auth.baseUrlMode` must be
explicit and `auth.publicBaseUrl` is required
(`packages/shared/src/config-schema.ts:167-187`). This value is not only
(`packages/shared/src/config-schema.ts`). This value is not only
browser-facing: the runtime hands it to spawned agents as their control-plane
URL, so it must resolve **from the machine the runtime runs on**, not just
from your browser. A placeholder hostname produces `getaddrinfo ENOTFOUND` on
@@ -114,7 +115,7 @@ this package: [`verification-log.md`](./verification-log.md).
| Capability | A. Self-hosted, same machine | B. Self-hosted, server/VPS | C. Paperclip Cloud | Evidence |
| --- | --- | --- | --- | --- |
| `mcp_remote` to a public provider endpoint | `untested` | **`verified`** on `authenticated` + `public` | `untested` | B: DeepWiki connected, catalog refreshed to 3 tools, `read_wiki_structure` executed through the gateway, `decision: allowed`. |
| `mcp_remote` to a loopback endpoint (`127.0.0.1`) | **`verified`** | **`verified`** on `private`; **`unsupported`** on `public` | `unsupported` | A: connected, `healthStatus ok`, 7 tools, real agent runs against it. B `public`: `HTTP 400 {"code":"remote_http_private_endpoint"}` on connect, `HTTP 502` on refresh. Guard: `allowPrivateRemoteEndpoints`, `server/src/services/tool-access.ts:3195-3200`. |
| `mcp_remote` to a loopback endpoint (`127.0.0.1`) | **`verified`** | **`verified`** on `private`; **`unsupported`** on `public` | `unsupported` | A: connected, `healthStatus ok`, 7 tools, real agent runs against it. B `public`: `HTTP 400 {"code":"remote_http_private_endpoint"}` on connect, `HTTP 502` on refresh. Guard: `allowPrivateRemoteEndpoints`, `server/src/services/tool-access.ts`. |
| `mcp_remote` to a link-local address | `untested` | `untested` | `untested` | `isAlwaysDeniedLinkLocalIp` in `server/src/services/remote-http-endpoint-guard.ts` denies in every mode. Source-cited only; no live call was made. |
| `local_stdio` with an approved template | `untested` — instance declares it **supported** | `untested` — instance declares it **unsupported** in both exposures | `untested` | The live `supportMatrix` differs by shape (blocks quoted in the verification report). No stdio connection was created, so the capability itself is untested everywhere. |
| `rest_api` through the connected MCP gateway | `untested` | `untested` | `untested` | Not exposed through the gateway; needs an execution adapter. Runbook "Transport support and boundaries". |
@@ -131,12 +132,12 @@ Pick the shape that matches the provider.
| --- | --- | --- | --- | --- |
| `auth: "none"` remote MCP | **`verified`** | **`verified`** (both exposures) | `untested` | Connect → catalog → gateway execution on both instances. |
| API key / PAT in a header | **`verified`** | `untested` | `untested` | A: connection with `credentialRefs: [{placement: "header", key: "x-api-key", secretId}]`; the provider's request log shows the header arrived, and the value appears nowhere in Paperclip (see redaction row). |
| OAuth via dynamic client registration (RFC 7591) | `untested` | `untested` | `untested` | **DCR is instance-local.** Each instance registers its own public client against its own `/api/tools/oauth/callback`. "Cloud-hosted and self-hosted instances use the SAME path — the only per-instance difference is the hostname inside the redirect URI." `CONNECTOR-PLAYBOOK.md:1569-1578`. Consent needs an authorized provider account; not exercised. |
| OAuth via dynamic client registration (RFC 7591) | `untested` | `untested` | `untested` | **DCR is instance-local.** Each instance registers its own public client against its own `/api/tools/oauth/callback`. "Cloud-hosted and self-hosted instances use the SAME path — the only per-instance difference is the hostname inside the redirect URI." `CONNECTOR-PLAYBOOK.md`, **Dynamic client registration (RFC 7591)**. Consent needs an authorized provider account; not exercised. |
| OAuth via CIMD | `untested`; falls through to DCR on loopback/plain HTTP | `untested` with a public HTTPS origin; falls through without one | `untested` | CIMD "requires a public HTTPS base URL … the authorization server has to fetch that document server-to-server, so loopback and plain-HTTP deployments fall through to the next tier." `GENERIC-REMOTE-MCP.md:94-104`. Neither test instance had a public HTTPS origin. |
| OAuth with a client you registered yourself | `untested` | `untested` | `untested` | Deployment-preconfigured `PAPERCLIP_TOOL_OAUTH_<PROVIDER>_CLIENT_ID` / `_SECRET` outranks every other tier — `server/src/services/tool-access.ts:8787-8804`. Not exercised. |
| OAuth redirect from a plain-HTTP non-loopback origin | n/a — loopback is HTTP-allowed | **`unsupported`** for `https-or-loopback-http` providers | n/a — Cloud is HTTPS | Fails fast with `oauth_redirect_origin_unsupported`; configure TLS. `CONNECTOR-PLAYBOOK.md:1580-1594`. |
| OAuth with a client you registered yourself | `untested` | `untested` | `untested` | Deployment-preconfigured `PAPERCLIP_TOOL_OAUTH_<PROVIDER>_CLIENT_ID` / `_SECRET` outranks every other tier — `safeOAuthEndpointUrl` in `server/src/services/tool-access.ts`. Not exercised. |
| OAuth redirect from a plain-HTTP non-loopback origin | n/a — loopback is HTTP-allowed | **`unsupported`** for `https-or-loopback-http` providers | n/a — Cloud is HTTPS | Fails fast with `oauth_redirect_origin_unsupported`; configure TLS. `CONNECTOR-PLAYBOOK.md`, **Redirect-URI constraints**. |
| Provider-generated secret-bearing URL | `untested` | `untested` | `untested` | Generic runtime path is complete. Treat the URL as a credential. |
| Paperclip-managed OAuth (`platform_shared`) | **`unsupported`** | **`unsupported`** | `untested` | Requires a reviewed Cloud connector profile and Cloud's fixed provider callback. `CONNECTOR-PLAYBOOK.md:184`. |
| Paperclip-managed OAuth (`platform_shared`) | **`unsupported`** | **`unsupported`** | `untested` | Requires a reviewed Cloud connector profile and Cloud's fixed provider callback. `CONNECTOR-PLAYBOOK.md`, **Authentication support matrix**. |
**No Cloud account is required for self-hosted OAuth.** The playbook is explicit
that DCR needs neither Paperclip ID nor Paperclip Connect, and that
@@ -149,7 +150,7 @@ one row of this table, not the default path. Note that the whole OAuth block is
| Capability | A. Same machine | B. Server/VPS | C. Cloud | Evidence |
| --- | --- | --- | --- | --- |
| Connect by URL, no code change | **`verified`** | **`verified`** | `untested` | `POST …/tools/apps/connect` with a pasted link, then `/finish`. No catalog entry authored; `CONNECTOR-PLAYBOOK.md:23-26` says none is needed, and none was. |
| Connect by URL, no code change | **`verified`** | **`verified`** | `untested` | `POST …/tools/apps/connect` with a pasted link, then `/finish`. No catalog entry authored; `CONNECTOR-PLAYBOOK.md` (opening section, grep `Connect your own MCP server`) says none is needed, and none was. |
| Catalog discovery and risk classification | **`verified`** | **`verified`** | `untested` | A: `read` / `write` / `destructive` assigned correctly across 7 tools with no manual input. |
| Effective policy on the acting agent | **`verified`** | **`verified`** | `untested` | A: `allowedToolNames` moved `[] → [list_notes, read_note] → +create_note` as entries were added; an ungranted agent stayed `[]`. |
| Narrow read through the gateway | **`verified`** | **`verified`** | `untested` | A: two agent runs. B: DeepWiki `read_wiki_structure`. |
@@ -207,13 +208,13 @@ findings that do not change the advice.
### F1 — a pasted MCP server can widen its own permissions after approval
On every catalog refresh, newly discovered entries are added to the connection's
active managed profile unless `quarantineNewEntries` is set — and for a
**user-pasted URL** it is explicitly set to `false`
(`server/src/services/tool-access.ts:12721`, in the same expression that flags
the connection `unverifiedServer: true`). Curated gallery apps and
Paperclip-managed cloud connectors *do* quarantine. The least-trusted class does
not.
On every catalog refresh, newly discovered entries are added to the
connection's active managed profile unless `quarantineNewEntries` is set — and
for a **user-pasted URL** it is explicitly set to `false`
(`server/src/services/tool-access.ts`, grep `unverifiedServer: true`, in the
same expression that flags the connection `unverifiedServer: true`). Curated
gallery apps and Paperclip-managed cloud connectors *do* quarantine. The
least-trusted class does not.
Executed: a connection finished with exactly two tools enabled and access limited
to one agent. The provider then advertised `export_notes`, which returns every
@@ -249,11 +250,12 @@ for permission questions, and a real agent run for execution questions.
`POST …/tools/apps/{id}/finish` with `access: {agentIds: [...]}` creates the
tool-profile binding but **no `tool_connection_install` row**. Agent readiness
requires both (`server/src/services/connection-intents.ts:241-248`), so the tool
never enters the agent's session. Both wizard-created connections, on both
instances, showed `installs: []` alongside a correct `allowedToolNames`. The
board's access view showed the tool allowed and the connection green; the agent
got `needs_user_action` and could not run.
requires both (the `usable` predicate in
`server/src/services/connection-intents.ts`), so the tool never enters the
agent's session. Both wizard-created connections, on both instances, showed
`installs: []` alongside a correct `allowedToolNames`. The board's access view
showed the tool allowed and the connection green; the agent got
`needs_user_action` and could not run.
**What this means for the skill's advice.** After finishing the wizard, check
`GET /api/tool-connections/{id}/installs` — not just the effective profile.
@@ -27,12 +27,12 @@ Read from each instance's `config.json` and confirmed against its live
`/api/health`. Neither instance had `PAPERCLIP_DEPLOYMENT_MODE`,
`PAPERCLIP_DEPLOYMENT_EXPOSURE`, or `PAPERCLIP_PUBLIC_URL` set in the
environment; the equivalent `config.json` keys are the source, which is what
`server/src/config.ts:170-190` reads after the environment.
`server/src/config.ts` reads after the environment.
| | **Instance A — shape A** | **Instance B — shape B** |
| --- | --- | --- |
| Deployment mode | `config.server.deploymentMode = local_trusted` | `config.server.deploymentMode = authenticated` |
| Exposure | **forced** `private` by `config.ts:187-190` | run as `private` **and** `public` |
| Exposure | **forced** `private` by `deploymentExposure` in `config.ts` | run as `private` **and** `public` |
| Public base URL | not set, not needed | `auth.publicBaseUrl` set and mandatory — see F7 |
| Bind | loopback `127.0.0.1:3810` | loopback `127.0.0.1:3820` |
| Database | embedded PostgreSQL | **external** PostgreSQL — embedded is refused, see F7 |
@@ -97,8 +97,9 @@ public exposure; the private-endpoint refusal happens at connect time, not here.
account on that provider. The unauthenticated metadata probe in the skill's
quickstart is current; the consent half stays `untested`.
No catalog definition was authored. `CONNECTOR-PLAYBOOK.md:23-26` says the
pasted-URL path needs no code change, and it did not.
No catalog definition was authored. `CONNECTOR-PLAYBOOK.md` (opening section,
grep `Connect your own MCP server`) says the pasted-URL path needs no code
change, and it did not.
### Provider evidence record
@@ -114,10 +115,11 @@ pasted-URL path needs no code change, and it did not.
## 3. The nine production-validation scenarios
These are the runbook's scenarios (`doc/connections/CONNECTOR-PLAYBOOK.md:1445-1455`).
Environment for every row unless stated otherwise: **instance A**,
`local_trusted` + `private`, commit `9335b7db10`, method key
`connect-your-own-mcp-server` (pasted URL, `authMode: none`).
These are the runbook's scenarios (`doc/connections/CONNECTOR-PLAYBOOK.md`,
**Step 9: Align With Production Validation**). Environment for every row
unless stated otherwise: **instance A**, `local_trusted` + `private`, commit
`9335b7db10`, method key `connect-your-own-mcp-server` (pasted URL, `authMode:
none`).
### 1. Setup and consent — pass
@@ -229,15 +231,15 @@ Environment for every row unless stated otherwise: **instance A**,
invocation for `create-note`. That correlation is the whole proof: the agent,
not the tester, did the work, and it went through the managed connection.
- **Instance B, `authenticated` + `public`.** Run `f027aa16…`, `succeeded`, one
correlated invocation:
`mcp.app-gallery-link-…:read-wiki-structure | allow | succeeded | args {"repoName": "paperclipai/paperclip"}`.
The agent reported DeepWiki's 13 top-level sections verbatim, and separately
attempted `read_wiki_contents` — the tool it was *not* granted at `finish` —
which failed at local dispatch with no request reaching DeepWiki. So shape B
has an allowed agent execution and a hidden-tool denial in the same run,
against a real third-party provider. No provider-side readback there: DeepWiki
is read-only.
- **Instance B, `authenticated` + `public`.** Run `f027aa16…`, `succeeded`,
one correlated invocation: `mcp.app-gallery-link-…:read-wiki-structure |
allow | succeeded | args {"repoName": "paperclipai/paperclip"}`. The agent
reported DeepWiki's 13 top-level sections verbatim, and separately attempted
`read_wiki_contents` — the tool it was *not* granted at `finish` — which
failed at local dispatch with no request reaching DeepWiki. So shape B has
an allowed agent execution and a hidden-tool denial in the same run, against
a real third-party provider. No provider-side readback there: DeepWiki is
read-only.
- **It took three shape-B runs to get there, and both failures were findings
rather than flakes.** Run 1 failed on `ENOTFOUND` because the mandatory
`auth.publicBaseUrl` is also what the runtime hands spawned agents as their
@@ -264,11 +266,12 @@ Environment for every row unless stated otherwise: **instance A**,
`POST …/grants/installations {"isDefault": true}` → a live gateway call.
- **Expected.** Revocation blocks later execution; reconnect reuses the intended
identity.
- **Actual.** After revocation the gateway refuses:
`{"error": {"message": "Organization authorization is required", "reasonCode": "organization_authorization_required"}}`,
logged as `call_failed`. Disabling the connection refuses earlier, at policy:
`deny / deny_disabled_connection`. After re-adding the installation the same
read succeeds again, on the same connection id, with a new grant id and no
- **Actual.** After revocation the gateway refuses: `{"error": {"message":
"Organization authorization is required", "reasonCode":
"organization_authorization_required"}}`, logged as `call_failed`. Disabling
the connection refuses earlier, at policy: `deny /
deny_disabled_connection`. After re-adding the installation the same read
succeeds again, on the same connection id, with a new grant id and no
duplicate connection. The gap is **F4**.
### 9. Activity and secret handling — pass
@@ -306,13 +309,13 @@ them at the step where they bite. The rest are recorded here only.
### F1 — a pasted MCP server can widen its own permissions after approval
On every catalog refresh, newly discovered entries join the connection's active
managed profile unless `quarantineNewEntries` is set — and for a **user-pasted
URL** it is explicitly set to `false`, in the same expression that flags the
connection `unverifiedServer: true`
(`server/src/services/tool-access.ts:12721`). Curated gallery apps and
Paperclip-managed cloud connectors *do* quarantine. The least-trusted class does
not.
On every catalog refresh, newly discovered entries join the connection's
active managed profile unless `quarantineNewEntries` is set — and for a
**user-pasted URL** it is explicitly set to `false`, in the same expression
that flags the connection `unverifiedServer: true`
(`server/src/services/tool-access.ts`, grep `unverifiedServer: true`). Curated
gallery apps and Paperclip-managed cloud connectors *do* quarantine. The
least-trusted class does not.
**Executed, on the recommended wizard path.** A connection was finished with
exactly two tools enabled (`list_notes` allowed, `create_note` ask-first) and
@@ -364,10 +367,11 @@ as board" and show the agent's own decision beside the result.
### F10 — the connect wizard grants the policy but not the install
`POST …/tools/apps/{id}/finish` with `access: {agentIds: [...]}` creates the
tool-profile binding — the agent's `allowedToolNames` correctly lists the chosen
tool — but creates **no `tool_connection_install` row**. Agent readiness
(`usableConnectionForAgent`, `server/src/services/connection-intents.ts:241-248`)
requires the connection to be both installed for the agent and permitted:
tool-profile binding — the agent's `allowedToolNames` correctly lists the
chosen tool — but creates **no `tool_connection_install` row**. Agent
readiness (`usableConnectionForAgent`, the `usable` predicate in
`server/src/services/connection-intents.ts`) requires the connection to be
both installed for the agent and permitted:
```ts
const usable = (connection) => connection
@@ -447,7 +451,7 @@ Both were found by hitting them:
public self-hosted instance cannot use the embedded database at all.
2. `auth.baseUrlMode must be explicit when deploymentMode=authenticated and
exposure=public`, plus `auth.publicBaseUrl` required
(`packages/shared/src/config-schema.ts:167-187`).
(`packages/shared/src/config-schema.ts`).
Neither is a defect. Both are the first two walls a self-hoster hits, which is
why they are in the shape-B row of the matrix.
+21 -15
View File
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"packageName": "@paperclipai/skills-catalog",
"packageVersion": "0.3.1",
"generatedAt": "2026-09-20T19:59:29.226Z",
"generatedAt": "2026-09-24T21:08:13.357Z",
"skills": [
{
"id": "paperclipai:bundled:docs:doc-maintenance",
@@ -1147,14 +1147,14 @@
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 20964,
"sha256": "ecdf045a004da99c6ed9a3384b6000151d6965564d2ed1543c76c94b336811b7"
"sizeBytes": 23654,
"sha256": "2b8ed818bc969425f6ebb7de0b70893b69ba6cbeae55d4a75660b4ae82046302"
},
{
"path": "references/catalog-contract.md",
"kind": "reference",
"sizeBytes": 13589,
"sha256": "f8a659b8c5790fd7c7b6d0a7bac1d3e16e175072f18c97ce3e5db1924f9abda3"
"sizeBytes": 15776,
"sha256": "e4a7f973ac2278bc23f86bf74e0199574d3341e91fe092a0fa06494a736ce930"
},
{
"path": "references/live-acceptance.md",
@@ -1165,11 +1165,17 @@
{
"path": "references/offline-verification.md",
"kind": "reference",
"sizeBytes": 18496,
"sha256": "3b5a73bc50963bc5a1d7044817642022397eb42229f78a75df4d3b3c8a43b0f1"
"sizeBytes": 18480,
"sha256": "f887188302dab5c3ea947a29fb21ebfe4281e57cf83c02bcfbfb42c4f2711500"
},
{
"path": "references/safe-discovery.md",
"kind": "reference",
"sizeBytes": 16680,
"sha256": "d65e3dbfee183e46923af1ac3b088dc589277551749d45cd8e4867db50c0d687"
}
],
"contentHash": "sha256:830e6a1d3d1dfc06372e4bd17883b6f916d5425d7c45b6e53bec8a1c8aa3c2aa"
"contentHash": "sha256:853e1f442eb14f95e72d312f305c657cf235c4cd501455ca0846fb58a043a889"
},
{
"id": "paperclipai:optional:software-development:connect-agent-tools",
@@ -1202,8 +1208,8 @@
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 26975,
"sha256": "13ea45dccab92a5f7dfdd67baa5ffe2952987cbad7fa085cff8de5d0c5fc53ed"
"sizeBytes": 27587,
"sha256": "72634d7d8c3bbcbec22e29a011d917bf828c94236f5f9f5e17c68f83fd74199a"
},
{
"path": "references/chat-and-email.md",
@@ -1214,17 +1220,17 @@
{
"path": "references/deployment-support-matrix.md",
"kind": "reference",
"sizeBytes": 21348,
"sha256": "7afb36d0e2baa4e3920d83332f539b332267ae63e41194dfcbfd145f48addb67"
"sizeBytes": 21528,
"sha256": "5ee2477d76c39842af4a0e5172a1acaae39d93406c84131ba1757851a458a35a"
},
{
"path": "references/verification-log.md",
"kind": "reference",
"sizeBytes": 25233,
"sha256": "56cd4452bd75ad646113c1f62926aa6f2b2f965db2d1e9adea350afe60c55b14"
"sizeBytes": 25362,
"sha256": "fb3799a05d6f0054eeadc55cb7441278013f13959349e0479609fd6439ea3913"
}
],
"contentHash": "sha256:15e41bb565bafee8b284fa21e32948a191e828f5956ee641ffe641d11804bd58"
"contentHash": "sha256:60fe80ab5181aeb9a55cbb5ccae090ac2bf38872435b08d7f98725eb0be9fd54"
},
{
"id": "paperclipai:optional:software-development:prepare-mcp-integration",