mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
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:
1 parent
066a4e8019
commit
c46092f202
9 files changed
+662
-167
No files matched your search
@@ -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
|
||||
|
||||
+75
-27
@@ -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,
|
||||
|
||||
+83
-36
@@ -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
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+377
@@ -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.
|
||||
+41
-31
@@ -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
|
||||
|
||||
+23
-21
@@ -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.
|
||||
|
||||
+38
-34
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user