@tineira/apc-protocol 0.7.0 → 0.8.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 CHANGED
@@ -2,7 +2,41 @@
2
2
 
3
3
  This file records the contract that an app and an agent share in Agent Pointed Context: the view an app reports, the pin (focus) a person drops on a target, presence, the format of the focus, `wait_for_focus`, the tools an app and a hub expose, the drag payload and pairing. It does not record the software (Pulse, the dock, Signals, the extension, the desktop apps); that is [`CHANGELOG.md`](../../CHANGELOG.md) at the root.
4
4
 
5
- The standard has its own semver, `APC_STANDARD_VERSION` in `@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.
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
+
7
+ ## 0.8.0 (draft, 2026-11-12)
8
+
9
+ 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.
10
+
11
+ ### The loop
12
+
13
+ - **`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.
14
+ - **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.
15
+ - **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.
16
+ - **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.
17
+ - **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).
18
+
19
+ ### The Direct profile, whatever the auth
20
+
21
+ - **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.
22
+ - **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`.
23
+ - **`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.
24
+ - **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.
25
+
26
+ ### Conformance
27
+
28
+ - 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.
29
+ - 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.
30
+
31
+ 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.
32
+
33
+ ## 0.7.1 (draft, 2026-11-04)
34
+
35
+ 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.
36
+
37
+ - **The package names in the doc comments are the published ones.** `core.ts` and `manifest.ts` (and so the `.d.ts` files in `dist/`) named `@apc/server`, `@apc/web` and `@apc/conformance`, from before the rename; they now say `@tineira/apc-server`, `@tineira/apc-web` and `@tineira/apc-conformance`.
38
+ - **The conformance checks take a newer patch of their own standard.** An app whose `apc.json` says a newer patch of the minor `@tineira/apc-conformance` knows (0.7.2 against 0.7.1) passes the standard check with a note, since a patch never changes the contract; a newer minor still fails until the checks are updated. Before, an app that moved to 0.7.1 failed against the 0.7.0 checks. The in-process suite keeps the note too: after `run()`, the check's `note` says it, and `runConformance` puts it on the result. (`standardSupport` in `packages/conformance/src/shared.ts`, `packages/conformance/src/index.ts`)
39
+ - The four packages and `APC_STANDARD_VERSION` are 0.7.1; nothing else in what the packages ship changed.
6
40
 
7
41
  ## 0.7.0 (draft, 2026-11-04)
8
42
 
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.7.x ships APC 0.7).
11
+ Pre-releases go out on the `next` tag while the standard is 0.x; the package's version follows the standard's (0.8.x ships APC 0.8).
12
12
 
13
13
  ```ts
14
14
  import { APC_STANDARD_VERSION, focusAttrs, parseApcManifest } from "@tineira/apc-protocol";
@@ -32,11 +32,11 @@ 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's MCP URL answers 403 with `RevokedAnswer` from then on, a call already running ends the same way (told by the grant's secret, never an id a later grant may reuse, 0.7), and the grant never works again. |
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
 
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 URL, one per agent. The connection (a grant
7
- * per agent, its secret MCP URL, the agent list where the person makes and names them) is the
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`)
@@ -13,7 +16,7 @@
13
16
  * Consent in the app is the app's server's to check (APC 0.7), never the page's alone: the server makes
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
- * `DockConsent` in @apc/server), whatever the page last loaded. A page that missed a revoke must not
19
+ * `DockConsent` in `@tineira/apc-server`), whatever the page last loaded. A page that missed a revoke must not
17
20
  * pair without asking.
18
21
  *
19
22
  * The core is the same on both, and per grant: the hub is one more holder of a grant.
@@ -26,7 +29,9 @@
26
29
  * the person's page; a read of a target names it in `targets`.
27
30
  * 4. **The focus format**: `FocusPayload`, from the grammar in `target.ts`.
28
31
  * 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.
32
+ * hint `then` while a page of the person's is open and the grant was pinned lately (APC 0.8), at most
33
+ * one delivering wait per grant (APC 0.8), and a pin marked seen only once the result that carries
34
+ * it is final (APC 0.8): an app may deliver a pin twice, never lose one.
30
35
  * 6. **Revoke from the app**: the person can cut any agent from the app itself, at once, without the
