@tineira/apc-protocol 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +4 -4
- package/dist/core.d.ts +186 -14
- package/dist/core.js +94 -7
- package/dist/manifest.d.ts +22 -3
- package/dist/manifest.js +18 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,52 @@ This file records the contract that an app and an agent share in Agent Pointed C
|
|
|
4
4
|
|
|
5
5
|
The standard has its own semver, `APC_STANDARD_VERSION` in `@tineira/apc-protocol`, separate from the software release. It stays 0.x while the standard is a draft and becomes 1.0.0 when it is published. The version of the paper (V1, the apc.tineira.com demo) is not a 1.x of this standard. Every change to the contract adds an entry here and bumps the constant: a new minor (or, from 1.0, a major) version. A patch (0.7.0 to 0.7.1) never changes the contract; it fixes the packages or the wording, so an app that implements 0.7.0 implements every 0.7.x.
|
|
6
6
|
|
|
7
|
+
## 0.9.0 (draft, 2026-11-12)
|
|
8
|
+
|
|
9
|
+
The pairing code: on the Dock profile the app's consent shows a code, and the dock's Allow requires it (TIN-859). A drag can be put on any page, and anyone who runs a dock can mint tickets for their own agent; before, they could lure an app's owner into a drop and an Allow and get their own agent paired with the owner's data. In `packages/protocol/src/core.ts`, `packages/server/src/consent.ts`, `packages/server/src/hub-client.ts`, `packages/conformance` and DESIGN.md section 12.
|
|
10
|
+
|
|
11
|
+
### Pairing
|
|
12
|
+
|
|
13
|
+
- **The app shows a pairing code with its consent, and sends it to the hub.** Six characters of Crockford base32 (`PAIRING_CODE_LENGTH`, `PAIRING_CODE_ALPHABET`, 30 bits), new for every consent, shown as `K7M-4P2` (`formatPairingCode`) with the warning to type it only in the person's own dock. After the Allow the app sends it raw in `pairing_code` of `POST {hub}/apc/pair` (`DockPairRequest`). An app that implements 0.9 **must** send it. The code never goes over MCP, in a URL, to `/apc/confirm` or anywhere but the app owner's page and that one request.
|
|
14
|
+
- **The hub keeps it and answers `pairing_code: "required"`** (`PAIRING_CODE_ACK`, `DockPairAnswer`), never shows it (not in the dock's state, a broadcast or any answer), and checks the body before it spends the ticket: a `pairing_code` that is present but not a code (`null`, `""`, a number) is `invalid_pairing_code`. Only an absent key is a pairing without a code; a hub may refuse those (`pairing_code_required`).
|
|
15
|
+
- **The dock owner's Allow needs the code.** The hub compares it in constant time (`pairingCodesMatch`) with what the app sent; a missing or malformed code is refused without counting; a wrong one counts, `wrong_pairing_code` with `attempts_left`, and the fifth (`PAIRING_CODE_MAX_ATTEMPTS`) removes the request and gives the grant back (`pairing_code_refused`). A request with a code that nobody approves goes after `PAIRING_CODE_TTL_MS` (120 s), its grant given back; one without keeps the old ten minutes. Deny and unpair need no code. Typing is forgiving: case, spaces and dashes are ignored, O reads as 0 and I or L as 1 (`normalizePairingCode`).
|
|
16
|
+
- **The app keeps a grant only on the acknowledgement**: a non-redirected HTTP 200 with `ok: true`, `pairing_code: "required"` and a string `pairing_id`. A hub's own refusal is HTTP 400 with `ok: false` and its error. Anything else (another status, a redirect, a missing acknowledgement, no answer) and the app deletes the grant before it answers its page (`hub_refused`, `hub_unreachable`, `hub_ignores_pairing_code`, `HubCallError`). `/apc/confirm` is believed the same way: 200, `ok: true`, `status` `"pending"` or `"paired"`.
|
|
17
|
+
- **A drag's ticket lives 240 s** (`TICKET_TTL_MS`; it was 60 s), longer than the app's two-minute consent, so the person has the time to read the card.
|
|
18
|
+
- **What the app shows after the Allow** (reference behaviour, not a MUST): the code again, the time left, and Cancel, until the hub connects (its first `initialize` on the grant) or the grant goes; if the person's own dock never asks for that code, the agent was someone else's.
|
|
19
|
+
|
|
20
|
+
### Packages
|
|
21
|
+
|
|
22
|
+
- `@tineira/apc-server`: `DockConsent.ask` returns `{ nonce, pairing_code }`, and `take` is replaced by `takeConsent`, which returns `{ ok: true, pairing_code }` or `{ ok: false }` (no boolean to test by mistake): every caller has to show the code and send it. New: `newPairingCode`, and `pairWithHub` / `confirmWithHub` (`redirect: "error"`, a 10 s limit, the rules above).
|
|
23
|
+
- `@tineira/apc-conformance`: `standInHub`, a hub to pair with in process, faithful on tickets, the code, wrong codes, expiry, the give-back and the acknowledgement, with broken modes (`no_ack`, `redirect`, `error_500`). An adapter on the Dock profile gives `dock: { hub, drop }`, required of an app that claims 0.9.0 or later (as `page.mobile` since 0.6). New checks: the consent shows a well-formed code and the app sends that code; every consent shows a new code; a hub that does not acknowledge, redirects or says yes with a 500 holds no grant; five wrong codes or the code's expiry give the grant back; the right code pairs.
|
|
24
|
+
|
|
25
|
+
An app that implements 0.8 keeps pairing with a hub in its default mode (`when_sent`), without a code, until the operator requires one; to claim 0.9.0 it shows and sends the code and fails closed on the hub's answer.
|
|
26
|
+
|
|
27
|
+
## 0.8.0 (draft, 2026-11-12)
|
|
28
|
+
|
|
29
|
+
The core tools made robust, from a second implementation in use, and the Direct profile freed from how the agent authenticates (TIN-839, TIN-667). In `packages/protocol/src/core.ts`, `manifest.ts`, `packages/server/src/tools.ts`, `waits.ts`, `packages/conformance` and DESIGN.md section 21.
|
|
30
|
+
|
|
31
|
+
### The loop
|
|
32
|
+
|
|
33
|
+
- **`then` only after a recent pin.** A result carries `then` only while a page of the person's is open (and shared) **and** the grant was pinned within the app's window, read or unread (`THEN_PIN_WINDOW_MIN`, 30 minutes unless the app sets its own; `thenApplies` in `@tineira/apc-server`). An open page with no recent pin gives no `then`, so an agent no longer waits after every answer. Before, an open page was enough.
|
|
34
|
+
- **Wait in the background.** The instructions (`APC_INSTRUCTIONS`), the `guide` prompt, the `then` note and `wait_for_focus`'s description tell the agent to run the wait in the background if its client can (a background subagent, say), and otherwise to keep it short and never chain waits (`WAIT_IN_BACKGROUND`). The default timeout stays the app's: 25 to 45 s suits a conversation.
|
|
35
|
+
- **One wait per grant.** At most one wait of a grant delivers. A new wait takes over: the one it replaces ends at its next check with `focus: null, timed_out: false` and a note, delivers nothing and marks nothing seen. A new wait within `WAIT_CLAIM_GAP_S` (2 s) of one still running is refused instead, with `focus: null, timed_out: false, retry_after_s` (new in `WaitNoFocus`) and a note, so two waits fired together do not take over from each other in a loop. A wait whose request the client cancels stops and frees its place at once: MCP's `notifications/cancelled`, which reaches a stateless server in a POST of its own (`InflightCalls` keeps the calls in flight per grant to find the one it names), and an HTTP abort where the host reports one.
|
|
36
|
+
- **Seen once delivered.** Checking for a pin, or polling for one, never marks it seen: only returning it does. An app marks a pin seen only once the result that carries it is final (in a wait, after it gave its place up and checked once more that its request was not cancelled), and marks that exact pin by its `pinned_at`, never a newer one dropped meanwhile. The result goes out whether or not the mark succeeds. So a pin may be delivered twice, and is never lost to a wait that was taken over or cancelled, or to a newer pin. MCP has no acknowledgement: a result lost after the app sent it, with no abort the app could see, cannot be told apart, and that pin is marked seen. Before, the reference marked the pin seen inside the tool, before the result left, so a wait the client had given up on, or a newer pin dropped between the read and the mark, could be lost.
|
|
37
|
+
- **Unavailable has its own shape, and trim never fails the read.** A core tool the app cannot answer just now still answers in its own shape with a `note` (new, optional, in `CurrentViewResult`): `{ view: null, focus: null, note }`, or `{ focus: null, timed_out: false, page_open: false, note }`; never a tool error or an `{ ok, reason }` of the app's own. A check on one item of the answer that cannot run hides that item and the rest answers; it never fails the read open, and a defect in one check never fails it whole (guidance, not wire format).
|
|
38
|
+
|
|
39
|
+
### The Direct profile, whatever the auth
|
|
40
|
+
|
|
41
|
+
- **What a grant must be** (`GrantRequirement`, `GRANT_REQUIREMENTS`): a stable id per agent connection (pins, views, presence and `apc_link` are keyed by it); revocable from the app alone, checked on every request and before and after each tool, and revoking ends what could bring it back without the person (with OAuth, its refresh tokens and authorization); revoked told apart from expired or unknown; trimmed per grant, so pointing grants no access. Any binding that meets them will do. The secret URL (`newDirectGrant`, `directMcpUrl`) stays the reference apps' binding; OAuth 2.1 (one MCP URL shared by every grant, the bearer token naming the grant) is another.
|
|
42
|
+
- **The revoked answer's status is the binding's.** Every binding answers a revoked grant with the `RevokedAnswer` body. The status is 403 with a secret URL (`REVOKED_HTTP_STATUS`, unchanged) and 401 with `WWW-Authenticate: Bearer error="invalid_token"` with a bearer token (`REVOKED_HTTP_STATUS_BEARER`). An unknown or expired grant answers without a `RevokedAnswer`.
|
|
43
|
+
- **`apc.json`.** A Direct app whose grants share one URL may serve it at `mcp.path` itself or at `mcp.path` without its last `/`; one origin may serve several such endpoints under the path, one per role (`declaresMcpUrl(manifest, url, { shared: true })`; a hub pairing a Dock app still requires one URL per grant). `direct.auth` (a short word such as `"url"` or `"oauth"`) and `direct.resource_metadata` (with OAuth, the protected-resource metadata URL on the origin) may say how agents authenticate, as information only: a reader never requires them.
|
|
44
|
+
- **Hosts with no long-lived process.** An app on stateless functions bounds `wait_for_focus` by its function's time limit (its own ceiling for `timeout_s`), tells a closed page from a heartbeat, and checks the grant before and after each tool call.
|
|
45
|
+
|
|
46
|
+
### Conformance
|
|
47
|
+
|
|
48
|
+
- A connection may carry headers: `AgentConnection.headers` (`{ authorization: "Bearer …" }`), and the CLI takes `--bearer-env <VAR>` and `--revoked-bearer-env <VAR>`, the name of an environment variable that holds the token (never the token itself on the command line; it is never printed). With a bearer token the revoked checks expect 401, the grant's URL may be `mcp.path` itself, and the unknown-grant 404 check is skipped.
|
|
49
|
+
- New checks of an app that claims 0.8.0 or later: an open page with no pin gives no `then`; a newer wait takes over and the older one delivers nothing; a second wait right after the first is refused with `retry_after_s` and the first still delivers; a wait cancelled by `notifications/cancelled`, and one cancelled by an HTTP abort, marks nothing seen and frees its place. Of an app that claims less, those checks pass with a note that they did not apply. The `then` check pins the agent first.
|
|
50
|
+
|
|
51
|
+
An app that implements 0.7 keeps working with agents and docks unchanged; to claim 0.8.0 it adds the `then` window, one wait per grant, and seen once delivered. `@tineira/apc-server`'s `ApcToolContext` now requires `waitSlot` (`MemoryWaitSlots.for(grantId)` for an app whose waits run in one process), takes `signal` (the request's, with `InflightCalls`' for MCP's cancel), and calls `markSeen(pinnedAt)` with the pin it delivered.
|
|
52
|
+
|
|
7
53
|
## 0.7.1 (draft, 2026-11-04)
|
|
8
54
|
|
|
9
55
|
No change to the contract: an app or a hub that implements 0.7.0 implements 0.7.1, and `standard` in `apc.json` may say either. This is the first release of `@tineira/apc-protocol`, `@tineira/apc-server`, `@tineira/apc-web` and `@tineira/apc-conformance` published by `.github/workflows/publish.yml` (npm trusted publishing) rather than from a maintainer's machine.
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ The contract an app and an agent share in Agent Pointed Context: wire types, the
|
|
|
8
8
|
npm i @tineira/apc-protocol@next
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Pre-releases go out on the `next` tag while the standard is 0.x; the package's version follows the standard's (0.
|
|
11
|
+
Pre-releases go out on the `next` tag while the standard is 0.x; the package's version follows the standard's (0.9.x ships APC 0.9).
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
14
|
import { APC_STANDARD_VERSION, focusAttrs, parseApcManifest } from "@tineira/apc-protocol";
|
|
@@ -32,16 +32,16 @@ What every APC app implements, whichever way agents reach it ([`src/core.ts`](sr
|
|
|
32
32
|
| The pin | Keeps one pin per grant, dropped by the person on a target (`FocusTarget`), and gives it as `FocusPayload` with `next` and `next_args`, the call that reads it. While it is unread, every tool result carries `attention`. |
|
|
33
33
|
| Presence | After each successful tool call, publishes a `PresenceEvent` (ids only) to the person's page; a read of a target names it. |
|
|
34
34
|
| The focus format | `FocusPayload`, from the grammar in [`src/target.ts`](src/target.ts): kinds, ids, parts and key-only params. |
|
|
35
|
-
| `get_current_view`, `wait_for_focus` | The two core tools (`CORE_TOOLS`), with `then` on results while a page of the person's is open, never on a wait that came back empty. A wait returns a pin dropped during it, times out cleanly, and ends as soon as the app knows the page is closed. |
|
|
35
|
+
| `get_current_view`, `wait_for_focus` | The two core tools (`CORE_TOOLS`), with `then` on results while a page of the person's is open and the grant was pinned in the last 30 minutes (0.8), never on a wait that came back empty. A wait returns a pin dropped during it, times out cleanly, and ends as soon as the app knows the page is closed. One wait per grant: a newer one takes over (0.8). A pin is marked seen only once the result that carries it is final, by its `pinned_at` (0.8): delivered twice at worst, never lost. |
|
|
36
36
|
| `apc.json` | Serves `/.well-known/apc.json` from its own origin: what it is and which profiles it offers (below). |
|
|
37
37
|
| `apc_link` | Only with both profiles: the same agent on both paths shown as one, with a one-time code; it grants nothing (below). |
|
|
38
38
|
| Mobile (0.6) | On a phone or any touch viewport (`APC_MOBILE_MEDIA`) shows its agents in its own page, on both profiles (below). |
|
|
39
|
-
| Revoke from the app | Revoke on every agent in the agent bar. At once, in the app alone (it calls nobody): the grant
|
|
39
|
+
| Revoke from the app | Revoke on every agent in the agent bar. At once, in the app alone (it calls nobody): every request with the grant answers `RevokedAnswer` from then on (403 with a secret URL, 401 with a bearer token, 0.8), a call already running ends the same way (told by the grant itself, never an id a later grant may reuse, 0.7), and the grant never works again. |
|
|
40
40
|
|
|
41
41
|
What belongs to a profile, not to the core:
|
|
42
42
|
|
|
43
43
|
- **Direct** (the agent connects to the app's own MCP URL): a grant per agent, its secret URL, the list where the person makes and names agents.
|
|
44
|
-
- **Dock** (the agent connects once, to a hub): the drop (`application/x-apc-agent`, the handle and the ticket), consent in the app (checked by the app's server, never its page alone: 0.7, `DockConsent` in `@tineira/apc-server`), `POST {hub}/apc/pair` (the hub reads the app's `apc.json` first), `POST {hub}/apc/confirm` on every drop, and `POST {app}/apc/revoke`, the hub giving a grant back.
|
|
44
|
+
- **Dock** (the agent connects once, to a hub): the drop (`application/x-apc-agent`, the handle and the ticket), consent in the app (checked by the app's server, never its page alone: 0.7, `DockConsent` in `@tineira/apc-server`), with a pairing code the dock's Allow requires (0.9, `PAIRING_CODE_LENGTH`), `POST {hub}/apc/pair` (the hub reads the app's `apc.json` first; the app keeps its grant only on the hub's acknowledgement of the code, `pairWithHub`), `POST {hub}/apc/confirm` on every drop, and `POST {app}/apc/revoke`, the hub giving a grant back.
|
|
45
45
|
|
|
46
46
|
Whatever it offers, an app publishes `apc.json` (below).
|
|
47
47
|
|
package/dist/core.d.ts
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* An app offers one profile or both:
|
|
5
5
|
*
|
|
6
|
-
* - **Direct**: the agent connects to the app's own MCP
|
|
7
|
-
*
|
|
8
|
-
* profile's, not the core's.
|
|
6
|
+
* - **Direct**: the agent connects to the app's own MCP endpoint. The connection (a grant per agent,
|
|
7
|
+
* how the agent authenticates, the agent list where the person makes and names them) is the
|
|
8
|
+
* profile's, not the core's. Since APC 0.8 the standard does not fix how the agent authenticates:
|
|
9
|
+
* any binding that meets `GrantRequirement` will do. The reference apps' binding is the secret URL
|
|
10
|
+
* (one MCP URL per grant, `newDirectGrant` in `@tineira/apc-server`); OAuth (one MCP URL shared by
|
|
11
|
+
* every grant, the bearer token naming the grant) is another.
|
|
9
12
|
* - **Dock**: the agent connects once, to a hub (the dock), which reaches the app with a grant the
|
|
10
13
|
* app gave it. Receiving the drop (`application/x-apc-agent`, the handle and the ticket), consent
|
|
11
14
|
* in the app, pairing (`POST {hub}/apc/pair`), the check on every drop (`POST {hub}/apc/confirm`)
|
|
@@ -14,7 +17,8 @@
|
|
|
14
17
|
* a grant for a dock agent it holds none for only on a request that proves the person allowed it on the
|
|
15
18
|
* app's page just now (the reference apps: a single-use nonce the page shows its consent with,
|
|
16
19
|
* `DockConsent` in `@tineira/apc-server`), whatever the page last loaded. A page that missed a revoke must not
|
|
17
|
-
* pair without asking.
|
|
20
|
+
* pair without asking. Since APC 0.9 the consent shows a pairing code, and the dock's Allow requires it
|
|
21
|
+
* (`PAIRING_CODE_LENGTH`, below).
|
|
18
22
|
*
|
|
19
23
|
* The core is the same on both, and per grant: the hub is one more holder of a grant.
|
|
20
24
|
*
|
|
@@ -26,7 +30,9 @@
|
|
|
26
30
|
* the person's page; a read of a target names it in `targets`.
|
|
27
31
|
* 4. **The focus format**: `FocusPayload`, from the grammar in `target.ts`.
|
|
28
32
|
* 5. **`get_current_view` and `wait_for_focus`**, the two core tools (`CORE_TOOLS`), with the loop
|
|
29
|
-
* hint `then` while a page of the person's is open.
|
|
33
|
+
* hint `then` while a page of the person's is open and the grant was pinned lately (APC 0.8), at most
|
|
34
|
+
* one delivering wait per grant (APC 0.8), and a pin marked seen only once the result that carries
|
|
35
|
+
* it is final (APC 0.8): an app may deliver a pin twice, never lose one.
|
|
30
36
|
* 6. **Revoke from the app**: the person can cut any agent from the app itself, at once, without the
|
|
31
37
|
* hub (`RevokedAnswer`).
|
|
32
38
|
* 7. **`apc_link`** (an app that offers both profiles): the same agent on both paths shown as one,
|
|
@@ -48,21 +54,68 @@ export type CoreTool = (typeof CORE_TOOLS)[number];
|
|
|
48
54
|
/** The key an unread pin puts on every tool result (structured and in the text): `{ focus, note }`. */
|
|
49
55
|
export declare const ATTENTION_KEY = "attention";
|
|
50
56
|
/**
|
|
51
|
-
* The loop hint
|
|
52
|
-
*
|
|
57
|
+
* The loop hint. Since APC 0.8 it is present on a result only while both hold: a page of the person's
|
|
58
|
+
* is open (and they share their view), and the grant was pinned within the app's window
|
|
59
|
+
* (`THEN_PIN_WINDOW_MIN`, 30 minutes unless the app sets its own). An open page with no recent pin gives
|
|
60
|
+
* no `then`, so an agent does not wait after every answer. It is never on a `wait_for_focus` that came
|
|
61
|
+
* back with no focus (that one ends the loop).
|
|
62
|
+
*
|
|
63
|
+
* The agent is told (in the instructions, the guide, the note and the tool's description) to run the
|
|
64
|
+
* wait in the background if its client can (a background subagent, say), and otherwise to keep it short
|
|
65
|
+
* and never chain waits.
|
|
53
66
|
*/
|
|
54
67
|
export declare const THEN_KEY = "then";
|
|
55
|
-
/**
|
|
68
|
+
/** The default `then` window, in minutes: the grant's pin, read or unread, is at most this old. An app may set its own. */
|
|
69
|
+
export declare const THEN_PIN_WINDOW_MIN = 30;
|
|
70
|
+
/**
|
|
71
|
+
* `get_current_view`: the screen and, if the person pointed the agent, the focus. Returning the focus
|
|
72
|
+
* marks it seen, once the result is final (APC 0.8, `WaitForFocusResult`).
|
|
73
|
+
*
|
|
74
|
+
* **Unavailable (APC 0.8).** When the app cannot answer a core tool just now (its store is down, a check
|
|
75
|
+
* cannot run), the tool still answers in its own shape, with nothing in it and a `note` for the agent:
|
|
76
|
+
* `{ view: null, focus: null, note }` here, `{ focus: null, timed_out: false, page_open: false, note }`
|
|
77
|
+
* from `wait_for_focus`. Never a tool error or an `{ ok, reason }` of the app's own. A revoked grant is not
|
|
78
|
+
* unavailable: it answers `RevokedAnswer`.
|
|
79
|
+
*
|
|
80
|
+
* **Trim, never fail (APC 0.8).** A check on one item of the answer (a filter, the selection, the pin)
|
|
81
|
+
* that cannot run hides that item, and the rest still answers. It never fails the read open (the item
|
|
82
|
+
* shown unchecked), and a defect in one check (a missing column, a value it cannot parse) never fails
|
|
83
|
+
* the read whole. A failure that concerns the grant itself (its auth, an expired session) still fails it
|
|
84
|
+
* closed.
|
|
85
|
+
*/
|
|
56
86
|
export interface CurrentViewResult {
|
|
57
87
|
view: ViewPayload | null;
|
|
58
88
|
focus: FocusPayload | null;
|
|
89
|
+
/** Why the result is empty, when the app could not answer it just now (APC 0.8). */
|
|
90
|
+
note?: string;
|
|
59
91
|
}
|
|
60
92
|
/**
|
|
61
93
|
* `wait_for_focus({ timeout_s })`: holds the call until the person pins the agent or `timeout_s`
|
|
62
94
|
* passes (1 to the app's ceiling). A pin dropped during the wait is returned, marked seen. With no
|
|
63
95
|
* pin: `timed_out: true` at the timeout, or `timed_out: false, page_open: false` as soon as the app
|
|
64
96
|
* knows the person's page is closed. `page_open` is left out while the person does not share their
|
|
65
|
-
* view (an app without that switch always shares).
|
|
97
|
+
* view (an app without that switch always shares). The default timeout is the app's: 25 to 45 s suits
|
|
98
|
+
* a conversation (`WAIT_DEFAULT_S` in `@tineira/apc-server` is 25), bounded by what its host can hold.
|
|
99
|
+
*
|
|
100
|
+
* **One wait per grant (APC 0.8).** At most one wait of a grant delivers. A new wait takes over: the one
|
|
101
|
+
* it replaces ends at its next check with `focus: null, timed_out: false` and a note, delivers nothing,
|
|
102
|
+
* and never marks a pin seen. A new wait that starts within `WAIT_CLAIM_GAP_S` of one still running is
|
|
103
|
+
* refused instead, with `focus: null, timed_out: false, retry_after_s` and a note: two waits fired
|
|
104
|
+
* together would otherwise take over from each other in a loop. A wait whose request the client cancels
|
|
105
|
+
* stops and frees its place at once: MCP's `notifications/cancelled` (which, to a stateless server, comes
|
|
106
|
+
* in a POST of its own: the app keeps the calls in flight per grant to find the one it names,
|
|
107
|
+
* `InflightCalls` in `@tineira/apc-server`), and an HTTP abort where the host reports one (on Cloudflare
|
|
108
|
+
* Workers, `request.signal` with the `enable_request_signal` flag, or a compatibility date from 2026-11-17).
|
|
109
|
+
*
|
|
110
|
+
* **Seen once delivered (APC 0.8).** Checking for a pin, or polling for one, never marks it seen:
|
|
111
|
+
* only returning it does. An app marks a pin seen only once the result that carries it is final (in a
|
|
112
|
+
* wait, after it gave its place up and checked one last time that its request was not cancelled), and
|
|
113
|
+
* marks that exact pin (by its `pinned_at`), never a newer one dropped meanwhile. It returns the result
|
|
114
|
+
* whether or not the mark succeeds. So a pin may be delivered twice, and is never lost to a wait that was
|
|
115
|
+
* taken over or cancelled, or to a newer pin. MCP has no acknowledgement: a result lost after the app sent
|
|
116
|
+
* it (the connection dropped with no abort the app could see) is not something an app can tell, and that
|
|
117
|
+
* pin is marked seen. A hub that relays a wait holds the pin it was handed: it keeps it unread until its
|
|
118
|
+
* own agent gets it.
|
|
66
119
|
*/
|
|
67
120
|
export type WaitForFocusResult = WaitGotFocus | WaitNoFocus;
|
|
68
121
|
export interface WaitGotFocus {
|
|
@@ -73,19 +126,56 @@ export interface WaitNoFocus {
|
|
|
73
126
|
focus: null;
|
|
74
127
|
timed_out: boolean;
|
|
75
128
|
page_open?: boolean;
|
|
129
|
+
/** On a wait refused because another wait of the grant started just before it (APC 0.8): when to try again. */
|
|
130
|
+
retry_after_s?: number;
|
|
76
131
|
note: string;
|
|
77
132
|
}
|
|
133
|
+
/** How soon after one wait of a grant another is refused rather than taking over, in seconds (APC 0.8). */
|
|
134
|
+
export declare const WAIT_CLAIM_GAP_S = 2;
|
|
135
|
+
/**
|
|
136
|
+
* What a Direct grant must be, whatever way the agent authenticates (APC 0.8). The standard does not fix
|
|
137
|
+
* the binding (a secret MCP URL, OAuth, an API key, mTLS); any binding that meets these will do:
|
|
138
|
+
*
|
|
139
|
+
* - `stable_id`: **a stable id per agent connection.** Pins, views, presence and `apc_link` are keyed by it.
|
|
140
|
+
* - `revocable_in_app`: **revocable from the app alone.** The app revokes it in its own store, calling
|
|
141
|
+
* nobody, and checks it on every request, and before and after each tool call. Revoking ends everything
|
|
142
|
+
* that could bring the grant back without the person: with OAuth, the refresh tokens and the
|
|
143
|
+
* authorization behind it too, so a client that answers a 401 by refreshing or re-authorizing silently
|
|
144
|
+
* gets nothing. The person lets the agent back with a new grant.
|
|
145
|
+
* - `revoked_told_apart`: **revoked is told apart from expired or unknown**, with a `RevokedAnswer` body,
|
|
146
|
+
* so the agent tells the person and stops retrying, where an expired credential may just be renewed.
|
|
147
|
+
* - `trimmed_per_grant`: **pointing grants no access.** What a grant may read is trimmed per grant; a pin
|
|
148
|
+
* never widens it.
|
|
149
|
+
*
|
|
150
|
+
* Two bindings are described:
|
|
151
|
+
*
|
|
152
|
+
* - **The secret URL** (the reference apps; `newDirectGrant` and `directMcpUrl` in `@tineira/apc-server`):
|
|
153
|
+
* one MCP URL per grant, under `apc.json`'s `mcp.path`; whoever holds the URL holds the grant. Revoked is
|
|
154
|
+
* HTTP 403 with `RevokedAnswer`; an unknown or expired grant is 404.
|
|
155
|
+
* - **OAuth 2.1**: one MCP URL shared by every grant, the bearer token naming the grant. Revoked is HTTP
|
|
156
|
+
* 401 with `WWW-Authenticate: Bearer error="invalid_token"` and the `RevokedAnswer` body, so the client
|
|
157
|
+
* tells "taken back by the person" from "expired" and does not just authorize again.
|
|
158
|
+
*
|
|
159
|
+
* On a host with no long-lived process (stateless functions and a database), an app still bounds
|
|
160
|
+
* `wait_for_focus` by its function's time limit (its own ceiling for `timeout_s`), tells a closed page from
|
|
161
|
+
* a heartbeat the page sends, and checks the grant before and after each tool call.
|
|
162
|
+
*/
|
|
163
|
+
export type GrantRequirement = "stable_id" | "revocable_in_app" | "revoked_told_apart" | "trimmed_per_grant";
|
|
164
|
+
export declare const GRANT_REQUIREMENTS: readonly GrantRequirement[];
|
|
78
165
|
/**
|
|
79
166
|
* Revoke from the app. Every agent in the app's agent bar, Direct or Dock, has Revoke, and it holds
|
|
80
167
|
* in the app alone:
|
|
81
168
|
*
|
|
82
169
|
* - It takes effect at once: the grant, its pin and (Dock) the app's link to the hub go. The app
|
|
83
170
|
* calls nobody, so it works with the hub down.
|
|
84
|
-
* - From then on every request
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
171
|
+
* - From then on every request with the grant, whatever the method, is answered with a `RevokedAnswer`
|
|
172
|
+
* body. The HTTP status is the binding's (APC 0.8, `GrantRequirement`): `REVOKED_HTTP_STATUS` (403)
|
|
173
|
+
* with a secret URL, `REVOKED_HTTP_STATUS_BEARER` (401, with `WWW-Authenticate: Bearer
|
|
174
|
+
* error="invalid_token"`) with OAuth. A call already running when the grant is revoked ends as a tool
|
|
175
|
+
* error whose `error` is `"revoked"`, with no app data. An unknown or expired grant is answered without
|
|
176
|
+
* a `RevokedAnswer` (404 with a secret URL; a plain 401 with OAuth), so the holder can tell "taken back"
|
|
177
|
+
* from "gone". Whether a grant was revoked is told by the grant itself (its secret or token), never by
|
|
178
|
+
* an id a later grant may reuse (0.7).
|
|
89
179
|
* - A revoked grant never works again. To let the agent back, the person makes a new grant: a new
|
|
90
180
|
* MCP URL (Direct), or a new drop, with consent in the app and in the dock (Dock).
|
|
91
181
|
* - A hub that gets a `RevokedAnswer` marks that pairing revoked ("Revoked by <app host>"), stops
|
|
@@ -93,7 +183,10 @@ export interface WaitNoFocus {
|
|
|
93
183
|
* again by itself.
|
|
94
184
|
*/
|
|
95
185
|
export declare const REVOKED_ERROR = "revoked";
|
|
186
|
+
/** The status of a revoked grant with the secret-URL binding. */
|
|
96
187
|
export declare const REVOKED_HTTP_STATUS = 403;
|
|
188
|
+
/** The status of a revoked grant with a bearer token (OAuth), with `WWW-Authenticate: Bearer error="invalid_token"` (APC 0.8). */
|
|
189
|
+
export declare const REVOKED_HTTP_STATUS_BEARER = 401;
|
|
97
190
|
export interface RevokedAnswer {
|
|
98
191
|
error: typeof REVOKED_ERROR;
|
|
99
192
|
/** The host of the app that revoked the grant, e.g. "pulse.example". */
|
|
@@ -126,6 +219,85 @@ export declare const APC_MOBILE_VIEWPORT: {
|
|
|
126
219
|
readonly width: 375;
|
|
127
220
|
readonly height: 812;
|
|
128
221
|
};
|
|
222
|
+
/**
|
|
223
|
+
* The pairing code (Dock profile, APC 0.9). A drag can be put on a page by anyone: whoever runs a dock can
|
|
224
|
+
* mint tickets for their own agent and lure the app's owner into a drop and an Allow. So a pairing is
|
|
225
|
+
* approved in the dock only with a code that only the app's owner sees.
|
|
226
|
+
*
|
|
227
|
+
* - **The app makes it** with its consent nonce: `PAIRING_CODE_LENGTH` characters of `PAIRING_CODE_ALPHABET`
|
|
228
|
+
* (Crockford base32, 30 bits), new for every consent (`newPairingCode` and `DockConsent` in
|
|
229
|
+
* `@tineira/apc-server`), and shows it on its consent card, formatted `K7M-4P2` (`formatPairingCode`). After
|
|
230
|
+
* the Allow, the app sends it raw (`K7M4P2`) in `pairing_code` of `POST {hub}/apc/pair` (`DockPairRequest`).
|
|
231
|
+
* An app that implements 0.9 **must** send it. It never goes anywhere else: not over MCP, not in a URL,
|
|
232
|
+
* not to `/apc/confirm`.
|
|
233
|
+
* - **The hub keeps it** with the pending pairing, never shows it (not in the dock's state, not in a
|
|
234
|
+
* broadcast, not in any answer) and answers a pairing that carries one with `pairing_code: "required"`
|
|
235
|
+
* (`PAIRING_CODE_ACK`, `DockPairAnswer`). It checks the body before it spends the ticket: a `pairing_code`
|
|
236
|
+
* that is present but does not normalize (`null`, `""`, a number) is `invalid_pairing_code`. Only an absent
|
|
237
|
+
* key is a pairing without a code (an app from before 0.9); a hub may refuse those too
|
|
238
|
+
* (`pairing_code_required`).
|
|
239
|
+
* - **The dock owner types it** to approve: the hub compares it, in constant time, with the one the app sent.
|
|
240
|
+
* A wrong one counts; after `PAIRING_CODE_MAX_ATTEMPTS` the request goes and the grant is given back
|
|
241
|
+
* (`pairing_code_refused`). A request with a code that nobody approves goes after `PAIRING_CODE_TTL_MS`, its
|
|
242
|
+
* grant given back. A match clears the code. Deny and unpair need no code.
|
|
243
|
+
* - **The app keeps a grant only on the acknowledgement**: a non-redirected HTTP 200 with `ok: true`,
|
|
244
|
+
* `pairing_code: "required"` and a string `pairing_id` (`pairWithHub` in `@tineira/apc-server`). Anything
|
|
245
|
+
* else (another status, a redirect, a missing acknowledgement, no answer) and the app deletes the grant
|
|
246
|
+
* before it answers its page: a hub that does not check codes never holds a grant (`hub_ignores_pairing_code`).
|
|
247
|
+
*
|
|
248
|
+
* The drag's ticket lives `TICKET_TTL_MS` (240 s since 0.9; 60 s before), longer than the app's consent, so
|
|
249
|
+
* the person has the time to read the card.
|
|
250
|
+
*/
|
|
251
|
+
export declare const PAIRING_CODE_LENGTH = 6;
|
|
252
|
+
/** Crockford base32: no I, L, O or U, so a code reads out loud without doubt. */
|
|
253
|
+
export declare const PAIRING_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
254
|
+
/** How long a pending pairing with a code waits for the dock owner, from `/apc/pair`. */
|
|
255
|
+
export declare const PAIRING_CODE_TTL_MS = 120000;
|
|
256
|
+
/** Wrong codes a hub takes for one pairing; the last one removes it. */
|
|
257
|
+
export declare const PAIRING_CODE_MAX_ATTEMPTS = 5;
|
|
258
|
+
/** What a hub that keeps the code answers in `pairing_code`. */
|
|
259
|
+
export declare const PAIRING_CODE_ACK = "required";
|
|
260
|
+
/** How long a drag's ticket works (APC 0.9: 240 s). */
|
|
261
|
+
export declare const TICKET_TTL_MS = 240000;
|
|
262
|
+
/** Why a hub refuses a pairing or an approval over its code. */
|
|
263
|
+
export type PairingCodeError = "invalid_pairing_code" | "pairing_code_required" | "wrong_pairing_code" | "pairing_code_refused";
|
|
264
|
+
/** Why an app did not keep a grant after asking a hub to pair (`pairWithHub`), when the hub gave no reason of its own. */
|
|
265
|
+
export type HubCallError = "hub_unreachable" | "hub_refused" | "hub_ignores_pairing_code";
|
|
266
|
+
/**
|
|
267
|
+
* A code as typed or sent, made canonical: case, spaces and dashes are ignored, O reads as 0 and I or L as 1.
|
|
268
|
+
* Null unless the result is `PAIRING_CODE_LENGTH` characters of `PAIRING_CODE_ALPHABET`.
|
|
269
|
+
*/
|
|
270
|
+
export declare function normalizePairingCode(input: unknown): string | null;
|
|
271
|
+
/** How a code is shown to a person: `K7M4P2` as `K7M-4P2`. For display only; the wire and storage keep it raw. Anything that is not a code is returned untouched, never shaped like one. */
|
|
272
|
+
export declare function formatPairingCode(code: string): string;
|
|
273
|
+
/** Whether two normalized codes are the same, in time that does not depend on where they differ. */
|
|
274
|
+
export declare function pairingCodesMatch(expected: string, given: string): boolean;
|
|
275
|
+
/** `POST {hub}/apc/pair`: what an app sends after the person's Allow (Dock profile). `pairing_code` is raw, 6 characters. */
|
|
276
|
+
export interface DockPairRequest {
|
|
277
|
+
ticket: string;
|
|
278
|
+
agent: string;
|
|
279
|
+
app: {
|
|
280
|
+
name: string;
|
|
281
|
+
origin: string;
|
|
282
|
+
};
|
|
283
|
+
mcp_url: string;
|
|
284
|
+
/** Required of an app that implements 0.9; absent only from an app before it. */
|
|
285
|
+
pairing_code?: string;
|
|
286
|
+
}
|
|
287
|
+
/** The hub's answer to `/apc/pair`: HTTP 200 with the acknowledgement, or HTTP 400 with the reason. */
|
|
288
|
+
export type DockPairAnswer = {
|
|
289
|
+
ok: true;
|
|
290
|
+
pairing_id: string;
|
|
291
|
+
status: "pending";
|
|
292
|
+
agent: {
|
|
293
|
+
name: string;
|
|
294
|
+
icon: string | null;
|
|
295
|
+
};
|
|
296
|
+
pairing_code: typeof PAIRING_CODE_ACK;
|
|
297
|
+
} | {
|
|
298
|
+
ok: false;
|
|
299
|
+
error: string;
|
|
300
|
+
};
|
|
129
301
|
/** The other path: Direct for Dock and Dock for Direct. */
|
|
130
302
|
export declare function oppositePath(path: ApcProfile): ApcProfile;
|
|
131
303
|
/**
|
package/dist/core.js
CHANGED
|
@@ -3,21 +3,36 @@ export const CORE_TOOLS = ["get_current_view", "wait_for_focus"];
|
|
|
3
3
|
/** The key an unread pin puts on every tool result (structured and in the text): `{ focus, note }`. */
|
|
4
4
|
export const ATTENTION_KEY = "attention";
|
|
5
5
|
/**
|
|
6
|
-
* The loop hint
|
|
7
|
-
*
|
|
6
|
+
* The loop hint. Since APC 0.8 it is present on a result only while both hold: a page of the person's
|
|
7
|
+
* is open (and they share their view), and the grant was pinned within the app's window
|
|
8
|
+
* (`THEN_PIN_WINDOW_MIN`, 30 minutes unless the app sets its own). An open page with no recent pin gives
|
|
9
|
+
* no `then`, so an agent does not wait after every answer. It is never on a `wait_for_focus` that came
|
|
10
|
+
* back with no focus (that one ends the loop).
|
|
11
|
+
*
|
|
12
|
+
* The agent is told (in the instructions, the guide, the note and the tool's description) to run the
|
|
13
|
+
* wait in the background if its client can (a background subagent, say), and otherwise to keep it short
|
|
14
|
+
* and never chain waits.
|
|
8
15
|
*/
|
|
9
16
|
export const THEN_KEY = "then";
|
|
17
|
+
/** The default `then` window, in minutes: the grant's pin, read or unread, is at most this old. An app may set its own. */
|
|
18
|
+
export const THEN_PIN_WINDOW_MIN = 30;
|
|
19
|
+
/** How soon after one wait of a grant another is refused rather than taking over, in seconds (APC 0.8). */
|
|
20
|
+
export const WAIT_CLAIM_GAP_S = 2;
|
|
21
|
+
export const GRANT_REQUIREMENTS = ["stable_id", "revocable_in_app", "revoked_told_apart", "trimmed_per_grant"];
|
|
10
22
|
/**
|
|
11
23
|
* Revoke from the app. Every agent in the app's agent bar, Direct or Dock, has Revoke, and it holds
|
|
12
24
|
* in the app alone:
|
|
13
25
|
*
|
|
14
26
|
* - It takes effect at once: the grant, its pin and (Dock) the app's link to the hub go. The app
|
|
15
27
|
* calls nobody, so it works with the hub down.
|
|
16
|
-
* - From then on every request
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
28
|
+
* - From then on every request with the grant, whatever the method, is answered with a `RevokedAnswer`
|
|
29
|
+
* body. The HTTP status is the binding's (APC 0.8, `GrantRequirement`): `REVOKED_HTTP_STATUS` (403)
|
|
30
|
+
* with a secret URL, `REVOKED_HTTP_STATUS_BEARER` (401, with `WWW-Authenticate: Bearer
|
|
31
|
+
* error="invalid_token"`) with OAuth. A call already running when the grant is revoked ends as a tool
|
|
32
|
+
* error whose `error` is `"revoked"`, with no app data. An unknown or expired grant is answered without
|
|
33
|
+
* a `RevokedAnswer` (404 with a secret URL; a plain 401 with OAuth), so the holder can tell "taken back"
|
|
34
|
+
* from "gone". Whether a grant was revoked is told by the grant itself (its secret or token), never by
|
|
35
|
+
* an id a later grant may reuse (0.7).
|
|
21
36
|
* - A revoked grant never works again. To let the agent back, the person makes a new grant: a new
|
|
22
37
|
* MCP URL (Direct), or a new drop, with consent in the app and in the dock (Dock).
|
|
23
38
|
* - A hub that gets a `RevokedAnswer` marks that pairing revoked ("Revoked by <app host>"), stops
|
|
@@ -25,7 +40,10 @@ export const THEN_KEY = "then";
|
|
|
25
40
|
* again by itself.
|
|
26
41
|
*/
|
|
27
42
|
export const REVOKED_ERROR = "revoked";
|
|
43
|
+
/** The status of a revoked grant with the secret-URL binding. */
|
|
28
44
|
export const REVOKED_HTTP_STATUS = 403;
|
|
45
|
+
/** The status of a revoked grant with a bearer token (OAuth), with `WWW-Authenticate: Bearer error="invalid_token"` (APC 0.8). */
|
|
46
|
+
export const REVOKED_HTTP_STATUS_BEARER = 401;
|
|
29
47
|
export function revokedAnswer(host, revokedAt) {
|
|
30
48
|
return {
|
|
31
49
|
error: REVOKED_ERROR,
|
|
@@ -58,6 +76,75 @@ export function isRevokedAnswer(body) {
|
|
|
58
76
|
*/
|
|
59
77
|
export const APC_MOBILE_MEDIA = "(pointer: coarse), (max-width: 640px)";
|
|
60
78
|
export const APC_MOBILE_VIEWPORT = { width: 375, height: 812 };
|
|
79
|
+
/**
|
|
80
|
+
* The pairing code (Dock profile, APC 0.9). A drag can be put on a page by anyone: whoever runs a dock can
|
|
81
|
+
* mint tickets for their own agent and lure the app's owner into a drop and an Allow. So a pairing is
|
|
82
|
+
* approved in the dock only with a code that only the app's owner sees.
|
|
83
|
+
*
|
|
84
|
+
* - **The app makes it** with its consent nonce: `PAIRING_CODE_LENGTH` characters of `PAIRING_CODE_ALPHABET`
|
|
85
|
+
* (Crockford base32, 30 bits), new for every consent (`newPairingCode` and `DockConsent` in
|
|
86
|
+
* `@tineira/apc-server`), and shows it on its consent card, formatted `K7M-4P2` (`formatPairingCode`). After
|
|
87
|
+
* the Allow, the app sends it raw (`K7M4P2`) in `pairing_code` of `POST {hub}/apc/pair` (`DockPairRequest`).
|
|
88
|
+
* An app that implements 0.9 **must** send it. It never goes anywhere else: not over MCP, not in a URL,
|
|
89
|
+
* not to `/apc/confirm`.
|
|
90
|
+
* - **The hub keeps it** with the pending pairing, never shows it (not in the dock's state, not in a
|
|
91
|
+
* broadcast, not in any answer) and answers a pairing that carries one with `pairing_code: "required"`
|
|
92
|
+
* (`PAIRING_CODE_ACK`, `DockPairAnswer`). It checks the body before it spends the ticket: a `pairing_code`
|
|
93
|
+
* that is present but does not normalize (`null`, `""`, a number) is `invalid_pairing_code`. Only an absent
|
|
94
|
+
* key is a pairing without a code (an app from before 0.9); a hub may refuse those too
|
|
95
|
+
* (`pairing_code_required`).
|
|
96
|
+
* - **The dock owner types it** to approve: the hub compares it, in constant time, with the one the app sent.
|
|
97
|
+
* A wrong one counts; after `PAIRING_CODE_MAX_ATTEMPTS` the request goes and the grant is given back
|
|
98
|
+
* (`pairing_code_refused`). A request with a code that nobody approves goes after `PAIRING_CODE_TTL_MS`, its
|
|
99
|
+
* grant given back. A match clears the code. Deny and unpair need no code.
|
|
100
|
+
* - **The app keeps a grant only on the acknowledgement**: a non-redirected HTTP 200 with `ok: true`,
|
|
101
|
+
* `pairing_code: "required"` and a string `pairing_id` (`pairWithHub` in `@tineira/apc-server`). Anything
|
|
102
|
+
* else (another status, a redirect, a missing acknowledgement, no answer) and the app deletes the grant
|
|
103
|
+
* before it answers its page: a hub that does not check codes never holds a grant (`hub_ignores_pairing_code`).
|
|
104
|
+
*
|
|
105
|
+
* The drag's ticket lives `TICKET_TTL_MS` (240 s since 0.9; 60 s before), longer than the app's consent, so
|
|
106
|
+
* the person has the time to read the card.
|
|
107
|
+
*/
|
|
108
|
+
export const PAIRING_CODE_LENGTH = 6;
|
|
109
|
+
/** Crockford base32: no I, L, O or U, so a code reads out loud without doubt. */
|
|
110
|
+
export const PAIRING_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
111
|
+
/** How long a pending pairing with a code waits for the dock owner, from `/apc/pair`. */
|
|
112
|
+
export const PAIRING_CODE_TTL_MS = 120_000;
|
|
113
|
+
/** Wrong codes a hub takes for one pairing; the last one removes it. */
|
|
114
|
+
export const PAIRING_CODE_MAX_ATTEMPTS = 5;
|
|
115
|
+
/** What a hub that keeps the code answers in `pairing_code`. */
|
|
116
|
+
export const PAIRING_CODE_ACK = "required";
|
|
117
|
+
/** How long a drag's ticket works (APC 0.9: 240 s). */
|
|
118
|
+
export const TICKET_TTL_MS = 240_000;
|
|
119
|
+
/**
|
|
120
|
+
* A code as typed or sent, made canonical: case, spaces and dashes are ignored, O reads as 0 and I or L as 1.
|
|
121
|
+
* Null unless the result is `PAIRING_CODE_LENGTH` characters of `PAIRING_CODE_ALPHABET`.
|
|
122
|
+
*/
|
|
123
|
+
export function normalizePairingCode(input) {
|
|
124
|
+
if (typeof input !== "string" || input.length > 32)
|
|
125
|
+
return null;
|
|
126
|
+
const code = input.toUpperCase().replace(/[\s-]/g, "").replace(/O/g, "0").replace(/[IL]/g, "1");
|
|
127
|
+
if (code.length !== PAIRING_CODE_LENGTH)
|
|
128
|
+
return null;
|
|
129
|
+
for (const c of code)
|
|
130
|
+
if (!PAIRING_CODE_ALPHABET.includes(c))
|
|
131
|
+
return null;
|
|
132
|
+
return code;
|
|
133
|
+
}
|
|
134
|
+
/** How a code is shown to a person: `K7M4P2` as `K7M-4P2`. For display only; the wire and storage keep it raw. Anything that is not a code is returned untouched, never shaped like one. */
|
|
135
|
+
export function formatPairingCode(code) {
|
|
136
|
+
const raw = normalizePairingCode(code);
|
|
137
|
+
return raw ? `${raw.slice(0, 3)}-${raw.slice(3)}` : code;
|
|
138
|
+
}
|
|
139
|
+
/** Whether two normalized codes are the same, in time that does not depend on where they differ. */
|
|
140
|
+
export function pairingCodesMatch(expected, given) {
|
|
141
|
+
if (expected.length !== PAIRING_CODE_LENGTH || given.length !== PAIRING_CODE_LENGTH)
|
|
142
|
+
return false;
|
|
143
|
+
let diff = 0;
|
|
144
|
+
for (let i = 0; i < PAIRING_CODE_LENGTH; i++)
|
|
145
|
+
diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
|
|
146
|
+
return diff === 0;
|
|
147
|
+
}
|
|
61
148
|
/** The other path: Direct for Dock and Dock for Direct. */
|
|
62
149
|
export function oppositePath(path) {
|
|
63
150
|
return path === "direct" ? "dock" : "direct";
|
package/dist/manifest.d.ts
CHANGED
|
@@ -15,9 +15,16 @@
|
|
|
15
15
|
* `"hub"` alone, so apps and tools can tell a dock from an app. Words a reader does not know are ignored.
|
|
16
16
|
* - **`mcp.path`**: where its MCP URLs live. Every grant's MCP URL on this origin starts with
|
|
17
17
|
* `origin + mcp.path`. A hub pairs only an MCP URL under it, so a path the domain did not declare in
|
|
18
|
-
* its own `/.well-known/` (another tenant's, on a shared host) is never paired.
|
|
18
|
+
* its own `/.well-known/` (another tenant's, on a shared host) is never paired. Since APC 0.8 a Direct
|
|
19
|
+
* app whose grants share one MCP URL (a bearer token names the grant, as with OAuth) may serve it at
|
|
20
|
+
* `mcp.path` itself, or at `mcp.path` without its last `/` (`/api/mcp` for `"/api/mcp/"`); one origin
|
|
21
|
+
* may serve several such endpoints under the path, one per role (`declaresMcpUrl` with `shared`).
|
|
19
22
|
* - **`direct.connect`** (with `"direct"`): the page, on the same origin, where the person connects an
|
|
20
23
|
* agent directly: what a person is shown when they drop a dock agent on a Direct-only app.
|
|
24
|
+
* - **`direct.auth`** (APC 0.8, optional, information only): how a Direct agent authenticates, a short
|
|
25
|
+
* word such as `"url"` (the secret URL) or `"oauth"`; with OAuth, `direct.resource_metadata` may give
|
|
26
|
+
* the URL of its OAuth protected-resource metadata, on its origin. A reader never requires either: the
|
|
27
|
+
* standard does not fix how agents authenticate (`GrantRequirement`).
|
|
21
28
|
* - **`dock`** (a hub): where it takes pairings, `pair` and `confirm`, as absolute URLs on its origin.
|
|
22
29
|
*
|
|
23
30
|
* A hub reads an app's `apc.json` when the app asks to pair (`POST {hub}/apc/pair`) and refuses the
|
|
@@ -50,9 +57,15 @@ export interface ApcManifest {
|
|
|
50
57
|
mcp: {
|
|
51
58
|
path: string;
|
|
52
59
|
};
|
|
53
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* With "direct": where the person connects an agent directly, on the same origin; and, as information
|
|
62
|
+
* only (APC 0.8), how agents authenticate (`auth`, e.g. "url" or "oauth") and, with OAuth, the URL of
|
|
63
|
+
* the protected-resource metadata (`resource_metadata`, on the same origin).
|
|
64
|
+
*/
|
|
54
65
|
direct?: {
|
|
55
66
|
connect: string;
|
|
67
|
+
auth?: string;
|
|
68
|
+
resource_metadata?: string;
|
|
56
69
|
};
|
|
57
70
|
/** A hub's pairing endpoints, on its origin. */
|
|
58
71
|
dock?: {
|
|
@@ -89,8 +102,14 @@ export declare function parseApcManifest(raw: unknown, origin: string): {
|
|
|
89
102
|
* ones, which a server may decode afterwards: so the rest of the path after `mcp.path` may hold no
|
|
90
103
|
* encoded slash, backslash or dot (`%2f`, `%5c`, `%2e`), no encoded percent (`%25`, a second round of
|
|
91
104
|
* encoding), and no backslash.
|
|
105
|
+
*
|
|
106
|
+
* With `shared` (APC 0.8: a Direct endpoint whose grants share one URL, the credential naming the grant),
|
|
107
|
+
* the URL may also be `mcp.path` itself or `mcp.path` without its last `/`. Without it, as a hub pairing a
|
|
108
|
+
* Dock app checks, the URL must lie strictly under `mcp.path`: one URL per grant.
|
|
92
109
|
*/
|
|
93
|
-
export declare function declaresMcpUrl(manifest: Pick<ApcManifest, "origin" | "mcp">, mcpUrl: string
|
|
110
|
+
export declare function declaresMcpUrl(manifest: Pick<ApcManifest, "origin" | "mcp">, mcpUrl: string, options?: {
|
|
111
|
+
shared?: boolean;
|
|
112
|
+
}): boolean;
|
|
94
113
|
/**
|
|
95
114
|
* What an app tells a person who drops an agent from their dock on it when it connects agents only
|
|
96
115
|
* directly (its `apc.json` lists no `"dock"`): the drop is answered, never lost. These are the app's
|
package/dist/manifest.js
CHANGED
|
@@ -10,6 +10,7 @@ export const APC_DEFAULT_MCP_PATH = "/mcp/";
|
|
|
10
10
|
export const APC_ROLES = ["direct", "dock", "hub"];
|
|
11
11
|
const VERSION_RE = /^(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,5})$/;
|
|
12
12
|
const PATH_RE = /^\/([A-Za-z0-9._~!$&'()*+,;=:@-]+\/)*$/;
|
|
13
|
+
const AUTH_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
13
14
|
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
14
15
|
/**
|
|
15
16
|
* An absolute http(s) URL on `origin`, with no credentials, as its href; null otherwise. The scheme is
|
|
@@ -80,6 +81,13 @@ export function parseApcManifest(raw, origin) {
|
|
|
80
81
|
if (!connect)
|
|
81
82
|
return bad("direct.connect is not a URL on its origin");
|
|
82
83
|
manifest.direct = { connect };
|
|
84
|
+
// Information only (APC 0.8): kept when well formed, otherwise left out, never a reason to refuse.
|
|
85
|
+
const direct = raw.direct;
|
|
86
|
+
if (typeof direct.auth === "string" && AUTH_RE.test(direct.auth))
|
|
87
|
+
manifest.direct.auth = direct.auth;
|
|
88
|
+
const metadata = onOrigin(direct.resource_metadata, origin);
|
|
89
|
+
if (metadata)
|
|
90
|
+
manifest.direct.resource_metadata = metadata;
|
|
83
91
|
}
|
|
84
92
|
if (profiles.includes("hub")) {
|
|
85
93
|
const dock = isObject(raw.dock) ? raw.dock : {};
|
|
@@ -97,8 +105,12 @@ export function parseApcManifest(raw, origin) {
|
|
|
97
105
|
* ones, which a server may decode afterwards: so the rest of the path after `mcp.path` may hold no
|
|
98
106
|
* encoded slash, backslash or dot (`%2f`, `%5c`, `%2e`), no encoded percent (`%25`, a second round of
|
|
99
107
|
* encoding), and no backslash.
|
|
108
|
+
*
|
|
109
|
+
* With `shared` (APC 0.8: a Direct endpoint whose grants share one URL, the credential naming the grant),
|
|
110
|
+
* the URL may also be `mcp.path` itself or `mcp.path` without its last `/`. Without it, as a hub pairing a
|
|
111
|
+
* Dock app checks, the URL must lie strictly under `mcp.path`: one URL per grant.
|
|
100
112
|
*/
|
|
101
|
-
export function declaresMcpUrl(manifest, mcpUrl) {
|
|
113
|
+
export function declaresMcpUrl(manifest, mcpUrl, options = {}) {
|
|
102
114
|
let url;
|
|
103
115
|
try {
|
|
104
116
|
url = new URL(mcpUrl);
|
|
@@ -108,9 +120,12 @@ export function declaresMcpUrl(manifest, mcpUrl) {
|
|
|
108
120
|
}
|
|
109
121
|
if (!urlOnOrigin(url.href, manifest.origin))
|
|
110
122
|
return false;
|
|
111
|
-
|
|
123
|
+
const path = manifest.mcp.path;
|
|
124
|
+
if (options.shared && path !== "/" && (url.pathname === path || url.pathname === path.slice(0, -1)))
|
|
125
|
+
return !/%(2f|5c|2e|25)/i.test(mcpUrl.split(/[?#]/, 1)[0]);
|
|
126
|
+
if (!url.pathname.startsWith(path) || url.pathname.length <= path.length)
|
|
112
127
|
return false;
|
|
113
|
-
const rest = url.pathname.slice(
|
|
128
|
+
const rest = url.pathname.slice(path.length);
|
|
114
129
|
// Checked on the URL as sent too: the parser turns some encoded dots into segments it then removes.
|
|
115
130
|
const sent = mcpUrl.split(/[?#]/, 1)[0];
|
|
116
131
|
const encoded = /%(2f|5c|2e|25)/i;
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tineira/apc-protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Agent Pointed Context (APC): the contract an app and an AI agent share. Wire types, the target grammar, the core, apc.json and the version of the standard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"apc",
|