@tineira/apc-protocol 0.0.0-stage → 0.7.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 +150 -0
- package/LICENSE +21 -0
- package/README.md +66 -2
- package/dist/core.d.ts +187 -0
- package/dist/core.js +97 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/manifest.d.ts +105 -0
- package/dist/manifest.js +134 -0
- package/dist/target.d.ts +28 -0
- package/dist/target.js +88 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.js +6 -0
- package/dist/version.d.ts +10 -0
- package/dist/version.js +10 -0
- package/package.json +42 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Changelog of the APC standard
|
|
2
|
+
|
|
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
|
+
|
|
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.
|
|
6
|
+
|
|
7
|
+
## 0.7.0 (draft, 2026-11-04)
|
|
8
|
+
|
|
9
|
+
Consent in the app, checked by the app's server, and revoke told by the grant itself (TIN-606). In `packages/protocol/src/core.ts`, `packages/server/src/consent.ts` and DESIGN.md section 14.
|
|
10
|
+
|
|
11
|
+
- **Consent in the app is the app's server's to check (the Dock profile).** An app makes a grant for a dock agent it holds no grant for only on a request that proves the person allowed it on the app's page just now, whatever the page last loaded: a page that missed a revoke, or a pairing the dock forgot, must not pair without asking. The reference apps send every dock drop to their server; for an agent with no grant it answers `consent_required` (`CONSENT_REQUIRED`) with a nonce, the page shows its consent with it, and the Allow sends it back. `DockConsent` in `@apc/server` gives and takes the nonce: 128 random bits, once, for the same dock and agent, within `DOCK_CONSENT_TTL_MS` (two minutes). The drag's ticket is not spent until the app calls `POST {hub}/apc/pair`. One drop of an agent is answered at a time: a second request for the same dock and agent while the first waits on the hub answers `pairing_in_progress` and changes nothing, and the new grant is linked to the agent only once the hub took it. Nothing changes between the app and the hub.
|
|
12
|
+
- **Whether a grant was revoked is told by the grant itself** (its secret, and the hash the app keeps of it), never by a grant id a later grant may reuse: a call running when its grant was revoked ends `revoked` even if a new grant took the id, and a later grant with a revoked one's id that is given back ends its running call as an unknown grant, not as revoked.
|
|
13
|
+
|
|
14
|
+
## 0.6.0 (draft, 2026-11-04)
|
|
15
|
+
|
|
16
|
+
On mobile, every app shows the agent in its page (TIN-608). In `packages/protocol/src/core.ts`, `packages/web`, `packages/conformance` and DESIGN.md section 19.
|
|
17
|
+
|
|
18
|
+
- **A new core rule (item 9).** In a viewport that matches `APC_MOBILE_MEDIA` (`(pointer: coarse), (max-width: 640px)`: a touch device, or a narrow window), an app shows its agents in its own page, each with its mark visible and usable for pinning, on both profiles. On a phone there is no dock window, extension or desktop pet to drag from, so the agent in the page is the only way the person can point it. The Dock profile's rule that the agent bar may collapse (0.4.0) does not apply there: the bar is never collapsed or hidden, and it stays reachable while the page scrolls without covering content.
|
|
19
|
+
- **A finger can pin.** With no mouse drag (phone browsers do no HTML5 drag and drop): touching the agent's mark and then a target (tap to pin, with a clear state and a way to cancel), or dragging the mark with a finger by pointer events. The result is the same pin as a mouse drag's. Unpin and Revoke work by touch, with finger-sized targets.
|
|
20
|
+
- `APC_MOBILE_MEDIA` and `APC_MOBILE_VIEWPORT` (375 x 812, the reference viewport) are exported.
|
|
21
|
+
- **Conformance.** An adapter may give `page.mobile` (`ApcMobilePage`: `agentBar`, `pinByTouch`, `unpinByTouch`). The suite then checks that every connected agent is shown in the page and the bar cannot be collapsed, that a pin made by touch is read by the agent through `get_current_view`, and that unpin by touch removes it.
|
|
22
|
+
- An app that implemented 0.5 and meets the rule needs no change to its MCP surface or `apc.json`; `standard` in `apc.json` may say 0.5.0 or 0.6.0.
|
|
23
|
+
|
|
24
|
+
## 0.5.0 (draft, 2026-11-03)
|
|
25
|
+
|
|
26
|
+
Discovery with `apc.json`, and a clear answer to a drop an app cannot take (TIN-579). In `packages/protocol/src/manifest.ts`, `packages/server/src/apc-json.ts`, `packages/web/src/pinning.ts`, `packages/conformance`, `apps/dock/src/worker/hub.ts` and DESIGN.md section 17.
|
|
27
|
+
|
|
28
|
+
### `apc.json`
|
|
29
|
+
|
|
30
|
+
- **Every APC origin says what it is** at `GET {origin}/.well-known/apc.json` (`APC_JSON_PATH`), from that origin, as JSON (`ApcManifest`): `name`, `standard` (the version it implements), `origin` (exactly the origin it is served from), `profiles`, `mcp: { path }` (every grant's MCP URL on the origin starts with `origin + path`), and, with `"direct"`, `direct: { connect }` (the page on its own origin where the person connects an agent directly). At most `APC_JSON_MAX_BYTES` (16 KiB).
|
|
31
|
+
- **`profiles`** is `"direct"`, `"dock"` or both for an app, and `"hub"` alone for a dock's hub (`ApcRole`), so apps and tools can tell a dock from an app. A hub's file also has `dock: { pair, confirm }`, its pairing endpoints. A reader ignores profile words and keys it does not know.
|
|
32
|
+
- `parseApcManifest(raw, origin)` checks a file read from `origin`: the manifest, or `apc_json_wrong_origin` (it names another origin) or `apc_json_invalid` (anything else, with a reason). `declaresMcpUrl(manifest, url)` says whether an MCP URL is an http(s) URL on the origin and under `mcp.path`, with no encoded `/`, `\`, `.` or `%` (`%2f`, `%5c`, `%2e`, `%25`) in its path and no `.` or `..` segment after `mcp.path`, so a server that decodes later cannot be walked out of it. Every URL in a manifest is http(s) on its origin (`urlOnOrigin`; a `blob:` URL of the origin is not). `compareStandard` orders versions.
|
|
33
|
+
|
|
34
|
+
### Pairing (the Dock profile)
|
|
35
|
+
|
|
36
|
+
- **A hub reads the app's `apc.json` before it pairs** (`POST {hub}/apc/pair`), once the ticket and the app's origin have passed, under the hub's transport rules (0.3.0): a time limit and the byte cap, and no redirect followed for any app, verified or not (the file is the origin's own or none). It refuses the pairing (`ApcJsonRefusal`) when the file is missing (`apc_json_missing`: a 4xx), answered with a redirect (`apc_json_redirected`, any app: a redirect is the origin answering), cannot be read as one (`apc_json_invalid`), names another origin (`apc_json_wrong_origin`), does not list `"dock"` (`dock_not_offered`), or does not cover the MCP URL the app sent (`mcp_url_not_declared`). An unverified app whose file cannot be reached (no answer, a time limit, a 5xx) is refused too (`apc_json_unreachable`).
|
|
37
|
+
- A hub may keep the copy it read: the reference dock uses it for 10 minutes, then reads the file again. A verified app whose file cannot be reached at all (no answer, a time limit, a 5xx) keeps pairing, checked against the last copy the hub read, or on the operator's word when it has none. Even then the MCP URL must lie under `APC_DEFAULT_MCP_PATH` (`/mcp/`) and pass `declaresMcpUrl`'s checks: the operator's word covers the profiles, never the path. A definite answer (missing, a redirect, invalid, another origin) refuses a verified app too. The hub reads the file only when an app asks to pair: a pairing made stays until it is revoked or unpaired.
|
|
38
|
+
|
|
39
|
+
### A drop an app cannot take
|
|
40
|
+
|
|
41
|
+
- **A dock agent dropped on an app that offers only Direct is answered, never lost.** The app (with `@apc/web`: `enablePinning({ profiles, direct: { name, connect }, onDockUnsupported })`) takes the drop anywhere on the page and tells the person that it connects agents directly, with its connect page, in the words of `directOnlyNote(name)`. It never pairs or pins such a drop. `profiles` may be a list, a promise or a loader such as `loadApcProfiles` (the page's own `apc.json`, read with no redirect, 3 s and 16 KiB at most). A dock agent's drop made before the profiles are known is held, then answered or paired as they say. When they cannot be read, the drop is answered, never paired: `DockUnsupported.reason` is `unchecked`, in the words of `couldNotCheckNote(name)`, and a loader is asked again on the next drop (`reason` is `direct_only` for a Direct-only app). While it takes native drops, `enablePinning` sets `data-apc-native-drops` on the root element; the browser extension answers a Direct-only app (by its `apc.json`) that does not, in its own words (the domain first).
|
|
42
|
+
|
|
43
|
+
### Conformance
|
|
44
|
+
|
|
45
|
+
- The suite reads the app's `apc.json` through the adapter's `fetch`: served as JSON from the app's origin, valid, a standard from 0.5.0 to the suite's own, the profile under test listed (both with `link`), never `"hub"`, the grant's MCP URL under `mcp.path`, and `direct.connect` on its origin.
|
|
46
|
+
|
|
47
|
+
## 0.4.0 (draft, 2026-11-03)
|
|
48
|
+
|
|
49
|
+
`apc_link`: one agent on both paths, shown as one (TIN-578). In `packages/protocol/src/core.ts`, `packages/server/src/links.ts`, `packages/conformance` and DESIGN.md section 16.
|
|
50
|
+
|
|
51
|
+
- **The offer.** When a connection arrives (`initialize`) with the same MCP client as one already connected on the opposite path to the same app, the app puts `apc_link: { code, expires_in, note }` (`ApcLinkOffer`, key `APC_LINK_KEY`) on a result of the new connection, a top-level key of the structured result and of the text, like `attention`. On the Dock path it rides on a result of one of the app's own tools, never a core tool (the hub merges those). The app asks at most once per MCP session, from one `initialize` to the next.
|
|
52
|
+
- **The answer.** The agent calls the tool `apc_link({ code })` (`APC_LINK_TOOL`) on its other connection to the same app: directly, or through the dock as `<app>__apc_link`. The app answers `ApcLinkDone` (`{ linked: true, note }`), or a tool error `ApcLinkRefused` (`{ error: "link_refused", reason, message }`).
|
|
53
|
+
- **The code** has at least 96 random bits, works once and expires after `APC_LINK_TTL_S` (120) seconds. It is redeemed only on a different connection to the same app, on the opposite path, among those it was offered to (the same client, neither linked). Otherwise the reason is `unknown_code`, `expired`, `used`, `same_connection`, `same_path`, `not_offered` or `already_linked`. The app keeps only its hash.
|
|
54
|
+
- **Linking grants nothing.** Each path keeps its own grant, trimming and pin; the agent bar shows one agent with both paths. Revoke on a linked agent cuts both grants, and the person can unlink, which removes the link only.
|
|
55
|
+
- **The client on the Dock path.** The hub initializes an app as itself (`clientInfo.name` "apc-hub"), so it names the agent's own MCP client in `params._meta["apc.tineira.com/agent_client"]` (`APC_AGENT_CLIENT_META`) of that `initialize`. On the Direct path the client is the agent's own `clientInfo.name`.
|
|
56
|
+
- `oppositePath` gives the other profile. `APC_INSTRUCTIONS` and the `guide` prompt tell the agent what an `apc_link` key means.
|
|
57
|
+
- The hub names the agent's client to verified apps only: an unverified app (0.3.0) learns nothing of it, so it is never offered a link through the hub.
|
|
58
|
+
|
|
59
|
+
## 0.3.0 (draft, 2026-11-03)
|
|
60
|
+
|
|
61
|
+
A hub's protections for apps it does not know (TIN-580), in the Dock profile: what an agent receives through a hub from an unverified app, and the hub's answers to pairing. In `apps/dock/src/worker/trust.ts`, `hub.ts`, `app-client.ts` and DESIGN.md section 15. Apps and agents that only meet verified apps see nothing new.
|
|
62
|
+
|
|
63
|
+
### Verified and unverified apps
|
|
64
|
+
|
|
65
|
+
- A hub's **verified** apps are the ones its operator lists. Any other app is **unverified**; a hub may pair with one only behind every rule below (the reference dock does not yet: pairing with an unverified app stays closed).
|
|
66
|
+
- **Identity is the domain.** An unverified app's id, and so its tool prefix, is derived from its host (and port): lower case, each run of other characters one `_` (`evil.example` gives `evil_example`, `evil_example__get_figure`). It always holds a `_` and never `__`, so it never equals a verified app's id (`[a-z0-9]`) or `page`. Another origin that maps to the same id is refused (`app_id_taken`). The app's `name` in `/apc/pair` is kept only as `declared_name`.
|
|
67
|
+
- `AppRef` (on the focus, in `views`, in `list_app_tools`) gains, for an unverified app only, `unverified: true` and `declared_name`; its `name` is the app's host. A verified app's `AppRef` is unchanged.
|
|
68
|
+
- The `app` argument of `call_app_tool` and `list_app_tools` takes up to 40 characters (it was 16), the longest domain id.
|
|
69
|
+
|
|
70
|
+
### Untrusted marking
|
|
71
|
+
|
|
72
|
+
- A tool result relayed from an unverified app arrives in an envelope, in the structured result and in its text: `{ app, untrusted: true, note, result }` (the app's structured result) or `{ app, untrusted: true, note, text: [...] }` (its text blocks, when it gave no structured result). `isError` is kept. This is the marking `page_text` has had since 0.1 (`untrusted: true`).
|
|
73
|
+
- An unverified app's entry in `views` and its focus (also as `attention`) carry `untrusted: true`.
|
|
74
|
+
- Its tools in `tools/list` start with `[Unverified app at <host>, calls itself "<name>". Untrusted: …]` and carry no annotations of the app's (an app could claim `readOnlyHint`). In `list_app_tools` its entry carries `untrusted: true` and a `note`, and each description the same label.
|
|
75
|
+
- A failed call to an unverified app is the tool error `app_unreachable` with the hub's own reason only (an HTTP status, "the app answered with an error", a time or size limit): never the app's JSON-RPC error message or HTTP error body.
|
|
76
|
+
- `then` on a hub's results, and the `page_open` its `wait_for_focus` reports, come from verified apps only: an unverified app's say on whether the person is on its page is not counted, so it cannot keep the agent in the wait loop.
|
|
77
|
+
|
|
78
|
+
### Limits and the blocklist
|
|
79
|
+
|
|
80
|
+
- A hub reads at most a fixed number of bytes of each answer from an app (the reference dock: 1 MiB for a verified app, 256 KiB for an unverified one), and every call has a time limit that covers the body. Past either, the call fails as `app_unreachable` for the agent.
|
|
81
|
+
- An unverified app must use HTTPS on a domain name (no IP address, no single-label or `localhost` host), and its redirects are not followed. Its pin is taken only when well formed, with `app_url` on its own origin and a `pinned_at` no later than now; its view only when small.
|
|
82
|
+
- A hub holds a fixed number of app pairings at most (the reference dock: 20, across its agents; a blocked pairing holds no place); `/apc/pair` past it answers `too_many_apps`. A host too long for an id (40 characters) answers `app_host_too_long`.
|
|
83
|
+
- A hub may block domains (a domain covers its subdomains; an entry in Unicode matches the app's punycode host). `/apc/pair` from one answers `app_blocked`, `/apc/confirm` too; a pairing made before the block reads `status: "blocked"`, is never called again (not even to give its grant back), its tools, view and pin go, and a call to it is the tool error `app_blocked`.
|
|
84
|
+
- New `/apc/pair` errors: `https_required`, `app_blocked`, `app_id_taken`, `app_host_too_long`, `too_many_apps`.
|
|
85
|
+
|
|
86
|
+
## 0.2.0 (draft, 2026-11-03)
|
|
87
|
+
|
|
88
|
+
The core written down, a conformance suite for it, and revoke from the app (TIN-577). In `packages/protocol/src/core.ts`, `packages/server/src/revoke.ts`, `packages/web/src/agents-bar.ts`, `packages/conformance` and DESIGN.md section 14.
|
|
89
|
+
|
|
90
|
+
### The core and the profiles
|
|
91
|
+
|
|
92
|
+
- The core is what every APC app implements, per grant, whichever way agents reach it: the view, the pin, presence, the focus format, `get_current_view` and `wait_for_focus` (`CORE_TOOLS`), and revoke from the app. A dock's hub is one more holder of a grant.
|
|
93
|
+
- Two profiles, each optional: **Direct** (the agent connects to the app's own MCP URL; the grant per agent, its URL and the agent list are the profile's) and **Dock** (the agent connects once, to a hub; receiving the drop, the ticket, consent in the app, `/apc/pair`, `/apc/confirm` and `/apc/revoke` from the hub are the profile's). `ApcProfile` names them.
|
|
94
|
+
- `CurrentViewResult` and `WaitForFocusResult` type the two tools' results. A wait returns a pin dropped during it, marked seen; with none, `timed_out: true` at the timeout, or `timed_out: false, page_open: false` as soon as the app knows the person's page is closed.
|
|
95
|
+
- `then` is never on a `wait_for_focus` result that came back with no focus: that result ends the loop. An app without a view-sharing switch always shares, so it always reports `page_open`.
|
|
96
|
+
- Presence is core: after each successful tool call the app publishes a `PresenceEvent` to the person's page, and a read of a target names it in `targets`.
|
|
97
|
+
- An unknown or expired grant answers 404, whatever the body.
|
|
98
|
+
|
|
99
|
+
### Revoke from the app
|
|
100
|
+
|
|
101
|
+
- Every agent in the app's agent bar (`<apc-agents>`), Direct or Dock, has Revoke: a first click asks, a second within `REVOKE_CONFIRM_MS` emits `apc-revoke`.
|
|
102
|
+
- It holds in the app alone and at once: the grant, its pin and (Dock) the app's link to the hub go. The app calls nobody, so it holds with the hub down.
|
|
103
|
+
- From then on every request to the grant's MCP URL is answered HTTP 403 (`REVOKED_HTTP_STATUS`) with a `RevokedAnswer`: `{ error: "revoked", revoked_by, revoked_at, message }`, `revoked_by` being the app's host. A call already running ends as a tool error with the same answer and no app data. `revokedAnswer` and `isRevokedAnswer` build and recognise it.
|
|
104
|
+
- A revoked grant never works again. The person lets the agent back with a new grant: a new MCP URL (Direct), or a new drop with consent in the app and in the dock (Dock).
|
|
105
|
+
- A hub that gets a `RevokedAnswer` marks the pairing revoked and shows "Revoked by <app host>", stops relaying to it, drops the view and the pin it took from it, never confirms it on a drop, and never pairs it again by itself. A new pairing with the same app replaces the revoked one.
|
|
106
|
+
|
|
107
|
+
## 0.1.0 (draft, 2026-11-03)
|
|
108
|
+
|
|
109
|
+
The contract as it stands today, in `packages/protocol/src`, `packages/server/src`, `packages/web/src` and DESIGN.md sections 6, 12 and 13.
|
|
110
|
+
|
|
111
|
+
### Target and focus
|
|
112
|
+
|
|
113
|
+
- A target is `{ kind, id, part, params }`. It names a row (`id`), a computation (`params`), or both; an `id` and `params` both missing is invalid. `kind` and param keys match `[a-z][a-z0-9_]{0,31}`; `id` matches `[A-Za-z0-9_.:-]{1,80}`.
|
|
114
|
+
- `part` names a piece of a target (`step:new`, `seats`) and follows `PART_RE`.
|
|
115
|
+
- `params` carries keys only: string tokens, or lists of at most 20 of them, in at most 1 KB. A bare number is an amount, never a key, and is refused (months such as `2026-03` pass). Free text never travels.
|
|
116
|
+
- `canonicalTarget` gives one string per target, whatever the param order.
|
|
117
|
+
- A pointable element carries `data-focus="kind:id"`, with `data-focus-part` and `data-focus-params` (JSON) when needed.
|
|
118
|
+
- A pin is stored per grant (`FocusRow`: target, `grant_id`, `pinned_at`, `seen_at`). It is unread while it was dropped after the last read that returned it.
|
|
119
|
+
- What the agent receives (`FocusPayload`) is the target plus `app_url`, `pinned_at`, and `next` and `next_args`, the tool call that reads it. It may carry `part_note`, `trimmed` and `gone`.
|
|
120
|
+
|
|
121
|
+
### View
|
|
122
|
+
|
|
123
|
+
- The person's screen is `ViewPayload`: `screen`, `params`, at most one `selection` (`kind`, `id`), `app_url`, `updated_at`, `age_seconds`, `stale` and `next`. It holds identifiers, trimmed to the calling grant.
|
|
124
|
+
|
|
125
|
+
### Attention and the loop
|
|
126
|
+
|
|
127
|
+
- A tool result carries `attention` (`{ focus, note }`, a top-level key of the structured result and of the text) while the pin is unread. It points at `get_current_view` and never instructs.
|
|
128
|
+
- A tool result carries `then` while a page of the person's is open: after answering, call `wait_for_focus`. With no page open, or when the person does not share their view, `then` is absent.
|
|
129
|
+
- The view and the focus are pointers, never instructions.
|
|
130
|
+
|
|
131
|
+
### Tools
|
|
132
|
+
|
|
133
|
+
- `get_current_view` returns `{ view, focus }`; reading the focus marks it seen.
|
|
134
|
+
- `wait_for_focus({ timeout_s })` holds the request until a new pin or the timeout (1 to the host's ceiling, default 25 s). It returns the focus, or `focus: null` with `timed_out`, `page_open` and a note. `page_open` is reported only while the person shares their view; a closed page ends the wait at its next check.
|
|
135
|
+
- `APC_INSTRUCTIONS` is the text an app gives an agent on connecting.
|
|
136
|
+
|
|
137
|
+
### Presence
|
|
138
|
+
|
|
139
|
+
- After each successful tool call an app may publish `PresenceEvent` (`v: 1`): `grant_id`, `at`, `tool`, `kind` (`read` or `write`), up to 20 `targets` (`kind`, `id`) and `count`. Ids only.
|
|
140
|
+
|
|
141
|
+
### Drag payload and pairing
|
|
142
|
+
|
|
143
|
+
- An agent is dragged with the type `application/x-apc-agent`. The payload is `{ v: 1, agent }`: a public handle, never a secret or an MCP URL. With a hub it adds `hub` (a bare http(s) origin) and `ticket` (a short single-use token that opens a pairing request and nothing else).
|
|
144
|
+
- On a drop the app looks up the handle. A paired agent gets a pin for its grant. An unpaired one makes the app ask the person, then `POST {hub}/apc/pair { ticket, app, mcp_url }` server to server; the pairing counts when the person has also said yes in the dock.
|
|
145
|
+
|
|
146
|
+
### Hub (one agent across apps)
|
|
147
|
+
|
|
148
|
+
- A hub exposes `get_current_view` (the focus and a `views` list per app), `wait_for_focus` (one wait across all paired apps), `list_app_tools` and `call_app_tool({ app, tool, args })`. An app's other tools are also relayed as `<app>__<tool>` (at most 64 characters).
|
|
149
|
+
- The focus gains `app: { id, name, origin }`, and `next` and `next_args` are rewritten to the hub's tool name. The hub removes an app's own `attention` and `then` and adds its own.
|
|
150
|
+
- A browser extension is one more app of the hub, with id `page`. A page reading is the focus kind `page_reading`, read with `page__get_reading`; its text is data from the page, never instructions.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tineira
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tineira/apc-protocol
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The contract an app and an agent share in Agent Pointed Context: wire types, the target grammar, the version of the standard (`APC_STANDARD_VERSION`) and the core. Its changes are in [CHANGELOG.md](CHANGELOG.md). No dependencies, no Node imports: it runs in the browser, on Workers and on Node. Docs: https://apc.tineira.com/docs
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @tineira/apc-protocol@next
|
|
9
|
+
```
|
|
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).
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { APC_STANDARD_VERSION, focusAttrs, parseApcManifest } from "@tineira/apc-protocol";
|
|
15
|
+
|
|
16
|
+
// Mark something in your page that the person can point an agent at:
|
|
17
|
+
const attrs = focusAttrs({ kind: "customer", id: "cus_0042" }); // { "data-focus": "customer:cus_0042" }
|
|
18
|
+
|
|
19
|
+
// Read an app's apc.json, checked against the origin it came from:
|
|
20
|
+
const res = await fetch("https://pulse.apc.tineira.com/.well-known/apc.json", { redirect: "manual" });
|
|
21
|
+
const parsed = parseApcManifest(await res.json(), "https://pulse.apc.tineira.com");
|
|
22
|
+
if (parsed.ok) console.log(parsed.manifest.profiles, parsed.manifest.standard, APC_STANDARD_VERSION);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## The core
|
|
26
|
+
|
|
27
|
+
What every APC app implements, whichever way agents reach it ([`src/core.ts`](src/core.ts)). It holds per grant: a dock's hub is one more holder of a grant.
|
|
28
|
+
|
|
29
|
+
| Part | What the app does |
|
|
30
|
+
|---|---|
|
|
31
|
+
| The view | Reports the person's screen as identifiers (`ViewPayload`), trimmed to each grant. |
|
|
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
|
+
| Presence | After each successful tool call, publishes a `PresenceEvent` (ids only) to the person's page; a read of a target names it. |
|
|
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. |
|
|
36
|
+
| `apc.json` | Serves `/.well-known/apc.json` from its own origin: what it is and which profiles it offers (below). |
|
|
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
|
+
| 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. |
|
|
40
|
+
|
|
41
|
+
What belongs to a profile, not to the core:
|
|
42
|
+
|
|
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.
|
|
45
|
+
|
|
46
|
+
Whatever it offers, an app publishes `apc.json` (below).
|
|
47
|
+
|
|
48
|
+
An app offers one profile or both. Pulse (`apps/demo`) and Signals (`apps/signals`) offer both, declared in `APC_PROFILES` in their `wrangler.jsonc`; the Direct profile's parts are packaged in `@tineira/apc-server` (`direct.ts`) and `@tineira/apc-web` (`<apc-direct-agents>`).
|
|
49
|
+
|
|
50
|
+
### Mobile (0.6)
|
|
51
|
+
|
|
52
|
+
On a phone there is no dock window, browser extension or desktop pet to drag an agent from, so the agent in the app's own page is the only way the person can point it. In a viewport that matches `APC_MOBILE_MEDIA` (`(pointer: coarse), (max-width: 640px)`), on both profiles, an app ([`src/core.ts`](src/core.ts)):
|
|
53
|
+
|
|
54
|
+
- **shows its agents in the page**, each with its mark visible and usable for pinning. The Dock profile's rule that the agent bar may collapse does not apply there, and the bar is never collapsed or hidden; it stays reachable while the page scrolls without covering content the person needs;
|
|
55
|
+
- **lets a finger pin**, with no mouse drag (phone browsers do no HTML5 drag and drop): touching the agent's mark and then a target ("tap to pin", with a clear state and a way to cancel), or dragging the mark with a finger by pointer events. The pin is the same `FocusTarget` as a mouse drag's. Unpin and Revoke work by touch, with finger-sized targets.
|
|
56
|
+
|
|
57
|
+
`@tineira/apc-web` does both (`<apc-agents>`, `enablePinning`). `APC_MOBILE_VIEWPORT` (375 x 812) is the reference viewport; `@tineira/apc-conformance` checks it through the adapter's `page.mobile`.
|
|
58
|
+
|
|
59
|
+
### `apc_link` (0.4)
|
|
60
|
+
|
|
61
|
+
An app that offers both profiles shows the same agent on both paths as one ([`src/core.ts`](src/core.ts)). When a connection arrives with the same MCP client as one already connected on the opposite path, the app puts `apc_link: { code, expires_in, note }` on a result of the new connection (on the Dock path, of one of its own tools), at most once per MCP session. The agent sends it back with the tool `apc_link({ code })` on its other connection (through a dock, `<app>__apc_link`). The code works once, for 120 s, only on a different connection on the opposite path that it was offered to; otherwise `link_refused` with a reason. Linking grants nothing: each path keeps its grant, the bar shows one agent, Revoke cuts both, and the person can unlink. On the Dock path the hub names the agent's own client in `_meta["apc.tineira.com/agent_client"]` of its `initialize`.
|
|
62
|
+
|
|
63
|
+
### `apc.json` (0.5)
|
|
64
|
+
|
|
65
|
+
Every APC origin says what it is at `GET {origin}/.well-known/apc.json` ([`src/manifest.ts`](src/manifest.ts)): its `name`, the `standard` it implements, its `origin` (exactly the one it is served from), its `profiles` (`"direct"`, `"dock"` or both; `"hub"` alone for a dock), `mcp.path` (where its MCP URLs live) and, with Direct, `direct.connect` (its page where the person connects an agent directly). `@tineira/apc-server` builds an app's (`apcManifest`, `apcJsonResponse`). A hub reads it before it pairs and refuses an app whose file is missing, invalid, names another origin, lists no `"dock"` or does not cover the MCP URL; a dock agent dropped on a Direct-only app is answered with how to connect directly (`directOnlyNote`), in the page and in the browser extension.
|
|
66
|
+
|
|
67
|
+
To check an app against the core, run [`@tineira/apc-conformance`](../conformance) on its MCP endpoint (see the root README), or `npx @tineira/apc-conformance@next <app-origin>` against a live app.
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The core of APC: what every APC app implements, whichever way agents reach it.
|
|
3
|
+
*
|
|
4
|
+
* An app offers one profile or both:
|
|
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.
|
|
9
|
+
* - **Dock**: the agent connects once, to a hub (the dock), which reaches the app with a grant the
|
|
10
|
+
* app gave it. Receiving the drop (`application/x-apc-agent`, the handle and the ticket), consent
|
|
11
|
+
* in the app, pairing (`POST {hub}/apc/pair`), the check on every drop (`POST {hub}/apc/confirm`)
|
|
12
|
+
* and the hub giving a grant back (`POST {app}/apc/revoke`) are the profile's, not the core's.
|
|
13
|
+
* Consent in the app is the app's server's to check (APC 0.7), never the page's alone: the server makes
|
|
14
|
+
* a grant for a dock agent it holds none for only on a request that proves the person allowed it on the
|
|
15
|
+
* 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
|
|
17
|
+
* pair without asking.
|
|
18
|
+
*
|
|
19
|
+
* The core is the same on both, and per grant: the hub is one more holder of a grant.
|
|
20
|
+
*
|
|
21
|
+
* 1. **The view**: what the person sees, as identifiers (`ViewPayload`), trimmed to the grant.
|
|
22
|
+
* 2. **The pin**: the person drops the agent's mark on a target (`FocusTarget`); the app keeps one
|
|
23
|
+
* pin per grant (`FocusRow`) and gives it as `FocusPayload`, with `next` and `next_args`, the
|
|
24
|
+
* call that reads it. While the pin is unread, every tool result carries `attention`.
|
|
25
|
+
* 3. **Presence**: after each successful tool call the app publishes a `PresenceEvent` (ids only) to
|
|
26
|
+
* the person's page; a read of a target names it in `targets`.
|
|
27
|
+
* 4. **The focus format**: `FocusPayload`, from the grammar in `target.ts`.
|
|
28
|
+
* 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.
|
|
30
|
+
* 6. **Revoke from the app**: the person can cut any agent from the app itself, at once, without the
|
|
31
|
+
* hub (`RevokedAnswer`).
|
|
32
|
+
* 7. **`apc_link`** (an app that offers both profiles): the same agent on both paths shown as one,
|
|
33
|
+
* with a one-time code; it grants nothing (`ApcLinkOffer`).
|
|
34
|
+
* 8. **`apc.json`** (APC 0.5): what the app is, at `GET {origin}/.well-known/apc.json`: its name, the
|
|
35
|
+
* standard, its origin, its profiles, where its MCP URLs live, its connect page (`manifest.ts`).
|
|
36
|
+
* 9. **Mobile** (APC 0.6): on a phone or any touch viewport (`APC_MOBILE_MEDIA`) the app shows its agents
|
|
37
|
+
* in its own page, on both profiles (below).
|
|
38
|
+
*
|
|
39
|
+
* `@apc/conformance` checks an app against this core, given its MCP endpoint and a small adapter for
|
|
40
|
+
* what happens in the page.
|
|
41
|
+
*/
|
|
42
|
+
import type { FocusPayload, ViewPayload } from "./types.js";
|
|
43
|
+
/** How an agent reaches an app. An app offers one or both; the core is the same on each. */
|
|
44
|
+
export type ApcProfile = "direct" | "dock";
|
|
45
|
+
/** The two tools every APC app exposes, on every grant. */
|
|
46
|
+
export declare const CORE_TOOLS: readonly ["get_current_view", "wait_for_focus"];
|
|
47
|
+
export type CoreTool = (typeof CORE_TOOLS)[number];
|
|
48
|
+
/** The key an unread pin puts on every tool result (structured and in the text): `{ focus, note }`. */
|
|
49
|
+
export declare const ATTENTION_KEY = "attention";
|
|
50
|
+
/**
|
|
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).
|
|
53
|
+
*/
|
|
54
|
+
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. */
|
|
56
|
+
export interface CurrentViewResult {
|
|
57
|
+
view: ViewPayload | null;
|
|
58
|
+
focus: FocusPayload | null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* `wait_for_focus({ timeout_s })`: holds the call until the person pins the agent or `timeout_s`
|
|
62
|
+
* passes (1 to the app's ceiling). A pin dropped during the wait is returned, marked seen. With no
|
|
63
|
+
* pin: `timed_out: true` at the timeout, or `timed_out: false, page_open: false` as soon as the app
|
|
64
|
+
* 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).
|
|
66
|
+
*/
|
|
67
|
+
export type WaitForFocusResult = WaitGotFocus | WaitNoFocus;
|
|
68
|
+
export interface WaitGotFocus {
|
|
69
|
+
focus: FocusPayload;
|
|
70
|
+
timed_out: false;
|
|
71
|
+
}
|
|
72
|
+
export interface WaitNoFocus {
|
|
73
|
+
focus: null;
|
|
74
|
+
timed_out: boolean;
|
|
75
|
+
page_open?: boolean;
|
|
76
|
+
note: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Revoke from the app. Every agent in the app's agent bar, Direct or Dock, has Revoke, and it holds
|
|
80
|
+
* in the app alone:
|
|
81
|
+
*
|
|
82
|
+
* - It takes effect at once: the grant, its pin and (Dock) the app's link to the hub go. The app
|
|
83
|
+
* 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).
|
|
89
|
+
* - A revoked grant never works again. To let the agent back, the person makes a new grant: a new
|
|
90
|
+
* MCP URL (Direct), or a new drop, with consent in the app and in the dock (Dock).
|
|
91
|
+
* - A hub that gets a `RevokedAnswer` marks that pairing revoked ("Revoked by <app host>"), stops
|
|
92
|
+
* relaying to it, drops what it kept from it (its view and the pin it took), and never pairs it
|
|
93
|
+
* again by itself.
|
|
94
|
+
*/
|
|
95
|
+
export declare const REVOKED_ERROR = "revoked";
|
|
96
|
+
export declare const REVOKED_HTTP_STATUS = 403;
|
|
97
|
+
export interface RevokedAnswer {
|
|
98
|
+
error: typeof REVOKED_ERROR;
|
|
99
|
+
/** The host of the app that revoked the grant, e.g. "pulse.example". */
|
|
100
|
+
revoked_by: string;
|
|
101
|
+
revoked_at: string;
|
|
102
|
+
message: string;
|
|
103
|
+
}
|
|
104
|
+
export declare function revokedAnswer(host: string, revokedAt: string): RevokedAnswer;
|
|
105
|
+
export declare function isRevokedAnswer(body: unknown): body is RevokedAnswer;
|
|
106
|
+
/**
|
|
107
|
+
* Mobile (APC 0.6). On a phone there is no dock window, browser extension or desktop pet to drag an
|
|
108
|
+
* agent from, so the agent in the app's own page is the only way the person can point it (give it
|
|
109
|
+
* focus). So, in a viewport that matches `APC_MOBILE_MEDIA`, on both profiles:
|
|
110
|
+
*
|
|
111
|
+
* - **The agents are shown in the page**, all of them, each with its mark visible and usable for
|
|
112
|
+
* pinning. This holds whatever the app's profiles, and whatever the agent bar does elsewhere: the
|
|
113
|
+
* Dock profile's rule that the bar may collapse to a small indicator does not apply, and the bar is
|
|
114
|
+
* never hidden or collapsed. It stays reachable while the page scrolls, without covering content
|
|
115
|
+
* the person needs.
|
|
116
|
+
* - **The person can pin with a finger, with no mouse drag.** Browsers on phones do not do HTML5
|
|
117
|
+
* drag and drop, so the app takes a touch alternative: touching the agent's mark and then a target
|
|
118
|
+
* ("tap to pin": a clear state, a way to cancel), or dragging the mark with a finger with pointer
|
|
119
|
+
* events. The result is the same pin (`FocusTarget`) as a mouse drag. Removing the pin (unpin) and
|
|
120
|
+
* Revoke work by touch too, with targets large enough for a finger.
|
|
121
|
+
*
|
|
122
|
+
* `APC_MOBILE_VIEWPORT` is the reference viewport the conformance suite and the apps are checked at.
|
|
123
|
+
*/
|
|
124
|
+
export declare const APC_MOBILE_MEDIA = "(pointer: coarse), (max-width: 640px)";
|
|
125
|
+
export declare const APC_MOBILE_VIEWPORT: {
|
|
126
|
+
readonly width: 375;
|
|
127
|
+
readonly height: 812;
|
|
128
|
+
};
|
|
129
|
+
/** The other path: Direct for Dock and Dock for Direct. */
|
|
130
|
+
export declare function oppositePath(path: ApcProfile): ApcProfile;
|
|
131
|
+
/**
|
|
132
|
+
* `apc_link` (APC 0.4): the same agent, connected to one app both ways (its own MCP URL, Direct, and
|
|
133
|
+
* through a dock's hub, Dock), shown as one agent in the app's agent bar.
|
|
134
|
+
*
|
|
135
|
+
* - **The offer.** When a connection arrives (`initialize`) with the same MCP client as one already
|
|
136
|
+
* connected on the opposite path to the same app, the app puts `apc_link: { code, expires_in, note }`
|
|
137
|
+
* (`ApcLinkOffer`) on a result of that new connection: a top-level key of the structured result and
|
|
138
|
+
* of the text, like `attention`. On the Dock path it rides on a result of one of the app's own tools,
|
|
139
|
+
* not a core tool (the hub merges those into its own results). The app asks at most once per MCP
|
|
140
|
+
* session (from one `initialize` to the next): an agent that does not answer, or answers late, is not
|
|
141
|
+
* asked again in that session, and stays two entries.
|
|
142
|
+
* - **The answer.** The agent calls the tool `apc_link({ code })` (`APC_LINK_TOOL`) on its *other*
|
|
143
|
+
* connection to the same app: directly, or through the dock as `<app>__apc_link`. The app links the
|
|
144
|
+
* two grants and answers `ApcLinkDone`, or refuses with a tool error, `ApcLinkRefused`.
|
|
145
|
+
* - **The code** is random (at least 96 bits), works once, expires after `expires_in` seconds
|
|
146
|
+
* (`APC_LINK_TTL_S`), and is redeemed only by a different connection to the same app, on the opposite
|
|
147
|
+
* path, among those the app offered it to (the same MCP client, not already linked). Anything else is
|
|
148
|
+
* refused with a reason (`ApcLinkRefusal`). The app keeps only a hash of it.
|
|
149
|
+
* - **Linking grants nothing.** Each path keeps its own grant, trimming and pin; the link changes only
|
|
150
|
+
* what the agent bar shows: one agent with both paths. Revoke on a linked agent cuts both grants. The
|
|
151
|
+
* person can unlink from the bar, which removes the link and nothing else.
|
|
152
|
+
* - **Which client it is.** On the Direct path, `clientInfo.name` of the agent's own `initialize`. On
|
|
153
|
+
* the Dock path the hub initializes the app as itself, so it names the agent's own MCP client in
|
|
154
|
+
* `params._meta[APC_AGENT_CLIENT_META]` of that `initialize`.
|
|
155
|
+
*/
|
|
156
|
+
export declare const APC_LINK_TOOL = "apc_link";
|
|
157
|
+
/** The key an offer puts on a result. */
|
|
158
|
+
export declare const APC_LINK_KEY = "apc_link";
|
|
159
|
+
/** How long a link code lives, in seconds. */
|
|
160
|
+
export declare const APC_LINK_TTL_S = 120;
|
|
161
|
+
/** On the Dock path: the `_meta` key of the hub's `initialize` that names the agent's own MCP client. */
|
|
162
|
+
export declare const APC_AGENT_CLIENT_META = "apc.tineira.com/agent_client";
|
|
163
|
+
export interface ApcLinkOffer {
|
|
164
|
+
/** One-time; sent back with `apc_link({ code })` on the other connection. */
|
|
165
|
+
code: string;
|
|
166
|
+
/** Seconds left: `APC_LINK_TTL_S` when offered. */
|
|
167
|
+
expires_in: number;
|
|
168
|
+
/** What to do with it, for the agent. */
|
|
169
|
+
note: string;
|
|
170
|
+
}
|
|
171
|
+
export interface ApcLinkDone {
|
|
172
|
+
linked: true;
|
|
173
|
+
note: string;
|
|
174
|
+
}
|
|
175
|
+
export declare const LINK_REFUSED_ERROR = "link_refused";
|
|
176
|
+
/**
|
|
177
|
+
* Why a code was refused: `unknown_code` (no such code in this app), `expired`, `used` (it already
|
|
178
|
+
* linked), `same_connection` (sent back on the connection that got it), `same_path` (on another
|
|
179
|
+
* connection of the same path), `not_offered` (on a connection it was not offered to: another client,
|
|
180
|
+
* or one not connected when it was offered), `already_linked` (one of the two is linked already).
|
|
181
|
+
*/
|
|
182
|
+
export type ApcLinkRefusal = "unknown_code" | "expired" | "used" | "same_connection" | "same_path" | "not_offered" | "already_linked";
|
|
183
|
+
export interface ApcLinkRefused {
|
|
184
|
+
error: typeof LINK_REFUSED_ERROR;
|
|
185
|
+
reason: ApcLinkRefusal;
|
|
186
|
+
message: string;
|
|
187
|
+
}
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** The two tools every APC app exposes, on every grant. */
|
|
2
|
+
export const CORE_TOOLS = ["get_current_view", "wait_for_focus"];
|
|
3
|
+
/** The key an unread pin puts on every tool result (structured and in the text): `{ focus, note }`. */
|
|
4
|
+
export const ATTENTION_KEY = "attention";
|
|
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).
|
|
8
|
+
*/
|
|
9
|
+
export const THEN_KEY = "then";
|
|
10
|
+
/**
|
|
11
|
+
* Revoke from the app. Every agent in the app's agent bar, Direct or Dock, has Revoke, and it holds
|
|
12
|
+
* in the app alone:
|
|
13
|
+
*
|
|
14
|
+
* - It takes effect at once: the grant, its pin and (Dock) the app's link to the hub go. The app
|
|
15
|
+
* 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).
|
|
21
|
+
* - A revoked grant never works again. To let the agent back, the person makes a new grant: a new
|
|
22
|
+
* MCP URL (Direct), or a new drop, with consent in the app and in the dock (Dock).
|
|
23
|
+
* - A hub that gets a `RevokedAnswer` marks that pairing revoked ("Revoked by <app host>"), stops
|
|
24
|
+
* relaying to it, drops what it kept from it (its view and the pin it took), and never pairs it
|
|
25
|
+
* again by itself.
|
|
26
|
+
*/
|
|
27
|
+
export const REVOKED_ERROR = "revoked";
|
|
28
|
+
export const REVOKED_HTTP_STATUS = 403;
|
|
29
|
+
export function revokedAnswer(host, revokedAt) {
|
|
30
|
+
return {
|
|
31
|
+
error: REVOKED_ERROR,
|
|
32
|
+
revoked_by: host,
|
|
33
|
+
revoked_at: revokedAt,
|
|
34
|
+
message: `The person revoked this agent's access in the app at ${host}. It does not come back: to reconnect, the person makes a new grant there.`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function isRevokedAnswer(body) {
|
|
38
|
+
const b = body;
|
|
39
|
+
return !!b && typeof b === "object" && b.error === REVOKED_ERROR && typeof b.revoked_by === "string";
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Mobile (APC 0.6). On a phone there is no dock window, browser extension or desktop pet to drag an
|
|
43
|
+
* agent from, so the agent in the app's own page is the only way the person can point it (give it
|
|
44
|
+
* focus). So, in a viewport that matches `APC_MOBILE_MEDIA`, on both profiles:
|
|
45
|
+
*
|
|
46
|
+
* - **The agents are shown in the page**, all of them, each with its mark visible and usable for
|
|
47
|
+
* pinning. This holds whatever the app's profiles, and whatever the agent bar does elsewhere: the
|
|
48
|
+
* Dock profile's rule that the bar may collapse to a small indicator does not apply, and the bar is
|
|
49
|
+
* never hidden or collapsed. It stays reachable while the page scrolls, without covering content
|
|
50
|
+
* the person needs.
|
|
51
|
+
* - **The person can pin with a finger, with no mouse drag.** Browsers on phones do not do HTML5
|
|
52
|
+
* drag and drop, so the app takes a touch alternative: touching the agent's mark and then a target
|
|
53
|
+
* ("tap to pin": a clear state, a way to cancel), or dragging the mark with a finger with pointer
|
|
54
|
+
* events. The result is the same pin (`FocusTarget`) as a mouse drag. Removing the pin (unpin) and
|
|
55
|
+
* Revoke work by touch too, with targets large enough for a finger.
|
|
56
|
+
*
|
|
57
|
+
* `APC_MOBILE_VIEWPORT` is the reference viewport the conformance suite and the apps are checked at.
|
|
58
|
+
*/
|
|
59
|
+
export const APC_MOBILE_MEDIA = "(pointer: coarse), (max-width: 640px)";
|
|
60
|
+
export const APC_MOBILE_VIEWPORT = { width: 375, height: 812 };
|
|
61
|
+
/** The other path: Direct for Dock and Dock for Direct. */
|
|
62
|
+
export function oppositePath(path) {
|
|
63
|
+
return path === "direct" ? "dock" : "direct";
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `apc_link` (APC 0.4): the same agent, connected to one app both ways (its own MCP URL, Direct, and
|
|
67
|
+
* through a dock's hub, Dock), shown as one agent in the app's agent bar.
|
|
68
|
+
*
|
|
69
|
+
* - **The offer.** When a connection arrives (`initialize`) with the same MCP client as one already
|
|
70
|
+
* connected on the opposite path to the same app, the app puts `apc_link: { code, expires_in, note }`
|
|
71
|
+
* (`ApcLinkOffer`) on a result of that new connection: a top-level key of the structured result and
|
|
72
|
+
* of the text, like `attention`. On the Dock path it rides on a result of one of the app's own tools,
|
|
73
|
+
* not a core tool (the hub merges those into its own results). The app asks at most once per MCP
|
|
74
|
+
* session (from one `initialize` to the next): an agent that does not answer, or answers late, is not
|
|
75
|
+
* asked again in that session, and stays two entries.
|
|
76
|
+
* - **The answer.** The agent calls the tool `apc_link({ code })` (`APC_LINK_TOOL`) on its *other*
|
|
77
|
+
* connection to the same app: directly, or through the dock as `<app>__apc_link`. The app links the
|
|
78
|
+
* two grants and answers `ApcLinkDone`, or refuses with a tool error, `ApcLinkRefused`.
|
|
79
|
+
* - **The code** is random (at least 96 bits), works once, expires after `expires_in` seconds
|
|
80
|
+
* (`APC_LINK_TTL_S`), and is redeemed only by a different connection to the same app, on the opposite
|
|
81
|
+
* path, among those the app offered it to (the same MCP client, not already linked). Anything else is
|
|
82
|
+
* refused with a reason (`ApcLinkRefusal`). The app keeps only a hash of it.
|
|
83
|
+
* - **Linking grants nothing.** Each path keeps its own grant, trimming and pin; the link changes only
|
|
84
|
+
* what the agent bar shows: one agent with both paths. Revoke on a linked agent cuts both grants. The
|
|
85
|
+
* person can unlink from the bar, which removes the link and nothing else.
|
|
86
|
+
* - **Which client it is.** On the Direct path, `clientInfo.name` of the agent's own `initialize`. On
|
|
87
|
+
* the Dock path the hub initializes the app as itself, so it names the agent's own MCP client in
|
|
88
|
+
* `params._meta[APC_AGENT_CLIENT_META]` of that `initialize`.
|
|
89
|
+
*/
|
|
90
|
+
export const APC_LINK_TOOL = "apc_link";
|
|
91
|
+
/** The key an offer puts on a result. */
|
|
92
|
+
export const APC_LINK_KEY = "apc_link";
|
|
93
|
+
/** How long a link code lives, in seconds. */
|
|
94
|
+
export const APC_LINK_TTL_S = 120;
|
|
95
|
+
/** On the Dock path: the `_meta` key of the hub's `initialize` that names the agent's own MCP client. */
|
|
96
|
+
export const APC_AGENT_CLIENT_META = "apc.tineira.com/agent_client";
|
|
97
|
+
export const LINK_REFUSED_ERROR = "link_refused";
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `apc.json` (APC 0.5): what an origin says about itself. Every APC app, and every hub, serves it at
|
|
3
|
+
* `GET {origin}/.well-known/apc.json` (`APC_JSON_PATH`), from its own origin:
|
|
4
|
+
*
|
|
5
|
+
* ```json
|
|
6
|
+
* { "name": "Signals", "standard": "0.5.0", "origin": "https://signals.example",
|
|
7
|
+
* "profiles": ["direct", "dock"], "mcp": { "path": "/mcp/" },
|
|
8
|
+
* "direct": { "connect": "https://signals.example/?connect" } }
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* - **`name`**: what the app (or hub) calls itself.
|
|
12
|
+
* - **`standard`**: the version of the APC standard it implements (`APC_STANDARD_VERSION`).
|
|
13
|
+
* - **`origin`**: the origin it is served from, exactly. A file that names another origin is refused.
|
|
14
|
+
* - **`profiles`**: how agents reach it. An app lists `"direct"`, `"dock"` or both; a hub lists
|
|
15
|
+
* `"hub"` alone, so apps and tools can tell a dock from an app. Words a reader does not know are ignored.
|
|
16
|
+
* - **`mcp.path`**: where its MCP URLs live. Every grant's MCP URL on this origin starts with
|
|
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.
|
|
19
|
+
* - **`direct.connect`** (with `"direct"`): the page, on the same origin, where the person connects an
|
|
20
|
+
* agent directly: what a person is shown when they drop a dock agent on a Direct-only app.
|
|
21
|
+
* - **`dock`** (a hub): where it takes pairings, `pair` and `confirm`, as absolute URLs on its origin.
|
|
22
|
+
*
|
|
23
|
+
* A hub reads an app's `apc.json` when the app asks to pair (`POST {hub}/apc/pair`) and refuses the
|
|
24
|
+
* pairing when it is missing (`apc_json_missing`), answered with a redirect (`apc_json_redirected`), cannot be read as one (`apc_json_invalid`), names
|
|
25
|
+
* another origin (`apc_json_wrong_origin`), does not list the Dock profile (`dock_not_offered`), or
|
|
26
|
+
* does not cover the MCP URL the app sent (`mcp_url_not_declared`); see `ApcJsonRefusal`.
|
|
27
|
+
*/
|
|
28
|
+
import type { ApcProfile } from "./core.js";
|
|
29
|
+
/** Where an origin serves its `apc.json`. */
|
|
30
|
+
export declare const APC_JSON_PATH = "/.well-known/apc.json";
|
|
31
|
+
/** The most bytes a reader takes of an `apc.json`. */
|
|
32
|
+
export declare const APC_JSON_MAX_BYTES = 16384;
|
|
33
|
+
/**
|
|
34
|
+
* Where an app's MCP URLs live when it says nothing else: `@apc/server`'s default `mcp.path`, and the
|
|
35
|
+
* path a hub holds a verified app to when it pairs it without a file (its file out of reach, no copy).
|
|
36
|
+
*/
|
|
37
|
+
export declare const APC_DEFAULT_MCP_PATH = "/mcp/";
|
|
38
|
+
/** A word of `profiles`: an app's profiles, or `"hub"` for a dock's hub. */
|
|
39
|
+
export type ApcRole = ApcProfile | "hub";
|
|
40
|
+
export declare const APC_ROLES: readonly ApcRole[];
|
|
41
|
+
export interface ApcManifest {
|
|
42
|
+
name: string;
|
|
43
|
+
/** The version of the APC standard it implements, e.g. "0.5.0". */
|
|
44
|
+
standard: string;
|
|
45
|
+
/** The origin it is served from. */
|
|
46
|
+
origin: string;
|
|
47
|
+
/** "direct", "dock" or both for an app; "hub" alone for a hub. */
|
|
48
|
+
profiles: ApcRole[];
|
|
49
|
+
/** Every grant's MCP URL on this origin starts with origin + path. */
|
|
50
|
+
mcp: {
|
|
51
|
+
path: string;
|
|
52
|
+
};
|
|
53
|
+
/** With "direct": where the person connects an agent directly, on the same origin. */
|
|
54
|
+
direct?: {
|
|
55
|
+
connect: string;
|
|
56
|
+
};
|
|
57
|
+
/** A hub's pairing endpoints, on its origin. */
|
|
58
|
+
dock?: {
|
|
59
|
+
pair: string;
|
|
60
|
+
confirm: string;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Why a hub refuses a pairing over the app's `apc.json` (APC 0.5). */
|
|
64
|
+
export type ApcJsonRefusal = "apc_json_missing" | "apc_json_redirected" | "apc_json_unreachable" | "apc_json_invalid" | "apc_json_wrong_origin" | "dock_not_offered" | "mcp_url_not_declared";
|
|
65
|
+
/**
|
|
66
|
+
* An absolute http(s) URL on `origin`, with no credentials, as its href; null otherwise. The scheme is
|
|
67
|
+
* checked as well as the origin: `blob:https://app.example/…` has that origin too, and is no page of it.
|
|
68
|
+
*/
|
|
69
|
+
export declare function urlOnOrigin(value: unknown, origin: string): string | null;
|
|
70
|
+
/** Compares two versions of the standard: negative, zero or positive. */
|
|
71
|
+
export declare function compareStandard(a: string, b: string): number;
|
|
72
|
+
/**
|
|
73
|
+
* An `apc.json` as read from `origin`, checked: the manifest, or why it is not one. `wrong_origin` when
|
|
74
|
+
* it names another origin than the one it was read from; `invalid` for anything else that is wrong
|
|
75
|
+
* (a missing or malformed field, no profile this reader knows, a hub listing an app's profile too, a
|
|
76
|
+
* Direct app with no connect page, a URL off its origin).
|
|
77
|
+
*/
|
|
78
|
+
export declare function parseApcManifest(raw: unknown, origin: string): {
|
|
79
|
+
ok: true;
|
|
80
|
+
manifest: ApcManifest;
|
|
81
|
+
} | {
|
|
82
|
+
ok: false;
|
|
83
|
+
error: "apc_json_invalid" | "apc_json_wrong_origin";
|
|
84
|
+
detail: string;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Whether an MCP URL lies under what the manifest declares: an http(s) URL on its origin, with no
|
|
88
|
+
* credentials, under `mcp.path`. The URL parser removes literal `.` and `..` segments, but not encoded
|
|
89
|
+
* ones, which a server may decode afterwards: so the rest of the path after `mcp.path` may hold no
|
|
90
|
+
* encoded slash, backslash or dot (`%2f`, `%5c`, `%2e`), no encoded percent (`%25`, a second round of
|
|
91
|
+
* encoding), and no backslash.
|
|
92
|
+
*/
|
|
93
|
+
export declare function declaresMcpUrl(manifest: Pick<ApcManifest, "origin" | "mcp">, mcpUrl: string): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* What an app tells a person who drops an agent from their dock on it when it connects agents only
|
|
96
|
+
* 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
|
|
98
|
+
* domain first and the name only as what the page says it is.
|
|
99
|
+
*/
|
|
100
|
+
export declare function directOnlyNote(name: string): string;
|
|
101
|
+
/**
|
|
102
|
+
* What an app tells a person who drops a dock agent on it when its page could not read its own
|
|
103
|
+
* `apc.json`: it cannot tell whether it takes dock agents, so it pairs nothing and says so.
|
|
104
|
+
*/
|
|
105
|
+
export declare function couldNotCheckNote(name: string): string;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Where an origin serves its `apc.json`. */
|
|
2
|
+
export const APC_JSON_PATH = "/.well-known/apc.json";
|
|
3
|
+
/** The most bytes a reader takes of an `apc.json`. */
|
|
4
|
+
export const APC_JSON_MAX_BYTES = 16_384;
|
|
5
|
+
/**
|
|
6
|
+
* Where an app's MCP URLs live when it says nothing else: `@apc/server`'s default `mcp.path`, and the
|
|
7
|
+
* path a hub holds a verified app to when it pairs it without a file (its file out of reach, no copy).
|
|
8
|
+
*/
|
|
9
|
+
export const APC_DEFAULT_MCP_PATH = "/mcp/";
|
|
10
|
+
export const APC_ROLES = ["direct", "dock", "hub"];
|
|
11
|
+
const VERSION_RE = /^(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,5})$/;
|
|
12
|
+
const PATH_RE = /^\/([A-Za-z0-9._~!$&'()*+,;=:@-]+\/)*$/;
|
|
13
|
+
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
14
|
+
/**
|
|
15
|
+
* An absolute http(s) URL on `origin`, with no credentials, as its href; null otherwise. The scheme is
|
|
16
|
+
* checked as well as the origin: `blob:https://app.example/…` has that origin too, and is no page of it.
|
|
17
|
+
*/
|
|
18
|
+
export function urlOnOrigin(value, origin) {
|
|
19
|
+
if (typeof value !== "string" || value.length > 2048)
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
return (url.protocol === "https:" || url.protocol === "http:") && url.origin === origin && !url.username && !url.password ? url.href : null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const onOrigin = urlOnOrigin;
|
|
30
|
+
/** Compares two versions of the standard: negative, zero or positive. */
|
|
31
|
+
export function compareStandard(a, b) {
|
|
32
|
+
const pa = a.split(".").map(Number);
|
|
33
|
+
const pb = b.split(".").map(Number);
|
|
34
|
+
for (let i = 0; i < 3; i++)
|
|
35
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
36
|
+
return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* An `apc.json` as read from `origin`, checked: the manifest, or why it is not one. `wrong_origin` when
|
|
41
|
+
* it names another origin than the one it was read from; `invalid` for anything else that is wrong
|
|
42
|
+
* (a missing or malformed field, no profile this reader knows, a hub listing an app's profile too, a
|
|
43
|
+
* Direct app with no connect page, a URL off its origin).
|
|
44
|
+
*/
|
|
45
|
+
export function parseApcManifest(raw, origin) {
|
|
46
|
+
const bad = (detail) => ({ ok: false, error: "apc_json_invalid", detail });
|
|
47
|
+
if (!isObject(raw))
|
|
48
|
+
return bad("not a JSON object");
|
|
49
|
+
const name = typeof raw.name === "string" ? raw.name.replace(/[\p{Cc}\p{Cf}]/gu, "").trim() : "";
|
|
50
|
+
if (!name || name.length > 60)
|
|
51
|
+
return bad("name is not a short string");
|
|
52
|
+
if (typeof raw.standard !== "string" || !VERSION_RE.test(raw.standard))
|
|
53
|
+
return bad("standard is not a version like 0.5.0");
|
|
54
|
+
if (typeof raw.origin !== "string")
|
|
55
|
+
return bad("origin is missing");
|
|
56
|
+
let declared;
|
|
57
|
+
try {
|
|
58
|
+
declared = new URL(raw.origin).origin;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return bad("origin is not an origin");
|
|
62
|
+
}
|
|
63
|
+
if (declared !== raw.origin)
|
|
64
|
+
return bad("origin is not an origin (scheme, host and port, no path or trailing slash)");
|
|
65
|
+
if (declared !== origin)
|
|
66
|
+
return { ok: false, error: "apc_json_wrong_origin", detail: `it names ${declared}, it was read from ${origin}` };
|
|
67
|
+
if (!Array.isArray(raw.profiles) || raw.profiles.length > 8)
|
|
68
|
+
return bad("profiles is not a short list");
|
|
69
|
+
const profiles = APC_ROLES.filter((r) => raw.profiles.includes(r));
|
|
70
|
+
if (!profiles.length)
|
|
71
|
+
return bad("profiles lists none of direct, dock, hub");
|
|
72
|
+
if (profiles.includes("hub") && profiles.length > 1)
|
|
73
|
+
return bad("a hub lists only hub");
|
|
74
|
+
const mcp = raw.mcp;
|
|
75
|
+
if (!isObject(mcp) || typeof mcp.path !== "string" || mcp.path.length > 200 || !PATH_RE.test(mcp.path) || mcp.path.split("/").some((s) => s === "." || s === ".."))
|
|
76
|
+
return bad("mcp.path is not a path that starts and ends with /");
|
|
77
|
+
const manifest = { name, standard: raw.standard, origin, profiles, mcp: { path: mcp.path } };
|
|
78
|
+
if (profiles.includes("direct")) {
|
|
79
|
+
const connect = isObject(raw.direct) ? onOrigin(raw.direct.connect, origin) : null;
|
|
80
|
+
if (!connect)
|
|
81
|
+
return bad("direct.connect is not a URL on its origin");
|
|
82
|
+
manifest.direct = { connect };
|
|
83
|
+
}
|
|
84
|
+
if (profiles.includes("hub")) {
|
|
85
|
+
const dock = isObject(raw.dock) ? raw.dock : {};
|
|
86
|
+
const pair = onOrigin(dock.pair, origin);
|
|
87
|
+
const confirm = onOrigin(dock.confirm, origin);
|
|
88
|
+
if (!pair || !confirm)
|
|
89
|
+
return bad("dock.pair and dock.confirm are not URLs on its origin");
|
|
90
|
+
manifest.dock = { pair, confirm };
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, manifest };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether an MCP URL lies under what the manifest declares: an http(s) URL on its origin, with no
|
|
96
|
+
* credentials, under `mcp.path`. The URL parser removes literal `.` and `..` segments, but not encoded
|
|
97
|
+
* ones, which a server may decode afterwards: so the rest of the path after `mcp.path` may hold no
|
|
98
|
+
* encoded slash, backslash or dot (`%2f`, `%5c`, `%2e`), no encoded percent (`%25`, a second round of
|
|
99
|
+
* encoding), and no backslash.
|
|
100
|
+
*/
|
|
101
|
+
export function declaresMcpUrl(manifest, mcpUrl) {
|
|
102
|
+
let url;
|
|
103
|
+
try {
|
|
104
|
+
url = new URL(mcpUrl);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (!urlOnOrigin(url.href, manifest.origin))
|
|
110
|
+
return false;
|
|
111
|
+
if (!url.pathname.startsWith(manifest.mcp.path) || url.pathname.length <= manifest.mcp.path.length)
|
|
112
|
+
return false;
|
|
113
|
+
const rest = url.pathname.slice(manifest.mcp.path.length);
|
|
114
|
+
// Checked on the URL as sent too: the parser turns some encoded dots into segments it then removes.
|
|
115
|
+
const sent = mcpUrl.split(/[?#]/, 1)[0];
|
|
116
|
+
const encoded = /%(2f|5c|2e|25)/i;
|
|
117
|
+
return !encoded.test(rest) && !encoded.test(sent) && !rest.includes("\\") && !rest.split("/").some((s) => s === "." || s === "..");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* What an app tells a person who drops an agent from their dock on it when it connects agents only
|
|
121
|
+
* 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
|
|
123
|
+
* domain first and the name only as what the page says it is.
|
|
124
|
+
*/
|
|
125
|
+
export function directOnlyNote(name) {
|
|
126
|
+
return `${name} connects agents directly, not through a dock. Connect your agent to ${name} on its own URL, then drag its mark from the agent bar there.`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* What an app tells a person who drops a dock agent on it when its page could not read its own
|
|
130
|
+
* `apc.json`: it cannot tell whether it takes dock agents, so it pairs nothing and says so.
|
|
131
|
+
*/
|
|
132
|
+
export function couldNotCheckNote(name) {
|
|
133
|
+
return `${name} could not check how it connects agents just now, so your agent was not paired. Drop it again in a moment, or connect your agent to ${name} directly on its own URL.`;
|
|
134
|
+
}
|
package/dist/target.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FocusParams, FocusTarget, KeyValue } from "./types.js";
|
|
2
|
+
/** The part grammar of the specification (the PFA 0114 check). */
|
|
3
|
+
export declare const PART_RE: RegExp;
|
|
4
|
+
export declare const PARAMS_MAX_BYTES = 1024;
|
|
5
|
+
/** Keys only: string tokens or short lists of them; no numbers, no free text, at most 1 KB. */
|
|
6
|
+
export declare function parseParams(raw: unknown): FocusParams | null;
|
|
7
|
+
/** A valid target, or null. A target names a row (id) or a computation (params), or both. */
|
|
8
|
+
export declare function parseTarget(raw: {
|
|
9
|
+
kind?: unknown;
|
|
10
|
+
id?: unknown;
|
|
11
|
+
part?: unknown;
|
|
12
|
+
params?: unknown;
|
|
13
|
+
}): FocusTarget | null;
|
|
14
|
+
/** One canonical string per target: param order does not matter. */
|
|
15
|
+
export declare function canonicalTarget(target: FocusTarget): string;
|
|
16
|
+
/** The three DOM attributes a pointable element carries. */
|
|
17
|
+
export interface FocusAttrs {
|
|
18
|
+
"data-focus": string;
|
|
19
|
+
"data-focus-part"?: string;
|
|
20
|
+
"data-focus-params"?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function focusAttrs(target: {
|
|
23
|
+
kind: string;
|
|
24
|
+
id?: string | null;
|
|
25
|
+
part?: string | null;
|
|
26
|
+
params?: Record<string, KeyValue> | null;
|
|
27
|
+
}): FocusAttrs;
|
|
28
|
+
export declare function parseFocusAttrs(focus: string | null, part: string | null, params: string | null): FocusTarget | null;
|
package/dist/target.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** The part grammar of the specification (the PFA 0114 check). */
|
|
2
|
+
export const PART_RE = /^[a-z][a-z0-9_]{0,31}(:[A-Za-z0-9_.|:-]{1,80})?$/;
|
|
3
|
+
const KIND_RE = /^[a-z][a-z0-9_]{0,31}$/;
|
|
4
|
+
const ID_RE = /^[A-Za-z0-9_.:-]{1,80}$/;
|
|
5
|
+
const PARAM_KEY_RE = /^[a-z][a-z0-9_]{0,31}$/;
|
|
6
|
+
const TOKEN_RE = /^[A-Za-z0-9_.:|-]{1,80}$/;
|
|
7
|
+
/** A bare number is an amount, never a key. Months ("2026-03") and ids pass. */
|
|
8
|
+
const NUMBER_RE = /^[-+]?\d+(\.\d+)?$/;
|
|
9
|
+
export const PARAMS_MAX_BYTES = 1024;
|
|
10
|
+
function isToken(value) {
|
|
11
|
+
return typeof value === "string" && TOKEN_RE.test(value) && !NUMBER_RE.test(value);
|
|
12
|
+
}
|
|
13
|
+
/** Keys only: string tokens or short lists of them; no numbers, no free text, at most 1 KB. */
|
|
14
|
+
export function parseParams(raw) {
|
|
15
|
+
if (raw === null || raw === undefined)
|
|
16
|
+
return null;
|
|
17
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
18
|
+
return null;
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
21
|
+
if (!PARAM_KEY_RE.test(key))
|
|
22
|
+
return null;
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
if (value.length === 0 || value.length > 20 || !value.every(isToken))
|
|
25
|
+
return null;
|
|
26
|
+
out[key] = [...value];
|
|
27
|
+
}
|
|
28
|
+
else if (isToken(value)) {
|
|
29
|
+
out[key] = value;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (new TextEncoder().encode(JSON.stringify(out)).length > PARAMS_MAX_BYTES)
|
|
36
|
+
return null;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** A valid target, or null. A target names a row (id) or a computation (params), or both. */
|
|
40
|
+
export function parseTarget(raw) {
|
|
41
|
+
const kind = raw.kind;
|
|
42
|
+
if (typeof kind !== "string" || !KIND_RE.test(kind))
|
|
43
|
+
return null;
|
|
44
|
+
const id = raw.id === null || raw.id === undefined || raw.id === "" ? null : raw.id;
|
|
45
|
+
if (id !== null && (typeof id !== "string" || !ID_RE.test(id)))
|
|
46
|
+
return null;
|
|
47
|
+
const part = raw.part === null || raw.part === undefined || raw.part === "" ? null : raw.part;
|
|
48
|
+
if (part !== null && (typeof part !== "string" || !PART_RE.test(part)))
|
|
49
|
+
return null;
|
|
50
|
+
const params = parseParams(raw.params);
|
|
51
|
+
if (raw.params !== null && raw.params !== undefined && params === null)
|
|
52
|
+
return null;
|
|
53
|
+
if (id === null && params === null)
|
|
54
|
+
return null;
|
|
55
|
+
return { kind, id: id, part: part, params };
|
|
56
|
+
}
|
|
57
|
+
/** One canonical string per target: param order does not matter. */
|
|
58
|
+
export function canonicalTarget(target) {
|
|
59
|
+
const params = target.params
|
|
60
|
+
? Object.fromEntries(Object.entries(target.params).sort(([a], [b]) => a.localeCompare(b)))
|
|
61
|
+
: null;
|
|
62
|
+
return JSON.stringify([target.kind, target.id, target.part, params]);
|
|
63
|
+
}
|
|
64
|
+
export function focusAttrs(target) {
|
|
65
|
+
const attrs = { "data-focus": `${target.kind}:${target.id ?? ""}` };
|
|
66
|
+
if (target.part)
|
|
67
|
+
attrs["data-focus-part"] = target.part;
|
|
68
|
+
if (target.params)
|
|
69
|
+
attrs["data-focus-params"] = JSON.stringify(target.params);
|
|
70
|
+
return attrs;
|
|
71
|
+
}
|
|
72
|
+
export function parseFocusAttrs(focus, part, params) {
|
|
73
|
+
if (!focus)
|
|
74
|
+
return null;
|
|
75
|
+
const colon = focus.indexOf(":");
|
|
76
|
+
if (colon < 1)
|
|
77
|
+
return null;
|
|
78
|
+
let parsedParams = null;
|
|
79
|
+
if (params) {
|
|
80
|
+
try {
|
|
81
|
+
parsedParams = JSON.parse(params);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return parseTarget({ kind: focus.slice(0, colon), id: focus.slice(colon + 1), part, params: parsedParams });
|
|
88
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types of the three APC channels, as in the companion specification
|
|
3
|
+
* (04-payload-specification.md, https://doi.org/10.5281/zenodo.23383101).
|
|
4
|
+
* Only identifiers and keys travel: never text, amounts or pixels.
|
|
5
|
+
*/
|
|
6
|
+
/** A key-only value: an id, a month, an enum word, or a list of those. */
|
|
7
|
+
export type KeyValue = string | string[];
|
|
8
|
+
export type FocusParams = Record<string, KeyValue>;
|
|
9
|
+
/** What the person pointed at: a row, a part of a row, or a part of a computed figure. */
|
|
10
|
+
export interface FocusTarget {
|
|
11
|
+
kind: string;
|
|
12
|
+
/** The row id; null for a figure, which is named by its params. */
|
|
13
|
+
id: string | null;
|
|
14
|
+
part: string | null;
|
|
15
|
+
params: FocusParams | null;
|
|
16
|
+
}
|
|
17
|
+
/** The stored pin, one per grant. */
|
|
18
|
+
export interface FocusRow extends FocusTarget {
|
|
19
|
+
grant_id: string;
|
|
20
|
+
pinned_at: string;
|
|
21
|
+
seen_at: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** What the agent receives: the target plus the tool and arguments that read it. */
|
|
24
|
+
export interface FocusPayload extends FocusTarget {
|
|
25
|
+
app_url: string;
|
|
26
|
+
pinned_at: string;
|
|
27
|
+
next: string;
|
|
28
|
+
next_args: Record<string, unknown>;
|
|
29
|
+
part_note?: string;
|
|
30
|
+
trimmed?: {
|
|
31
|
+
hidden_entities: number;
|
|
32
|
+
} | null;
|
|
33
|
+
gone?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** Carried on every tool result while the pin is unread. */
|
|
36
|
+
export interface Attention {
|
|
37
|
+
focus: FocusPayload;
|
|
38
|
+
note: string;
|
|
39
|
+
}
|
|
40
|
+
/** The person's screen as identifiers, trimmed to the calling grant. */
|
|
41
|
+
export interface ViewPayload {
|
|
42
|
+
screen: string;
|
|
43
|
+
params: FocusParams;
|
|
44
|
+
selection: {
|
|
45
|
+
kind: string;
|
|
46
|
+
id: string;
|
|
47
|
+
} | null;
|
|
48
|
+
app_url: string;
|
|
49
|
+
updated_at: string;
|
|
50
|
+
age_seconds: number;
|
|
51
|
+
stale: boolean;
|
|
52
|
+
next: string | null;
|
|
53
|
+
}
|
|
54
|
+
/** One message on the presence channel, after each successful tool call. */
|
|
55
|
+
export interface PresenceEvent {
|
|
56
|
+
v: 1;
|
|
57
|
+
grant_id: string;
|
|
58
|
+
at: string;
|
|
59
|
+
tool: string;
|
|
60
|
+
kind: "read" | "write";
|
|
61
|
+
targets: Array<{
|
|
62
|
+
kind: string;
|
|
63
|
+
id: string;
|
|
64
|
+
}>;
|
|
65
|
+
count: number;
|
|
66
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version of the APC standard: the contract an app and an agent share
|
|
3
|
+
* (view, pin, presence, the focus format, `wait_for_focus`, the tools, the
|
|
4
|
+
* drag payload, pairing). It is semver and separate from the software release
|
|
5
|
+
* (the repo's tags v0.1.0 and v0.2.0): the software can ship a dozen times
|
|
6
|
+
* without the contract changing. It stays 0.x while the standard is a draft
|
|
7
|
+
* and becomes 1.0.0 when the standard is published. Every change to the
|
|
8
|
+
* contract adds an entry to `packages/protocol/CHANGELOG.md` and bumps this.
|
|
9
|
+
*/
|
|
10
|
+
export declare const APC_STANDARD_VERSION = "0.7.0";
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version of the APC standard: the contract an app and an agent share
|
|
3
|
+
* (view, pin, presence, the focus format, `wait_for_focus`, the tools, the
|
|
4
|
+
* drag payload, pairing). It is semver and separate from the software release
|
|
5
|
+
* (the repo's tags v0.1.0 and v0.2.0): the software can ship a dozen times
|
|
6
|
+
* without the contract changing. It stays 0.x while the standard is a draft
|
|
7
|
+
* and becomes 1.0.0 when the standard is published. Every change to the
|
|
8
|
+
* contract adds an entry to `packages/protocol/CHANGELOG.md` and bumps this.
|
|
9
|
+
*/
|
|
10
|
+
export const APC_STANDARD_VERSION = "0.7.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tineira/apc-protocol",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.7.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"apc",
|
|
7
|
+
"agent-pointed-context",
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"ai-agents",
|
|
11
|
+
"protocol"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://apc.tineira.com/docs",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/tineira/agent-pointed-context/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/tineira/agent-pointed-context",
|
|
20
|
+
"directory": "packages/protocol"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"CHANGELOG.md"
|
|
35
|
+
],
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {},
|
|
44
|
+
"types": "./dist/index.d.ts"
|
|
6
45
|
}
|