31
36
  * hub (`RevokedAnswer`).
32
37
  * 7. **`apc_link`** (an app that offers both profiles): the same agent on both paths shown as one,
@@ -36,7 +41,7 @@
36
41
  * 9. **Mobile** (APC 0.6): on a phone or any touch viewport (`APC_MOBILE_MEDIA`) the app shows its agents
37
42
  * in its own page, on both profiles (below).
38
43
  *
39
- * `@apc/conformance` checks an app against this core, given its MCP endpoint and a small adapter for
44
+ * `@tineira/apc-conformance` checks an app against this core, given its MCP endpoint and a small adapter for
40
45
  * what happens in the page.
41
46
  */
42
47
  import type { FocusPayload, ViewPayload } from "./types.js";
@@ -48,21 +53,68 @@ export type CoreTool = (typeof CORE_TOOLS)[number];
48
53
  /** The key an unread pin puts on every tool result (structured and in the text): `{ focus, note }`. */
49
54
  export declare const ATTENTION_KEY = "attention";
50
55
  /**
51
- * The loop hint: present on a result while a page of the person's is open and they share their view,
52
- * except on a `wait_for_focus` that came back with no focus (that one ends the loop).
56
+ * The loop hint. Since APC 0.8 it is present on a result only while both hold: a page of the person's
57
+ * is open (and they share their view), and the grant was pinned within the app's window
58
+ * (`THEN_PIN_WINDOW_MIN`, 30 minutes unless the app sets its own). An open page with no recent pin gives
59
+ * no `then`, so an agent does not wait after every answer. It is never on a `wait_for_focus` that came
60
+ * back with no focus (that one ends the loop).
61
+ *
62
+ * The agent is told (in the instructions, the guide, the note and the tool's description) to run the
63
+ * wait in the background if its client can (a background subagent, say), and otherwise to keep it short
64
+ * and never chain waits.
53
65
  */
54
66
  export declare const THEN_KEY = "then";
55
- /** `get_current_view`: the screen and, if the person pointed the agent, the focus. Reading the focus marks it seen. */
67
+ /** The default `then` window, in minutes: the grant's pin, read or unread, is at most this old. An app may set its own. */
68
+ export declare const THEN_PIN_WINDOW_MIN = 30;
69
+ /**
70
+ * `get_current_view`: the screen and, if the person pointed the agent, the focus. Returning the focus
71
+ * marks it seen, once the result is final (APC 0.8, `WaitForFocusResult`).
72
+ *
73
+ * **Unavailable (APC 0.8).** When the app cannot answer a core tool just now (its store is down, a check
74
+ * cannot run), the tool still answers in its own shape, with nothing in it and a `note` for the agent:
75
+ * `{ view: null, focus: null, note }` here, `{ focus: null, timed_out: false, page_open: false, note }`
76
+ * from `wait_for_focus`. Never a tool error or an `{ ok, reason }` of the app's own. A revoked grant is not
77
+ * unavailable: it answers `RevokedAnswer`.
78
+ *
79
+ * **Trim, never fail (APC 0.8).** A check on one item of the answer (a filter, the selection, the pin)
80
+ * that cannot run hides that item, and the rest still answers. It never fails the read open (the item
81
+ * shown unchecked), and a defect in one check (a missing column, a value it cannot parse) never fails
82
+ * the read whole. A failure that concerns the grant itself (its auth, an expired session) still fails it
83
+ * closed.
84
+ */
56
85
  export interface CurrentViewResult {
57
86
  view: ViewPayload | null;
58
87
  focus: FocusPayload | null;
88
+ /** Why the result is empty, when the app could not answer it just now (APC 0.8). */
89
+ note?: string;
59
90
  }
