@tineira/apc-conformance 0.7.1 → 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/dist/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * `apc-conformance`: the HTTP conformance checks (`http.ts`) against a live app, from a terminal.
4
4
  *
5
5
  * npx @tineira/apc-conformance@next https://pulse.example --grant https://pulse.example/mcp/<sid>/<secret>
6
+ * APC_TOKEN=… npx @tineira/apc-conformance@next https://app.example --grant https://app.example/api/mcp --bearer-env APC_TOKEN
6
7
  *
7
8
  * PASS, FAIL or SKIP per check; exit 1 on any FAIL, 2 on a usage error; `--json` for the report as JSON.
8
9
  */
@@ -12,12 +13,17 @@ import { HttpConformanceUsageError, redactSecrets, runHttpConformance } from "./
12
13
  const HELP = `apc-conformance: checks a live APC app over HTTP (https://apc.tineira.com/docs)
13
14
 
14
15
  Usage:
15
- apc-conformance <app-origin> [--grant <mcp-url>] [--revoked <mcp-url>] [--json] [--allow-http] [--timeout <s>]
16
+ apc-conformance <app-origin> [--grant <mcp-url>] [--bearer-env <VAR>] [--revoked <mcp-url>] [--revoked-bearer-env <VAR>]
17
+ [--json] [--allow-http] [--timeout <s>]
16
18
 
17
19
  <app-origin> The app's origin, e.g. https://pulse.apc.tineira.com
18
20
  --grant <mcp-url> The MCP URL of a live agent connected directly (Direct profile). Runs the MCP checks:
19
21
  initialize, tools/list, get_current_view, wait_for_focus, unknown grant 404.
22
+ --bearer-env <VAR> The name of an environment variable that holds the grant's bearer token, for an app whose
23
+ agents authenticate with OAuth (one MCP URL shared by every grant, APC 0.8). The token is
24
+ sent as Authorization: Bearer and never printed. Never put a token on the command line.
20
25
  --revoked <mcp-url> The MCP URL of a grant you revoked in the app: checks the revoked answer.
26
+ --revoked-bearer-env <VAR> The same, for the revoked grant's token.
21
27
  --json Print the report as JSON.
22
28
  --allow-http Allow http:, for a dev server on the host localhost, 127.0.0.1 or [::1] only (not *.localhost).
23
29
  --timeout <s> Seconds per request (default 15).
@@ -65,6 +71,8 @@ async function main(argv) {
65
71
  options: {
66
72
  grant: { type: "string" },
67
73
  revoked: { type: "string" },
74
+ "bearer-env": { type: "string" },
75
+ "revoked-bearer-env": { type: "string" },
68
76
  json: { type: "boolean", default: false },
69
77
  "allow-http": { type: "boolean", default: false },
70
78
  timeout: { type: "string" },
@@ -100,12 +108,27 @@ async function main(argv) {
100
108
  }
101
109
  timeoutMs = Math.round(s * 1000);
102
110
  }
111
+ // A token is read from the environment by the variable's name, never from the command line, and never printed.
112
+ const tokens = {};
113
+ for (const [flag, key] of [["bearer-env", "bearer"], ["revoked-bearer-env", "revokedBearer"]]) {
114
+ const name = values[flag];
115
+ if (name === undefined)
116
+ continue;
117
+ const token = process.env[name];
118
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !token) {
119
+ // The value is never echoed: a token passed here by mistake can look like a variable name.
120
+ process.stderr.write(`--${flag}: give the name of an environment variable that holds the token; the one given ${/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? "is unset or empty" : "is not a variable name"} (not shown)\n`);
121
+ return 2;
122
+ }
123
+ tokens[key] = token;
124
+ }
103
125
  let report;
104
126
  try {
105
127
  report = await runHttpConformance({
106
128
  origin: positionals[0],
107
129
  ...(values.grant !== undefined ? { grant: values.grant } : {}),
108
130
  ...(values.revoked !== undefined ? { revoked: values.revoked } : {}),
131
+ ...tokens,
109
132
  allowHttp: values["allow-http"],
110
133
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
111
134
  });
package/dist/http.d.ts CHANGED
@@ -3,8 +3,15 @@ export interface HttpConformanceOptions {
3
3
  origin: string;
4
4
  /** The MCP URL of a live grant (an agent connected directly). Without it the MCP checks are skipped. */
5
5
  grant?: string;
6
+ /**
7
+ * The grant's bearer token, when the URL alone does not carry the grant (APC 0.8: OAuth, one MCP URL shared
8
+ * by every grant). Sent as `Authorization: Bearer …`, never shown in the report.
9
+ */
10
+ bearer?: string;
6
11
  /** The MCP URL of a grant the person revoked in the app. Without it the revoked-answer check is skipped. */
7
12
  revoked?: string;
13
+ /** The revoked grant's bearer token, as `bearer` (APC 0.8). */
14
+ revokedBearer?: string;
8
15
  /** Lets `http:` through, on a loopback host only (localhost, 127.0.0.1, [::1]): a dev server. */
9
16
  allowHttp?: boolean;
10
17
  /** Per request, in ms (default 15000). */
package/dist/http.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * no credentials in URLs, every grant URL on the app's own origin, redirects never followed (a 3xx is a
11
11
  * failure), a byte cap on every body and a timeout on every request.
12
12
  */
13
- import { APC_JSON_MAX_BYTES, APC_JSON_PATH, APC_STANDARD_VERSION, CORE_TOOLS, REVOKED_HTTP_STATUS, declaresMcpUrl, isRevokedAnswer, parseApcManifest, } from "@tineira/apc-protocol";
13
+ import { APC_JSON_MAX_BYTES, APC_JSON_PATH, APC_STANDARD_VERSION, CORE_TOOLS, REVOKED_HTTP_STATUS, REVOKED_HTTP_STATUS_BEARER, declaresMcpUrl, isRevokedAnswer, parseApcManifest, } from "@tineira/apc-protocol";
14
14
  import { ConformanceError, ISO, attentionOf, checkCoreToolList, checkFocusShape, checkView, data, expect, isObject, resultKey, rpcBody, standardSupport } from "./shared.js";
15
15
  /**
16
16
  * The checks of the suite that need the person's page (an adapter in process), not reachable over HTTP:
@@ -30,6 +30,8 @@ export const IN_PROCESS_ONLY = [
30
30
  { name: "apc_link: the offer, the code and its refusals, revoke and unlink of a linked agent", why: "needs Dock connections through a hub and the agent bar (link adapter)" },
31
31
  ];
32
32
  const DEFAULT_TIMEOUT_MS = 15_000;
33
+ /** A revoked bearer grant's challenge (APC 0.8). */
34
+ const INVALID_TOKEN = /^bearer\b.*error="?invalid_token"?/i;
33
35
  /** The most bytes read of an MCP reply. */
34
36
  const MCP_MAX_BYTES = 1_048_576;
35
37
  const PROTOCOL_VERSION = "2025-06-18";
@@ -155,6 +157,14 @@ export function httpConformance(options) {
155
157
  function build(options) {
156
158
  const { origin, grant, revoked } = checkOptions(options);
157
159
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
160
+ for (const [token, what] of [[options.bearer, "the bearer token"], [options.revokedBearer, "the revoked grant's bearer token"]]) {
161
+ if (token !== undefined && (!token || /[\s,"]/.test(token) || token.length > 8192))
162
+ throw new HttpConformanceUsageError(`${what} is empty or not a token (not shown)`);
163
+ }
164
+ /** The credential headers of the live grant and of the revoked one (APC 0.8): the two may share one URL, so each check says which it sends. */
165
+ const bearer = (token) => (token ? { authorization: `Bearer ${token}` } : {});
166
+ const liveAuth = bearer(options.bearer);
167
+ const revokedAuth = bearer(options.revokedBearer);
158
168
  const doFetch = options.fetch ?? ((request) => fetch(request));
159
169
  let manifest = null;
160
170
  let manifestRaw = undefined;
@@ -173,7 +183,7 @@ function build(options) {
173
183
  await response.body?.cancel().catch(() => { });
174
184
  throw new ConformanceError(`${init.method ?? "GET"} ${shown(url)}: answered with a redirect (HTTP ${response.status}); APC never follows one`);
175
185
  }
176
- return { status: response.status, contentType: response.headers.get("content-type") ?? "", text: await readCapped(response, maxBytes, url) };
186
+ return { status: response.status, contentType: response.headers.get("content-type") ?? "", wwwAuthenticate: response.headers.get("www-authenticate") ?? "", text: await readCapped(response, maxBytes, url) };
177
187
  }
178
188
  async function readCapped(response, maxBytes, url) {
179
189
  const declared = Number(response.headers.get("content-length") ?? NaN);
@@ -205,18 +215,19 @@ function build(options) {
205
215
  }
206
216
  return new TextDecoder().decode(all);
207
217
  }
208
- /** A grant URL is never shown whole: its secret is the grant. */
218
+ /** A grant URL is never shown whole: its last segment may be the secret, even when a bearer token is given too. */
209
219
  function shown(url) {
210
220
  return url === `${origin}${APC_JSON_PATH}` ? url : redactGrantUrl(url);
211
221
  }
212
- async function rpc(url, method, params, abortMs) {
222
+ /** A JSON-RPC call; with the live grant's credential unless `headers` names another (the revoked grant's). */
223
+ async function rpc(url, method, params, abortMs, headers = liveAuth) {
213
224
  const started = Date.now();
214
225
  const r = await get(url.href, {
215
226
  method: "POST",
216
- headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": PROTOCOL_VERSION },
227
+ headers: { ...headers, "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": PROTOCOL_VERSION },
217
228
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
218
229
  }, MCP_MAX_BYTES, abortMs);
219
- return { status: r.status, body: rpcBody(r.contentType, r.text), ms: Date.now() - started };
230
+ return { status: r.status, body: rpcBody(r.contentType, r.text), ms: Date.now() - started, wwwAuthenticate: r.wwwAuthenticate };
220
231
  }
221
232
  async function result(url, method, params = {}, abortMs) {
222
233
  const { status, body, ms } = await rpc(url, method, params, abortMs);
@@ -295,7 +306,9 @@ function build(options) {
295
306
  run: withGrant(async (g) => {
296
307
  if (!manifest)
297
308
  return "apc.json did not parse";
298
- expect(declaresMcpUrl(manifest, g.href), `the grant's MCP URL ${redactGrantUrl(g.href)} is not under ${origin}${manifest.mcp.path} (mcp.path)`);
309
+ // With a bearer token the grants may share one URL, served at mcp.path itself (APC 0.8).
310
+ const shared = !!options.bearer;
311
+ expect(declaresMcpUrl(manifest, g.href, { shared }), `the grant's MCP URL ${shown(g.href)} is not under ${origin}${manifest.mcp.path} (mcp.path)${shared ? ", nor that path itself" : ""}`);
299
312
  }),