60
91
  /**
61
92
  * `wait_for_focus({ timeout_s })`: holds the call until the person pins the agent or `timeout_s`
62
93
  * passes (1 to the app's ceiling). A pin dropped during the wait is returned, marked seen. With no
63
94
  * pin: `timed_out: true` at the timeout, or `timed_out: false, page_open: false` as soon as the app
64
95
  * 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).
96
+ * view (an app without that switch always shares). The default timeout is the app's: 25 to 45 s suits
97
+ * a conversation (`WAIT_DEFAULT_S` in `@tineira/apc-server` is 25), bounded by what its host can hold.
98
+ *
99
+ * **One wait per grant (APC 0.8).** At most one wait of a grant delivers. A new wait takes over: the one
100
+ * it replaces ends at its next check with `focus: null, timed_out: false` and a note, delivers nothing,
101
+ * and never marks a pin seen. A new wait that starts within `WAIT_CLAIM_GAP_S` of one still running is
102
+ * refused instead, with `focus: null, timed_out: false, retry_after_s` and a note: two waits fired
103
+ * together would otherwise take over from each other in a loop. A wait whose request the client cancels
104
+ * stops and frees its place at once: MCP's `notifications/cancelled` (which, to a stateless server, comes
105
+ * in a POST of its own: the app keeps the calls in flight per grant to find the one it names,
106
+ * `InflightCalls` in `@tineira/apc-server`), and an HTTP abort where the host reports one (on Cloudflare
107
+ * Workers, `request.signal` with the `enable_request_signal` flag, or a compatibility date from 2026-11-17).
108
+ *
109
+ * **Seen once delivered (APC 0.8).** Checking for a pin, or polling for one, never marks it seen:
110
+ * only returning it does. An app marks a pin seen only once the result that carries it is final (in a
111
+ * wait, after it gave its place up and checked one last time that its request was not cancelled), and
112
+ * marks that exact pin (by its `pinned_at`), never a newer one dropped meanwhile. It returns the result
113
+ * whether or not the mark succeeds. So a pin may be delivered twice, and is never lost to a wait that was
114
+ * taken over or cancelled, or to a newer pin. MCP has no acknowledgement: a result lost after the app sent
115
+ * it (the connection dropped with no abort the app could see) is not something an app can tell, and that
116
+ * pin is marked seen. A hub that relays a wait holds the pin it was handed: it keeps it unread until its
117
+ * own agent gets it.
66
118
  */
67
119
  export type WaitForFocusResult = WaitGotFocus | WaitNoFocus;
68
120
  export interface WaitGotFocus {
@@ -73,19 +125,56 @@ export interface WaitNoFocus {
73
125
  focus: null;
74
126
  timed_out: boolean;
75
127
  page_open?: boolean;
128
+ /** On a wait refused because another wait of the grant started just before it (APC 0.8): when to try again. */
129
+ retry_after_s?: number;
76
130
  note: string;
77
131
  }
132
+ /** How soon after one wait of a grant another is refused rather than taking over, in seconds (APC 0.8). */
133
+ export declare const WAIT_CLAIM_GAP_S = 2;
134
+ /**
135
+ * What a Direct grant must be, whatever way the agent authenticates (APC 0.8). The standard does not fix
136
+ * the binding (a secret MCP URL, OAuth, an API key, mTLS); any binding that meets these will do:
137
+ *
138
+ * - `stable_id`: **a stable id per agent connection.** Pins, views, presence and `apc_link` are keyed by it.
139
+ * - `revocable_in_app`: **revocable from the app alone.** The app revokes it in its own store, calling
140
+ * nobody, and checks it on every request, and before and after each tool call. Revoking ends everything
141
+ * that could bring the grant back without the person: with OAuth, the refresh tokens and the
142
+ * authorization behind it too, so a client that answers a 401 by refreshing or re-authorizing silently
143
+ * gets nothing. The person lets the agent back with a new grant.
144
+ * - `revoked_told_apart`: **revoked is told apart from expired or unknown**, with a `RevokedAnswer` body,
145
+ * so the agent tells the person and stops retrying, where an expired credential may just be renewed.
146
+ * - `trimmed_per_grant`: **pointing grants no access.** What a grant may read is trimmed per grant; a pin
147
+ * never widens it.
148
+ *
149
+ * Two bindings are described:
150
+ *
151
+ * - **The secret URL** (the reference apps; `newDirectGrant` and `directMcpUrl` in `@tineira/apc-server`):
152
+ * one MCP URL per grant, under `apc.json`'s `mcp.path`; whoever holds the URL holds the grant. Revoked is
153
+ * HTTP 403 with `RevokedAnswer`; an unknown or expired grant is 404.
154
+ * - **OAuth 2.1**: one MCP URL shared by every grant, the bearer token naming the grant. Revoked is HTTP
155
+ * 401 with `WWW-Authenticate: Bearer error="invalid_token"` and the `RevokedAnswer` body, so the client
156
+ * tells "taken back by the person" from "expired" and does not just authorize again.
157
+ *
158
+ * On a host with no long-lived process (stateless functions and a database), an app still bounds
159
+ * `wait_for_focus` by its function's time limit (its own ceiling for `timeout_s`), tells a closed page from
160
+ * a heartbeat the page sends, and checks the grant before and after each tool call.
161
+ */
162
+ export type GrantRequirement = "stable_id" | "revocable_in_app" | "revoked_told_apart" | "trimmed_per_grant";
163
+ export declare const GRANT_REQUIREMENTS: readonly GrantRequirement[];
78
164
  /**
79
165
  * Revoke from the app. Every agent in the app's agent bar, Direct or Dock, has Revoke, and it holds
80
166
  * in the app alone:
81
167
  *
82
168
  * - It takes effect at once: the grant, its pin and (Dock) the app's link to the hub go. The app
83
169
  * calls nobody, so it works with the hub down.
84
- * - From then on every request to the grant's MCP URL, whatever the method, is answered with HTTP
85
- * `REVOKED_HTTP_STATUS` (403) and a `RevokedAnswer` body. A call already running when the grant is
86
- * revoked ends as a tool error whose `error` is `"revoked"`, with no app data. An unknown or
87
- * expired grant stays 404, so the holder can tell "taken back" from "gone". Whether a grant was
88
- * revoked is told by the grant itself (its secret), never by an id a later grant may reuse (0.7).
170
+ * - From then on every request with the grant, whatever the method, is answered with a `RevokedAnswer`
171
+ * body. The HTTP status is the binding's (APC 0.8, `GrantRequirement`): `REVOKED_HTTP_STATUS` (403)
172
+ * with a secret URL, `REVOKED_HTTP_STATUS_BEARER` (401, with `WWW-Authenticate: Bearer
173
+ * error="invalid_token"`) with OAuth. A call already running when the grant is revoked ends as a tool
174
+ * error whose `error` is `"revoked"`, with no app data. An unknown or expired grant is answered without
175
+ * a `RevokedAnswer` (404 with a secret URL; a plain 401 with OAuth), so the holder can tell "taken back"
176
+ * from "gone". Whether a grant was revoked is told by the grant itself (its secret or token), never by
177
+ * an id a later grant may reuse (0.7).
89
178
  * - A revoked grant never works again. To let the agent back, the person makes a new grant: a new
90
179
  * MCP URL (Direct), or a new drop, with consent in the app and in the dock (Dock).
91
180
  * - A hub that gets a `RevokedAnswer` marks that pairing revoked ("Revoked by <app host>"), stops
@@ -93,7 +182,10 @@ export interface WaitNoFocus {
93
182
  * again by itself.
94
183
  */
95
184
  export declare const REVOKED_ERROR = "revoked";
185
+ /** The status of a revoked grant with the secret-URL binding. */
96
186
  export declare const REVOKED_HTTP_STATUS = 403;
187
+ /** The status of a revoked grant with a bearer token (OAuth), with `WWW-Authenticate: Bearer error="invalid_token"` (APC 0.8). */
188
+ export declare const REVOKED_HTTP_STATUS_BEARER = 401;
97
189
  export interface RevokedAnswer {
98
190
  error: typeof REVOKED_ERROR;
99
191
  /** The host of the app that revoked the grant, e.g. "pulse.example". */
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: present on a result while a page of the person's is open and they share their view,
7
- * except on a `wait_for_focus` that came back with no focus (that one ends the loop).
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 to the grant's MCP URL, whatever the method, is answered with HTTP
17
- * `REVOKED_HTTP_STATUS` (403) and a `RevokedAnswer` body. A call already running when the grant is
18
- * revoked ends as a tool error whose `error` is `"revoked"`, with no app data. An unknown or
19
- * expired grant stays 404, so the holder can tell "taken back" from "gone". Whether a grant was
20
- * revoked is told by the grant itself (its secret), never by an id a later grant may reuse (0.7).
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,
@@ -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
@@ -31,7 +38,7 @@ export declare const APC_JSON_PATH = "/.well-known/apc.json";
31
38
  /** The most bytes a reader takes of an `apc.json`. */
32
39
  export declare const APC_JSON_MAX_BYTES = 16384;
33
40
  /**
34
- * Where an app's MCP URLs live when it says nothing else: `@apc/server`'s default `mcp.path`, and the
41
+ * Where an app's MCP URLs live when it says nothing else: `@tineira/apc-server`'s default `mcp.path`, and the
35
42
  * path a hub holds a verified app to when it pairs it without a file (its file out of reach, no copy).
36
43
  */
37
44
  export declare const APC_DEFAULT_MCP_PATH = "/mcp/";
@@ -50,9 +57,15 @@ export interface ApcManifest {
50
57
  mcp: {
51
58
  path: string;
52
59
  };
53
- /** With "direct": where the person connects an agent directly, on the same origin. */
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,12 +102,18 @@ 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): boolean;
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
97
- * own words, on its own page (`@apc/web`); the browser extension, which speaks for itself, puts the
116
+ * own words, on its own page (`@tineira/apc-web`); the browser extension, which speaks for itself, puts the
98
117
  * domain first and the name only as what the page says it is.
99
118
  */
100
119
  export declare function directOnlyNote(name: string): string;
package/dist/manifest.js CHANGED
@@ -3,13 +3,14 @@ export const APC_JSON_PATH = "/.well-known/apc.json";
3
3
  /** The most bytes a reader takes of an `apc.json`. */
4
4
  export const APC_JSON_MAX_BYTES = 16_384;
5
5
  /**
6
- * Where an app's MCP URLs live when it says nothing else: `@apc/server`'s default `mcp.path`, and the
6
+ * Where an app's MCP URLs live when it says nothing else: `@tineira/apc-server`'s default `mcp.path`, and the
7
7
  * path a hub holds a verified app to when it pairs it without a file (its file out of reach, no copy).
8
8
  */
9
9
  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
- if (!url.pathname.startsWith(manifest.mcp.path) || url.pathname.length <= manifest.mcp.path.length)
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(manifest.mcp.path.length);
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;
@@ -119,7 +134,7 @@ export function declaresMcpUrl(manifest, mcpUrl) {
119
134
  /**
120
135
  * What an app tells a person who drops an agent from their dock on it when it connects agents only
121
136
  * directly (its `apc.json` lists no `"dock"`): the drop is answered, never lost. These are the app's
122
- * own words, on its own page (`@apc/web`); the browser extension, which speaks for itself, puts the
137
+ * own words, on its own page (`@tineira/apc-web`); the browser extension, which speaks for itself, puts the
123
138
  * domain first and the name only as what the page says it is.
124
139
  */
125
140
  export function directOnlyNote(name) {
package/dist/version.d.ts CHANGED
@@ -7,4 +7,4 @@
7
7
  * and becomes 1.0.0 when the standard is published. Every change to the
8
8
  * contract adds an entry to `packages/protocol/CHANGELOG.md` and bumps this.
9
9
  */
10
- export declare const APC_STANDARD_VERSION = "0.7.0";
10
+ export declare const APC_STANDARD_VERSION = "0.8.0";
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  * and becomes 1.0.0 when the standard is published. Every change to the
8
8
  * contract adds an entry to `packages/protocol/CHANGELOG.md` and bumps this.
9
9
  */
10
- export const APC_STANDARD_VERSION = "0.7.0";
10
+ export const APC_STANDARD_VERSION = "0.8.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tineira/apc-protocol",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",