300
313
  },
301
314
  {
@@ -370,6 +383,8 @@ function build(options) {
370
383
  id: "grant-unknown-404",
371
384
  name: "an unknown grant answers HTTP 404, told apart from a revoked one (403)",
372
385
  run: withGrant(async (g) => {
386
+ if (options.bearer)
387
+ return "with a bearer token the grants share the URL: an unknown token is the auth's own 401, not a URL to try";
373
388
  const unknown = new URL(g.href);
374
389
  const parts = unknown.pathname.split("/");
375
390
  const last = parts.findLastIndex((p) => p.length > 0);
@@ -382,23 +397,27 @@ function build(options) {
382
397
  },
383
398
  {
384
399
  id: "revoked-answers",
385
- name: `a revoked grant answers HTTP ${REVOKED_HTTP_STATUS} { error: "revoked", revoked_by, revoked_at } to every method, POST, GET and DELETE`,
400
+ name: `a revoked grant answers { error: "revoked", revoked_by, revoked_at } to every method, POST, GET and DELETE: HTTP ${REVOKED_HTTP_STATUS} with a secret URL, ${REVOKED_HTTP_STATUS_BEARER} with a bearer token`,
386
401
  async run() {
387
402
  if (!revoked)
388
403
  return "give --revoked <mcp-url>: the MCP URL of a grant you revoked in the app";
404
+ const bearer = !!options.revokedBearer;
405
+ const expected = bearer ? REVOKED_HTTP_STATUS_BEARER : REVOKED_HTTP_STATUS;
389
406
  if (manifest)
390
- expect(declaresMcpUrl(manifest, revoked.href), `the revoked grant's MCP URL is not under ${origin}${manifest.mcp.path} (mcp.path)`);
407
+ expect(declaresMcpUrl(manifest, revoked.href, { shared: bearer }), `the revoked grant's MCP URL is not under ${origin}${manifest.mcp.path} (mcp.path)`);
391
408
  for (const [method, params] of [
392
409
  ["initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "apc-conformance", version: APC_STANDARD_VERSION } }],
393
410
  ["tools/list", {}],
394
411
  ["tools/call", { name: "get_current_view", arguments: {} }],
395
412
  ]) {
396
- const { status, body } = await rpc(revoked, method, params);
397
- expect(status === REVOKED_HTTP_STATUS, `${method} on the revoked grant: HTTP ${status}, expected ${REVOKED_HTTP_STATUS}`);
413
+ const { status, body, wwwAuthenticate } = await rpc(revoked, method, params, undefined, revokedAuth);
414
+ expect(status === expected, `${method} on the revoked grant: HTTP ${status}, expected ${expected}`);
415
+ if (bearer)
416
+ expect(INVALID_TOKEN.test(wwwAuthenticate), `${method} on the revoked grant: no WWW-Authenticate: Bearer error="invalid_token"`);
398
417
  expect(isRevokedAnswer(body) && body.revoked_by.length > 0 && ISO.test(body.revoked_at), `${method} on the revoked grant answers ${JSON.stringify(body).slice(0, 200)}, not { error: "revoked", revoked_by, revoked_at }`);
399
418
  }
400
419
  for (const method of ["GET", "DELETE"]) {
401
- const r = await get(revoked.href, { method, headers: { accept: "application/json, text/event-stream", "mcp-protocol-version": PROTOCOL_VERSION } }, MCP_MAX_BYTES);
420
+ const r = await get(revoked.href, { method, headers: { ...revokedAuth, accept: "application/json, text/event-stream", "mcp-protocol-version": PROTOCOL_VERSION } }, MCP_MAX_BYTES);
402
421
  let body = null;
403
422
  try {
404
423
  body = JSON.parse(r.text);
@@ -406,14 +425,27 @@ function build(options) {
406
425
  catch {
407
426
  // not JSON
408
427
  }
409
- expect(r.status === REVOKED_HTTP_STATUS, `${method} on the revoked grant: HTTP ${r.status}, expected ${REVOKED_HTTP_STATUS}`);
428
+ expect(r.status === expected, `${method} on the revoked grant: HTTP ${r.status}, expected ${expected}`);
429
+ if (bearer)
430
+ expect(INVALID_TOKEN.test(r.wwwAuthenticate), `${method} on the revoked grant: no WWW-Authenticate: Bearer error="invalid_token"`);
410
431
  expect(isRevokedAnswer(body), `${method} on the revoked grant answers ${r.text.slice(0, 200)}, not { error: "revoked", ... }`);
411
432
  }
412
433
  },
413
434
  },
414
435
  ];
415
- // Whatever a check says (a quoted body, an echoed URL, a skip reason, a note), never a grant's secret.
416
- const hide = (s) => redactSecrets(s, [grant, revoked]);
436
+ // Whatever a check says (a quoted body, an echoed URL, a skip reason, a note), never a grant's secret or token.
437
+ const tokens = [options.bearer, options.revokedBearer].filter((t) => !!t);
438
+ const hide = (s) => {
439
+ let out = redactSecrets(s, [grant, revoked]);
440
+ // Messages cut a quoted body at 200 characters, so a token may appear cut short: any run of 8 or more of
441
+ // its first characters goes too, and anything sent as a Bearer credential.
442
+ for (const t of tokens) {
443
+ out = out.split(t).join("<token>");
444
+ if (t.length >= 8)
445
+ out = out.replace(new RegExp(`${escapeRegExp(t.slice(0, 8))}[^\\s"'<]*`, "g"), "<token>");
446
+ }
447
+ return out.replace(/(bearer\s+)[^\s"'<]+/gi, "$1<token>");
448
+ };
417
449
  const safe = checks.map((c) => ({
418
450
  ...c,
419
451
  async run() {
package/dist/index.d.ts CHANGED
@@ -21,10 +21,16 @@
21
21
  import { type ApcProfile, type FocusTarget, type PresenceEvent } from "@tineira/apc-protocol";
22
22
  export { ConformanceError } from "./shared.js";
23
23
  export { HttpConformanceUsageError, IN_PROCESS_ONLY, httpConformance, redactGrantUrl, redactSecrets, runHttpConformance, type HttpCheck, type HttpCheckResult, type HttpConformanceOptions, type HttpConformanceReport, } from "./http.js";
24
- /** One agent's way into the app: the MCP URL its holder calls (the agent itself, or a dock's hub) and the app's id for the grant. */
24
+ /**
25
+ * One agent's way into the app: the MCP URL its holder calls (the agent itself, or a dock's hub), the
26
+ * app's id for the grant, and (APC 0.8) the headers that carry the grant when the URL alone does not: an
27
+ * app whose grants share one MCP URL gives `{ authorization: "Bearer …" }`. With a secret URL, leave
28
+ * `headers` out.
29
+ */
25
30
  export interface AgentConnection {
26
31
  mcpUrl: string;
27
32
  grantId: string;
33
+ headers?: Record<string, string>;
28
34
  }
29
35
  /**
30
36
  * What the `apc_link` checks need (APC 0.4), from an app that offers both profiles. The suite makes the
package/dist/index.js CHANGED
@@ -18,10 +18,12 @@
18
18
  * of the suite that HTTP reaches (apc.json, and with a grant's MCP URL the core tools' shapes and timing,
19
19
  * and a revoked grant's answer); the `apc-conformance` command (`cli.ts`) runs them from a terminal.
20
20
  */
21
- import { APC_AGENT_CLIENT_META, APC_JSON_MAX_BYTES, APC_JSON_PATH, APC_MOBILE_VIEWPORT, APC_STANDARD_VERSION, APC_LINK_KEY, APC_LINK_TOOL, APC_LINK_TTL_S, CORE_TOOLS, LINK_REFUSED_ERROR, REVOKED_ERROR, REVOKED_HTTP_STATUS, canonicalTarget, compareStandard, declaresMcpUrl, isRevokedAnswer, parseApcManifest, } from "@tineira/apc-protocol";
21
+ import { APC_AGENT_CLIENT_META, APC_JSON_MAX_BYTES, APC_JSON_PATH, APC_MOBILE_VIEWPORT, APC_STANDARD_VERSION, APC_LINK_KEY, APC_LINK_TOOL, APC_LINK_TTL_S, CORE_TOOLS, LINK_REFUSED_ERROR, REVOKED_ERROR, REVOKED_HTTP_STATUS, REVOKED_HTTP_STATUS_BEARER, WAIT_CLAIM_GAP_S, canonicalTarget, compareStandard, declaresMcpUrl, isRevokedAnswer, parseApcManifest, } from "@tineira/apc-protocol";
22
22
  import { ConformanceError, ISO, attentionOf, checkCoreToolList, checkFocusShape, checkView, data, expect, isObject, resultKey, rpcBody, standardSupport, text } from "./shared.js";
23
23
  export { ConformanceError } from "./shared.js";
24
24
  export { HttpConformanceUsageError, IN_PROCESS_ONLY, httpConformance, redactGrantUrl, redactSecrets, runHttpConformance, } from "./http.js";
25
+ /** A grant's credential travels in a header (a bearer token), not in its URL: several grants may share the URL (APC 0.8). */
26
+ const sharesUrl = (conn) => !!conn.headers && Object.keys(conn.headers).some((h) => h.toLowerCase() === "authorization");
25
27
  /** A check whose body may resolve to `{ note }`: the note lands on the check, and `run()` stays a test body. */
26
28
  function noted(name, body) {
27
29
  const check = {
@@ -36,27 +38,53 @@ function noted(name, body) {
36
38
  }
37
39
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
38
40
  /** One JSON-RPC request over Streamable HTTP, stateless, as a dock's hub sends it: the HTTP status and the reply. */
39
- async function send(app, url, method, params) {
40
- const request = new Request(url, {
41
+ async function send(app, to, method, params, signal) {
42
+ const request = new Request(to.mcpUrl, {
41
43
  method: "POST",
42
- headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" },
44
+ headers: { ...to.headers, "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" },
43
45
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
46
+ ...(signal ? { signal } : {}),
44
47
  });
45
48
  const response = await (app.fetch ? app.fetch(request) : fetch(request));
46
49
  const body = rpcBody(response.headers.get("content-type") ?? "", await response.text());
47
- return { status: response.status, body };
50
+ return { status: response.status, body, headers: response.headers };
48
51
  }
49
- async function result(app, url, method, params = {}) {
50
- const { status, body } = await send(app, url, method, params);
52
+ /** A JSON-RPC notification (no id) in a POST of its own: the HTTP status (202 for a Streamable HTTP server). */
53
+ async function notify(app, to, method, params) {
54
+ const request = new Request(to.mcpUrl, {
55
+ method: "POST",
56
+ headers: { ...to.headers, "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" },
57
+ body: JSON.stringify({ jsonrpc: "2.0", method, params }),
58
+ });
59
+ const response = await (app.fetch ? app.fetch(request) : fetch(request));
60
+ await response.body?.cancel().catch(() => { });
61
+ return response.status;
62
+ }
63
+ /** What a check of a 0.8 rule says of an app that claims an earlier standard: passed, not checked. */
64
+ const NOT_08 = { note: "not checked: the app's apc.json claims a standard before 0.8.0, which does not have this rule" };
65
+ async function result(app, to, method, params = {}, signal) {
66
+ const { status, body } = await send(app, to, method, params, signal);
51
67
  expect(status === 200, `${method}: HTTP ${status}, expected 200 (${JSON.stringify(body).slice(0, 200)})`);
52
68
  expect(isObject(body) && !("error" in body && body.error), `${method}: JSON-RPC error ${JSON.stringify(body).slice(0, 200)}`);
53
69
  return body.result;
54
70
  }
55
- async function call(app, url, tool, args = {}) {
56
- const r = await result(app, url, "tools/call", { name: tool, arguments: args });
71
+ async function call(app, to, tool, args = {}, signal) {
72
+ const r = await result(app, to, "tools/call", { name: tool, arguments: args }, signal);
57
73
  expect(isObject(r), `${tool}: no result`);
58
74
  return r;
59
75
  }
76
+ /**
77
+ * A revoked grant's answer (APC 0.8): a `RevokedAnswer` body, with the binding's status: 403 for a secret URL, 401 with
78
+ * `WWW-Authenticate: Bearer error="invalid_token"` for a bearer token.
79
+ */
80
+ function checkRevoked(conn, status, body, what, headers) {
81
+ const bearer = sharesUrl(conn);
82
+ const expected = bearer ? REVOKED_HTTP_STATUS_BEARER : REVOKED_HTTP_STATUS;
83
+ expect(status === expected, `${what} on a revoked grant: HTTP ${status}, expected ${expected}`);
84
+ if (bearer)
85
+ expect(/^bearer\b.*error="?invalid_token"?/i.test(headers.get("www-authenticate") ?? ""), `${what} on a revoked bearer grant: no WWW-Authenticate: Bearer error="invalid_token"`);
86
+ expect(isRevokedAnswer(body) && body.revoked_by.length > 0 && ISO.test(body.revoked_at), `${what} on a revoked grant answers { error: "revoked", revoked_by, revoked_at }: ${JSON.stringify(body).slice(0, 200)}`);
87
+ }
60
88
  /** A focus in the core's format, naming `target`. */
61
89
  function checkFocus(focus, target, where) {
62
90
  const parsed = checkFocusShape(focus, where);
@@ -72,10 +100,17 @@ async function timed(work) {
72
100
  export function apcConformance(app) {
73
101
  const [first, second] = app.targets;
74
102
  const check = (name, run) => noted(`${app.name} (${app.profile}): ${name}`, run);
103
+ /** Whether the app's apc.json claims `version` or later: a rule added in a version is checked only of apps that claim it. */
104
+ const claims = async (conn, version) => {
105
+ const request = new Request(`${new URL(conn.mcpUrl).origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
106
+ const response = await (app.fetch ? app.fetch(request) : fetch(request));
107
+ const standard = (await response.json().catch(() => ({}))).standard;
108
+ return typeof standard === "string" && compareStandard(standard, version) >= 0;
109
+ };
75
110
  return [
76
111
  check("serves apc.json from its own origin, naming the profiles it serves and where its MCP URLs live", async () => {
77
- const { mcpUrl } = await app.connect();
78
- const origin = new URL(mcpUrl).origin;
112
+ const conn = await app.connect();
113
+ const origin = new URL(conn.mcpUrl).origin;
79
114
  const request = new Request(`${origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
80
115
  const response = await (app.fetch ? app.fetch(request) : fetch(request));
81
116
  expect(response.status === 200, `GET ${APC_JSON_PATH}: HTTP ${response.status}, expected 200`);
@@ -98,21 +133,23 @@ export function apcConformance(app) {
98
133
  expect(m.profiles.includes(app.profile), `profiles ${JSON.stringify(m.profiles)} leave out ${app.profile}, which the app serves`);
99
134
  if (app.link)
100
135
  expect(m.profiles.includes("direct") && m.profiles.includes("dock"), `profiles ${JSON.stringify(m.profiles)}: an app that links both paths offers both`);
101
- expect(declaresMcpUrl(m, mcpUrl), `the grant's MCP URL is not under ${origin}${m.mcp.path} (mcp.path)`);
136
+ // A Direct app whose grants share one URL (a bearer token names the grant) may serve it at mcp.path itself (APC 0.8).
137
+ const shared = app.profile === "direct" && sharesUrl(conn);
138
+ expect(declaresMcpUrl(m, conn.mcpUrl, { shared }), `the grant's MCP URL is not under ${origin}${m.mcp.path} (mcp.path)${shared ? ", nor that path itself" : ""}`);
102
139
  if (m.profiles.includes("direct"))
103
140
  expect(m.direct && new URL(m.direct.connect).origin === origin, "direct.connect is the app's own page");
104
141
  if (support.note)
105
142
  return { note: support.note };
106
143
  }),
107
144
  check("lists the core tools, with their arguments", async () => {
108
- const { mcpUrl } = await app.connect();
109
- checkCoreToolList(await result(app, mcpUrl, "tools/list"), CORE_TOOLS);
145
+ const conn = await app.connect();
146
+ checkCoreToolList(await result(app, conn, "tools/list"), CORE_TOOLS);
110
147
  }),
111
148
  check("get_current_view returns the view as identifiers, and no focus before a pin", async () => {
112
- const { mcpUrl } = await app.connect();
149
+ const conn = await app.connect();
113
150
  await app.page.setOpen(true);
114
151
  await app.page.showView();
115
- const r = await call(app, mcpUrl, "get_current_view");
152
+ const r = await call(app, conn, "get_current_view");
116
153
  expect(!r.isError, "get_current_view failed");
117
154
  const d = data(r);
118
155
  expect("view" in d && "focus" in d, "the result has view and focus");
@@ -121,29 +158,31 @@ export function apcConformance(app) {
121
158
  expect(attentionOf(r) === undefined, "no pin, so no attention");
122
159
  }),
123
160
  check("a pin rides as attention on any result until it is read, and next reads it", async () => {
124
- const { mcpUrl, grantId } = await app.connect();
161
+ const conn = await app.connect();
162
+ const { grantId } = conn;
125
163
  expect(await app.page.pin(grantId, first), "the app refused a pin on the first target");
126
- const probed = await call(app, mcpUrl, app.probe.tool, app.probe.args);
164
+ const probed = await call(app, conn, app.probe.tool, app.probe.args);
127
165
  expect(!probed.isError, `${app.probe.tool} failed`);
128
166
  const attention = attentionOf(probed);
129
167
  expect(isObject(attention) && typeof attention.note === "string", "an unread pin puts attention { focus, note } on the result");
130
168
  checkFocus(attention.focus, first, "attention");
131
- const view = await call(app, mcpUrl, "get_current_view");
169
+ const view = await call(app, conn, "get_current_view");
132
170
  const focus = checkFocus(data(view).focus, first, "get_current_view");
133
- const after = await call(app, mcpUrl, app.probe.tool, app.probe.args);
171
+ const after = await call(app, conn, app.probe.tool, app.probe.args);
134
172
  expect(attentionOf(after) === undefined, "reading the focus marks it seen: no attention after");
135
- const read = await call(app, mcpUrl, focus.next, focus.next_args);
173
+ const read = await call(app, conn, focus.next, focus.next_args);
136
174
  expect(!read.isError, `next (${String(focus.next)}) with next_args failed: ${JSON.stringify(read.content).slice(0, 200)}`);
137
175
  // One pin per grant: a new drop replaces it, unread again.
138
176
  expect(await app.page.pin(grantId, second), "the app refused a pin on the second target");
139
- const again = attentionOf(await call(app, mcpUrl, app.probe.tool, app.probe.args));
177
+ const again = attentionOf(await call(app, conn, app.probe.tool, app.probe.args));
140
178
  expect(isObject(again), "a new pin is unread again");
141
179
  checkFocus(again.focus, second, "attention after a second pin");
142
180
  }),
143
181
  check("wait_for_focus returns a pin dropped during the wait", async () => {
144
- const { mcpUrl, grantId } = await app.connect();
182
+ const conn = await app.connect();
183
+ const { grantId } = conn;
145
184
  await app.page.setOpen(true);
146
- const waiting = timed(call(app, mcpUrl, "wait_for_focus", { timeout_s: 10 }));
185
+ const waiting = timed(call(app, conn, "wait_for_focus", { timeout_s: 10 }));
147
186
  await sleep(300);
148
187
  expect(await app.page.pin(grantId, second), "the app refused the pin");
149
188
  const { value: r, ms } = await waiting;
@@ -152,13 +191,13 @@ export function apcConformance(app) {
152
191
  expect(d.timed_out === false, "a wait that got a pin did not time out");
153
192
  checkFocus(d.focus, second, "wait_for_focus");
154
193
  expect(ms < 9000, `it returned at ${ms} ms, not soon after the pin`);
155
- const after = await call(app, mcpUrl, app.probe.tool, app.probe.args);
194
+ const after = await call(app, conn, app.probe.tool, app.probe.args);
156
195
  expect(attentionOf(after) === undefined, "the wait marks the pin seen");
157
196
  }),
158
197
  check("wait_for_focus times out cleanly", async () => {
159
- const { mcpUrl } = await app.connect();
198
+ const conn = await app.connect();
160
199
  await app.page.setOpen(true);
161
- const { value: r, ms } = await timed(call(app, mcpUrl, "wait_for_focus", { timeout_s: 1 }));
200
+ const { value: r, ms } = await timed(call(app, conn, "wait_for_focus", { timeout_s: 1 }));
162
201
  expect(!r.isError, "wait_for_focus failed");
163
202
  const d = data(r);
164
203
  expect(d.focus === null && d.timed_out === true, `a wait with no pin times out: ${JSON.stringify(d)}`);
@@ -168,9 +207,9 @@ export function apcConformance(app) {
168
207
  expect(text(r).then === undefined && d.then === undefined, "a wait that came back empty ends the loop: no then");
169
208
  }),
170
209
  check("wait_for_focus ends when the person has no page open", async () => {
171
- const { mcpUrl } = await app.connect();
210
+ const conn = await app.connect();
172
211
  await app.page.setOpen(false);
173
- const { value: r, ms } = await timed(call(app, mcpUrl, "wait_for_focus", { timeout_s: 3 }));
212
+ const { value: r, ms } = await timed(call(app, conn, "wait_for_focus", { timeout_s: 3 }));
174
213
  const d = data(r);
175
214
  expect(d.focus === null, "no pin, no focus");
176
215
  if (d.page_open === undefined)
@@ -181,25 +220,33 @@ export function apcConformance(app) {
181
220
  }
182
221
  await app.page.setOpen(true);
183
222
  }),
184
- check("then rides on results only while the person's page is open", async () => {
185
- const { mcpUrl } = await app.connect();
223
+ check("then rides on results only while the person's page is open and the agent was pinned lately", async () => {
224
+ const conn = await app.connect();
186
225
  await app.page.setOpen(true);
187
- const open = await call(app, mcpUrl, app.probe.tool, app.probe.args);
226
+ const v08 = await claims(conn, "0.8.0");
227
+ if (v08) {
228
+ const unpinned = await call(app, conn, app.probe.tool, app.probe.args);
229
+ expect(resultKey(unpinned, "then").value === undefined && text(unpinned).then === undefined, "an open page with no pin gives no then (APC 0.8): the agent does not wait after every answer");
230
+ }
231
+ expect(await app.page.pin(conn.grantId, first), "the app refused the pin");
232
+ expect(!(await call(app, conn, "get_current_view")).isError, "get_current_view failed");
233
+ const open = await call(app, conn, app.probe.tool, app.probe.args);
188
234
  // resultKey: the same value in the structured result and the text (a text-only model follows the text).
189
235
  const then = resultKey(open, "then");
190
236
  expect(typeof then.value === "string" && typeof text(open).then === "string", "with the page open, a result carries then, structured and in the text");
191
237
  await app.page.setOpen(false);
192
- const closed = await call(app, mcpUrl, app.probe.tool, app.probe.args);
238
+ const closed = await call(app, conn, app.probe.tool, app.probe.args);
193
239
  expect(resultKey(closed, "then").value === undefined && text(closed).then === undefined, "with no page open, no then");
194
240
  await app.page.setOpen(true);
195
241
  }),
196
242
  check("presence names what a read touched, with ids only", async () => {
197
- const { mcpUrl, grantId } = await app.connect();
243
+ const conn = await app.connect();
244
+ const { grantId } = conn;
198
245
  await app.page.setOpen(true);
199
246
  expect(await app.page.pin(grantId, first), "the app refused the pin");
200
- const focus = checkFocus(data(await call(app, mcpUrl, "get_current_view")).focus, first, "get_current_view");
247
+ const focus = checkFocus(data(await call(app, conn, "get_current_view")).focus, first, "get_current_view");
201
248
  app.page.takePresence();
202
- const read = await call(app, mcpUrl, focus.next, focus.next_args);
249
+ const read = await call(app, conn, focus.next, focus.next_args);
203
250
  expect(!read.isError, "next failed");
204
251
  const events = app.page.takePresence().filter((e) => e.grant_id === grantId && e.tool === focus.next);
205
252
  expect(events.length === 1, `one presence event for the read, got ${events.length}`);
@@ -218,13 +265,12 @@ export function apcConformance(app) {
218
265
  expect(await app.page.pin(a.grantId, first), "the app refused the pin");
219
266
  expect(await app.page.revoke(a.grantId), "the app refused to revoke");
220
267
  for (const [method, params] of [["initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "apc-conformance", version: "0" } }], ["tools/list", {}], ["tools/call", { name: "get_current_view", arguments: {} }], ["tools/call", { name: app.probe.tool, arguments: app.probe.args }]]) {
221
- const { status, body } = await send(app, a.mcpUrl, method, params);
222
- expect(status === REVOKED_HTTP_STATUS, `${method} on a revoked grant: HTTP ${status}, expected ${REVOKED_HTTP_STATUS}`);
223
- expect(isRevokedAnswer(body) && body.revoked_by.length > 0 && ISO.test(body.revoked_at), `${method} on a revoked grant answers { error: "revoked", revoked_by, revoked_at }: ${JSON.stringify(body).slice(0, 200)}`);
268
+ const { status, body, headers } = await send(app, a, method, params);
269
+ checkRevoked(a, status, body, method, headers);
224
270
  }
225
271
  // Whatever the HTTP method: a GET (the SSE stream) or a DELETE (ending a session) is answered "revoked" too, not 405.
226
272
  for (const method of ["GET", "DELETE"]) {
227
- const request = new Request(a.mcpUrl, { method, headers: { accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" } });
273
+ const request = new Request(a.mcpUrl, { method, headers: { ...a.headers, accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" } });
228
274
  const response = await (app.fetch ? app.fetch(request) : fetch(request));
229
275
  const text = await response.text();
230
276
  let body = null;
@@ -234,17 +280,17 @@ export function apcConformance(app) {
234
280
  catch {
235
281
  // not JSON
236
282
  }
237
- expect(response.status === REVOKED_HTTP_STATUS, `${method} on a revoked grant: HTTP ${response.status}, expected ${REVOKED_HTTP_STATUS}`);
238
- expect(isRevokedAnswer(body), `${method} on a revoked grant answers { error: "revoked", revoked_by, revoked_at }: ${text.slice(0, 200)}`);
283
+ checkRevoked(a, response.status, body, method, response.headers);
239
284
  }
240
285
  expect(!(await app.page.pin(a.grantId, second)), "a revoked agent cannot be pinned");
241
- const other = await call(app, b.mcpUrl, "get_current_view");
286
+ const other = await call(app, b, "get_current_view");
242
287
  expect(!other.isError && "view" in data(other), "another agent is not touched");
243
288
  }),
244
289
  check("a call already running when the grant is revoked ends revoked, with no data", async () => {
245
- const { mcpUrl, grantId } = await app.connect();
290
+ const conn = await app.connect();
291
+ const { grantId } = conn;
246
292
  await app.page.setOpen(true);
247
- const waiting = timed(call(app, mcpUrl, "wait_for_focus", { timeout_s: 10 }));
293
+ const waiting = timed(call(app, conn, "wait_for_focus", { timeout_s: 10 }));
248
294
  await sleep(300);
249
295
  expect(await app.page.revoke(grantId), "the app refused to revoke");
250
296
  const { value: r, ms } = await waiting;
@@ -254,6 +300,61 @@ export function apcConformance(app) {
254
300
  expect(!("focus" in d) && !("view" in d) && r.structuredContent === undefined, "it carries no focus, view or other data");
255
301
  expect(ms < 9000, `it ended at ${ms} ms, not soon after the revoke`);
256
302
  }),
303
+ check("one wait per grant: a newer wait takes over, and the older one delivers nothing (APC 0.8)", async () => {
304
+ const conn = await app.connect();
305
+ if (!(await claims(conn, "0.8.0")))
306
+ return NOT_08;
307
+ await app.page.setOpen(true);
308
+ const older = call(app, conn, "wait_for_focus", { timeout_s: 10 });
309
+ await sleep((WAIT_CLAIM_GAP_S + 0.3) * 1000);
310
+ const newer = timed(call(app, conn, "wait_for_focus", { timeout_s: 10 }));
311
+ const old = data(await older);
312
+ expect(old.focus === null && old.timed_out === false && typeof old.note === "string", `the wait a newer one took over ends with focus null, timed_out false and a note: ${JSON.stringify(old).slice(0, 200)}`);
313
+ expect(await app.page.pin(conn.grantId, first), "the app refused the pin");
314
+ const { value: got, ms } = await newer;
315
+ checkFocus(data(got).focus, first, "the newer wait");
316
+ expect(ms < 9000, `the newer wait returned at ${ms} ms, not soon after the pin`);
317
+ }),
318
+ check("a second wait started right after the first is refused with retry_after_s, and the first still delivers (APC 0.8)", async () => {
319
+ const conn = await app.connect();
320
+ if (!(await claims(conn, "0.8.0")))
321
+ return NOT_08;
322
+ await app.page.setOpen(true);
323
+ const running = call(app, conn, "wait_for_focus", { timeout_s: 10 });
324
+ await sleep(200);
325
+ const refused = data(await call(app, conn, "wait_for_focus", { timeout_s: 10 }));
326
+ expect(refused.focus === null && refused.timed_out === false, `the second wait is refused: ${JSON.stringify(refused).slice(0, 200)}`);
327
+ expect(typeof refused.retry_after_s === "number" && refused.retry_after_s >= 1 && refused.retry_after_s <= WAIT_CLAIM_GAP_S + 1, `with retry_after_s, 1 to ${WAIT_CLAIM_GAP_S + 1}: ${JSON.stringify(refused).slice(0, 200)}`);
328
+ expect(typeof refused.note === "string" && refused.note.length > 0, "and a note");
329
+ expect(await app.page.pin(conn.grantId, second), "the app refused the pin");
330
+ checkFocus(data(await running).focus, second, "the first wait, still running");
331
+ }),
332
+ ...["notifications/cancelled", "an HTTP abort"].map((how) => check(`a pin is never lost to a cancelled wait (${how}): it marks nothing seen and frees its place at once (APC 0.8)`, async () => {
333
+ const conn = await app.connect();
334
+ if (!(await claims(conn, "0.8.0")))
335
+ return NOT_08;
336
+ await app.page.setOpen(true);
337
+ const controller = new AbortController();
338
+ // Not awaited before the pin: a handler that ignores the cancel would still be waiting, and would take the pin.
339
+ const waiting = call(app, conn, "wait_for_focus", { timeout_s: 10 }, how === "an HTTP abort" ? controller.signal : undefined).catch(() => null);
340
+ await sleep(300);
341
+ if (how === "an HTTP abort")
342
+ controller.abort();
343
+ // MCP's cancel, in a POST of its own, as a client of a stateless server sends it.
344
+ else
345
+ expect((await notify(app, conn, "notifications/cancelled", { requestId: 1, reason: "apc-conformance" })) < 300, "notifications/cancelled was not accepted");
346
+ await sleep(300);
347
+ expect(await app.page.pin(conn.grantId, first), "the app refused the pin");
348
+ // Past a poll of a wait that ignored the cancel (2 s in @tineira/apc-server), so it would have taken the pin by now.
349
+ await sleep(2500);
350
+ const attention = attentionOf(await call(app, conn, app.probe.tool, app.probe.args));
351
+ expect(isObject(attention), `the pin dropped after the cancelled wait is still unread: it rides as attention (the cancelled wait took it)`);
352
+ checkFocus(attention.focus, first, "attention after a cancelled wait");
353
+ const again = data(await call(app, conn, "wait_for_focus", { timeout_s: 2 }));
354
+ expect(again.retry_after_s === undefined, `a cancelled wait frees its place at once: ${JSON.stringify(again).slice(0, 200)}`);
355
+ checkFocus(again.focus, first, "the next wait gets the pin");
356
+ await Promise.race([waiting, sleep(11_000)]);
357
+ })),
257
358
  ...mobileChecks(app, app.page.mobile),
258
359
  ...(app.link ? linkChecks(app, app.link) : []),
259
360
  ];
@@ -267,8 +368,8 @@ function mobileChecks(app, mobile) {
267
368
  check("the page adapter for a phone is given (required of an app that claims 0.6.0 or later)", async () => {
268
369
  if (mobile)
269
370
  return;
270
- const { mcpUrl } = await app.connect();
271
- const request = new Request(`${new URL(mcpUrl).origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
371
+ const conn = await app.connect();
372
+ const request = new Request(`${new URL(conn.mcpUrl).origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
272
373
  const response = await (app.fetch ? app.fetch(request) : fetch(request));
273
374
  const claimed = (await response.json().catch(() => ({}))).standard;
274
375
  expect(typeof claimed === "string" && compareStandard(claimed, "0.6.0") < 0, `apc.json claims standard ${String(claimed)}, which requires the mobile rule to be checked: give page.mobile in the adapter (ApcMobilePage: agentBar, pinByTouch, unpinByTouch)`);
@@ -287,18 +388,19 @@ function mobileChecks(app, mobile) {
287
388
  check("a person pins with a finger, no mouse drag, and the agent reads the pin; unpin works by touch too", async () => {
288
389
  if (!mobile)
289
390
  return;
290
- const { mcpUrl, grantId } = await app.connect();
391
+ const conn = await app.connect();
392
+ const { grantId } = conn;
291
393
  await app.page.setOpen(true);
292
394
  await app.page.showView();
293
395
  expect(await mobile.pinByTouch(grantId, first), "the page refused a pin made by tapping the agent and then the target");
294
- const r = await call(app, mcpUrl, "get_current_view");
396
+ const r = await call(app, conn, "get_current_view");
295
397
  expect(!r.isError, "get_current_view failed");
296
398
  checkFocus(data(r).focus, first, "get_current_view after a pin by touch");
297
399
  // The same again on another target: one pin per grant, replaced.
298
400
  expect(await mobile.pinByTouch(grantId, second), "the page refused a second pin by touch");
299
- checkFocus(data(await call(app, mcpUrl, "get_current_view")).focus, second, "get_current_view after a second pin by touch");
401
+ checkFocus(data(await call(app, conn, "get_current_view")).focus, second, "get_current_view after a second pin by touch");
300
402
  expect(await mobile.unpinByTouch(grantId), "the page offers no way to remove the pin with a finger");
301
- const after = await call(app, mcpUrl, "get_current_view");
403
+ const after = await call(app, conn, "get_current_view");
302
404
  expect(data(after).focus === null, "the pin is still there after the person removed it by touch");
303
405
  }),
304
406
  ];
@@ -308,7 +410,7 @@ async function arrive(app, conn, client) {
308
410
  const params = conn.path === "direct"
309
411
  ? { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: client, version: "1.0.0" } }
310
412
  : { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "apc-hub", title: `${client} via dock`, version: "0.1.0" }, _meta: { [APC_AGENT_CLIENT_META]: client } };
311
- await result(app, conn.mcpUrl, "initialize", params);
413
+ await result(app, conn, "initialize", params);
312
414
  }
313
415
  /** The offer on a result, where the model sees it: in the structured result and the same in the text. */
314
416
  function offerOf(r) {
@@ -335,13 +437,13 @@ function linkChecks(app, link) {
335
437
  await arrive(app, direct, name);
336
438
  const dock = await connect("dock");
337
439
  await arrive(app, dock, name);
338
- const offered = await call(app, dock.mcpUrl, app.probe.tool, app.probe.args);
440
+ const offered = await call(app, dock, app.probe.tool, app.probe.args);
339
441
  expect(!offered.isError, `${app.probe.tool} failed`);
340
442
  const offer = offerOf(offered);
341
443
  expect(offer, `the second connection of ${name}, on the other path, gets apc_link on a result of one of the app's own tools`);
342
444
  return { name, direct, dock, offer };
343
445
  };
344
- const redeem = (conn, code) => call(app, conn.mcpUrl, APC_LINK_TOOL, { code });
446
+ const redeem = (conn, code) => call(app, conn, APC_LINK_TOOL, { code });
345
447
  const refused = (r, reason, where) => {
346
448
  expect(r.isError === true, `${where}: refused, as a tool error (${JSON.stringify(r).slice(0, 200)})`);
347
449
  const d = text(r);
@@ -349,13 +451,13 @@ function linkChecks(app, link) {
349
451
  };
350
452
  const linkedEntry = async (a, b) => (await link.agents()).find((e) => e.grants.includes(a) && e.grants.includes(b));
351
453
  const works = async (conn, where) => {
352
- const r = await call(app, conn.mcpUrl, "get_current_view");
454
+ const r = await call(app, conn, "get_current_view");
353
455
  expect(!r.isError && "view" in data(r), `${where} still reads the app`);
354
456
  };
355
457
  return [
356
458
  check("the same client on the other path is offered a code once per session, and the code links them", async () => {
357
459
  const { direct, dock, offer } = await pair();
358
- const again = await call(app, dock.mcpUrl, app.probe.tool, app.probe.args);
460
+ const again = await call(app, dock, app.probe.tool, app.probe.args);
359
461
  expect(offerOf(again) === undefined, "asked at most once per MCP session");
360
462
  const done = await redeem(direct, offer.code);
361
463
  expect(!done.isError && data(done).linked === true, `the other connection redeems it: ${JSON.stringify(done).slice(0, 200)}`);
@@ -387,22 +489,22 @@ function linkChecks(app, link) {
387
489
  await sleep((APC_LINK_TTL_S + 1) * 1000);
388
490
  refused(await redeem(direct, offer.code), "expired", "after expires_in");
389
491
  expect(!(await linkedEntry(direct.grantId, dock.grantId)), "it stays two agents");
390
- expect(offerOf(await call(app, dock.mcpUrl, app.probe.tool, app.probe.args)) === undefined, "not asked again in that session");
492
+ expect(offerOf(await call(app, dock, app.probe.tool, app.probe.args)) === undefined, "not asked again in that session");
391
493
  }),
392
494
  check("a connection of another client is offered nothing", async () => {
393
495
  const direct = await connect("direct");
394
496
  await arrive(app, direct, client());
395
497
  const dock = await connect("dock");
396
498
  await arrive(app, dock, client());
397
- expect(offerOf(await call(app, dock.mcpUrl, app.probe.tool, app.probe.args)) === undefined, "no offer between two clients");
499
+ expect(offerOf(await call(app, dock, app.probe.tool, app.probe.args)) === undefined, "no offer between two clients");
398
500
  }),
399
501
  check("revoke on a linked agent cuts both grants", async () => {
400
502
  const { direct, dock, offer } = await pair();
401
503
  expect(!(await redeem(direct, offer.code)).isError, "linked");
402
504
  expect(await app.page.revoke(dock.grantId), "the app refused to revoke");
403
505
  for (const [conn, where] of [[direct, "Direct"], [dock, "Dock"]]) {
404
- const { status, body } = await send(app, conn.mcpUrl, "tools/call", { name: "get_current_view", arguments: {} });
405
- expect(status === REVOKED_HTTP_STATUS && isRevokedAnswer(body), `the ${where} grant answers 403 revoked: HTTP ${status}`);
506
+ const { status, body, headers } = await send(app, conn, "tools/call", { name: "get_current_view", arguments: {} });
507
+ checkRevoked(conn, status, body, `get_current_view on the ${where} grant`, headers);
406
508
  }
407
509
  }),
408
510
  check("unlink shows two agents again, each with its own grant", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tineira/apc-conformance",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Agent Pointed Context (APC) conformance: checks an app against the standard, in process with an adapter, or live over HTTP with the apc-conformance command.",
5
5
  "keywords": [
6
6
  "apc",
@@ -45,7 +45,7 @@
45
45
  "access": "public"
46
46
  },
47
47
  "dependencies": {
48
- "@tineira/apc-protocol": "^0.7.1"
48
+ "@tineira/apc-protocol": "^0.8.0"
49
49
  },
50
50
  "scripts": {},
51
51
  "types": "./dist/index.d.ts",