@tineira/apc-conformance 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +24 -1
- package/dist/http.d.ts +7 -0
- package/dist/http.js +54 -19
- package/dist/index.d.ts +11 -1
- package/dist/index.js +181 -64
- package/dist/shared.d.ts +13 -0
- package/dist/shared.js +21 -1
- package/package.json +2 -2
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>] [--
|
|
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,8 +10,8 @@
|
|
|
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,
|
|
14
|
-
import { ConformanceError, ISO, attentionOf, checkCoreToolList, checkFocusShape, checkView, data, expect, isObject, resultKey, rpcBody } from "./shared.js";
|
|
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
|
+
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:
|
|
17
17
|
* `apcConformance(adapter)` runs them in the app's own tests.
|
|
@@ -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
|
|
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
|
-
|
|
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);
|
|
@@ -267,12 +278,15 @@ function build(options) {
|
|
|
267
278
|
},
|
|
268
279
|
{
|
|
269
280
|
id: "apc-json-standard",
|
|
270
|
-
name: `its standard is one these checks know (0.5.0 to ${APC_STANDARD_VERSION})`,
|
|
281
|
+
name: `its standard is one these checks know (0.5.0 to ${APC_STANDARD_VERSION}, or a newer patch of it)`,
|
|
271
282
|
async run() {
|
|
272
283
|
if (!manifest)
|
|
273
284
|
return "apc.json did not parse";
|
|
274
|
-
|
|
275
|
-
|
|
285
|
+
const support = standardSupport(manifest.standard, APC_STANDARD_VERSION);
|
|
286
|
+
if (!support.ok)
|
|
287
|
+
throw new ConformanceError(support.reason);
|
|
288
|
+
if (support.note)
|
|
289
|
+
return { note: support.note };
|
|
276
290
|
},
|
|
277
291
|
},
|
|
278
292
|
{
|
|
@@ -292,7 +306,9 @@ function build(options) {
|
|
|
292
306
|
run: withGrant(async (g) => {
|
|
293
307
|
if (!manifest)
|
|
294
308
|
return "apc.json did not parse";
|
|
295
|
-
|
|
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" : ""}`);
|
|
296
312
|
}),
|
|
297
313
|
},
|
|
298
314
|
{
|
|
@@ -367,6 +383,8 @@ function build(options) {
|
|
|
367
383
|
id: "grant-unknown-404",
|
|
368
384
|
name: "an unknown grant answers HTTP 404, told apart from a revoked one (403)",
|
|
369
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";
|
|
370
388
|
const unknown = new URL(g.href);
|
|
371
389
|
const parts = unknown.pathname.split("/");
|
|
372
390
|
const last = parts.findLastIndex((p) => p.length > 0);
|
|
@@ -379,23 +397,27 @@ function build(options) {
|
|
|
379
397
|
},
|
|
380
398
|
{
|
|
381
399
|
id: "revoked-answers",
|
|
382
|
-
name: `a revoked grant answers
|
|
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`,
|
|
383
401
|
async run() {
|
|
384
402
|
if (!revoked)
|
|
385
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;
|
|
386
406
|
if (manifest)
|
|
387
|
-
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)`);
|
|
388
408
|
for (const [method, params] of [
|
|
389
409
|
["initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "apc-conformance", version: APC_STANDARD_VERSION } }],
|
|
390
410
|
["tools/list", {}],
|
|
391
411
|
["tools/call", { name: "get_current_view", arguments: {} }],
|
|
392
412
|
]) {
|
|
393
|
-
const { status, body } = await rpc(revoked, method, params);
|
|
394
|
-
expect(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"`);
|
|
395
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 }`);
|
|
396
418
|
}
|
|
397
419
|
for (const method of ["GET", "DELETE"]) {
|
|
398
|
-
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);
|
|
399
421
|
let body = null;
|
|
400
422
|
try {
|
|
401
423
|
body = JSON.parse(r.text);
|
|
@@ -403,14 +425,27 @@ function build(options) {
|
|
|
403
425
|
catch {
|
|
404
426
|
// not JSON
|
|
405
427
|
}
|
|
406
|
-
expect(r.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"`);
|
|
407
431
|
expect(isRevokedAnswer(body), `${method} on the revoked grant answers ${r.text.slice(0, 200)}, not { error: "revoked", ... }`);
|
|
408
432
|
}
|
|
409
433
|
},
|
|
410
434
|
},
|
|
411
435
|
];
|
|
412
|
-
// Whatever a check says (a quoted body, an echoed URL, a skip reason, a note), never a grant's secret.
|
|
413
|
-
const
|
|
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
|
+
};
|
|
414
449
|
const safe = checks.map((c) => ({
|
|
415
450
|
...c,
|
|
416
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
|
-
/**
|
|
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
|
|
@@ -99,7 +105,10 @@ export interface ApcAppAdapter {
|
|
|
99
105
|
}
|
|
100
106
|
export interface ConformanceCheck {
|
|
101
107
|
name: string;
|
|
108
|
+
/** Throws when the check fails. */
|
|
102
109
|
run(): Promise<void>;
|
|
110
|
+
/** Set by `run()` when the check passed with something worth saying (a newer patch of the standard, say). */
|
|
111
|
+
note?: string;
|
|
103
112
|
}
|
|
104
113
|
export interface ConformanceReport {
|
|
105
114
|
app: string;
|
|
@@ -110,6 +119,7 @@ export interface ConformanceReport {
|
|
|
110
119
|
name: string;
|
|
111
120
|
ok: boolean;
|
|
112
121
|
error?: string;
|
|
122
|
+
note?: string;
|
|
113
123
|
}>;
|
|
114
124
|
}
|
|
115
125
|
/** The checks of the core, in order. Each makes its own agents, so any one can run alone. */
|
package/dist/index.js
CHANGED
|
@@ -18,33 +18,73 @@
|
|
|
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";
|
|
22
|
-
import { ConformanceError, ISO, attentionOf, checkCoreToolList, checkFocusShape, checkView, data, expect, isObject, resultKey, rpcBody, text } from "./shared.js";
|
|
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
|
+
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");
|
|
27
|
+
/** A check whose body may resolve to `{ note }`: the note lands on the check, and `run()` stays a test body. */
|
|
28
|
+
function noted(name, body) {
|
|
29
|
+
const check = {
|
|
30
|
+
name,
|
|
31
|
+
async run() {
|
|
32
|
+
const out = await body();
|
|
33
|
+
if (out?.note)
|
|
34
|
+
check.note = out.note;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
return check;
|
|
38
|
+
}
|
|
25
39
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
40
|
/** One JSON-RPC request over Streamable HTTP, stateless, as a dock's hub sends it: the HTTP status and the reply. */
|
|
27
|
-
async function send(app,
|
|
28
|
-
const request = new Request(
|
|
41
|
+
async function send(app, to, method, params, signal) {
|
|
42
|
+
const request = new Request(to.mcpUrl, {
|
|
29
43
|
method: "POST",
|
|
30
|
-
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" },
|
|
31
45
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
46
|
+
...(signal ? { signal } : {}),
|
|
32
47
|
});
|
|
33
48
|
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
34
49
|
const body = rpcBody(response.headers.get("content-type") ?? "", await response.text());
|
|
35
|
-
return { status: response.status, body };
|
|
50
|
+
return { status: response.status, body, headers: response.headers };
|
|
51
|
+
}
|
|
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;
|
|
36
62
|
}
|
|
37
|
-
|
|
38
|
-
|
|
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);
|
|
39
67
|
expect(status === 200, `${method}: HTTP ${status}, expected 200 (${JSON.stringify(body).slice(0, 200)})`);
|
|
40
68
|
expect(isObject(body) && !("error" in body && body.error), `${method}: JSON-RPC error ${JSON.stringify(body).slice(0, 200)}`);
|
|
41
69
|
return body.result;
|
|
42
70
|
}
|
|
43
|
-
async function call(app,
|
|
44
|
-
const r = await result(app,
|
|
71
|
+
async function call(app, to, tool, args = {}, signal) {
|
|
72
|
+
const r = await result(app, to, "tools/call", { name: tool, arguments: args }, signal);
|
|
45
73
|
expect(isObject(r), `${tool}: no result`);
|
|
46
74
|
return r;
|
|
47
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
|
+
}
|
|
48
88
|
/** A focus in the core's format, naming `target`. */
|
|
49
89
|
function checkFocus(focus, target, where) {
|
|
50
90
|
const parsed = checkFocusShape(focus, where);
|
|
@@ -59,11 +99,18 @@ async function timed(work) {
|
|
|
59
99
|
/** The checks of the core, in order. Each makes its own agents, so any one can run alone. */
|
|
60
100
|
export function apcConformance(app) {
|
|
61
101
|
const [first, second] = app.targets;
|
|
62
|
-
const check = (name, run) => (
|
|
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
|
+
};
|
|
63
110
|
return [
|
|
64
111
|
check("serves apc.json from its own origin, naming the profiles it serves and where its MCP URLs live", async () => {
|
|
65
|
-
const
|
|
66
|
-
const origin = new URL(mcpUrl).origin;
|
|
112
|
+
const conn = await app.connect();
|
|
113
|
+
const origin = new URL(conn.mcpUrl).origin;
|
|
67
114
|
const request = new Request(`${origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
|
|
68
115
|
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
69
116
|
expect(response.status === 200, `GET ${APC_JSON_PATH}: HTTP ${response.status}, expected 200`);
|
|
@@ -80,24 +127,29 @@ export function apcConformance(app) {
|
|
|
80
127
|
const parsed = parseApcManifest(body, origin);
|
|
81
128
|
expect(parsed.ok, `${APC_JSON_PATH}: ${parsed.ok ? "" : `${parsed.error}, ${parsed.detail}`}`);
|
|
82
129
|
const m = parsed.manifest;
|
|
83
|
-
|
|
130
|
+
const support = standardSupport(m.standard, APC_STANDARD_VERSION);
|
|
131
|
+
expect(support.ok, support.ok ? "" : `${support.reason} (this suite checks 0.5.0 up to ${APC_STANDARD_VERSION}, and newer patches of it)`);
|
|
84
132
|
expect(!m.profiles.includes("hub"), "an app's profiles are direct, dock or both, never hub");
|
|
85
133
|
expect(m.profiles.includes(app.profile), `profiles ${JSON.stringify(m.profiles)} leave out ${app.profile}, which the app serves`);
|
|
86
134
|
if (app.link)
|
|
87
135
|
expect(m.profiles.includes("direct") && m.profiles.includes("dock"), `profiles ${JSON.stringify(m.profiles)}: an app that links both paths offers both`);
|
|
88
|
-
|
|
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" : ""}`);
|
|
89
139
|
if (m.profiles.includes("direct"))
|
|
90
140
|
expect(m.direct && new URL(m.direct.connect).origin === origin, "direct.connect is the app's own page");
|
|
141
|
+
if (support.note)
|
|
142
|
+
return { note: support.note };
|
|
91
143
|
}),
|
|
92
144
|
check("lists the core tools, with their arguments", async () => {
|
|
93
|
-
const
|
|
94
|
-
checkCoreToolList(await result(app,
|
|
145
|
+
const conn = await app.connect();
|
|
146
|
+
checkCoreToolList(await result(app, conn, "tools/list"), CORE_TOOLS);
|
|
95
147
|
}),
|
|
96
148
|
check("get_current_view returns the view as identifiers, and no focus before a pin", async () => {
|
|
97
|
-
const
|
|
149
|
+
const conn = await app.connect();
|
|
98
150
|
await app.page.setOpen(true);
|
|
99
151
|
await app.page.showView();
|
|
100
|
-
const r = await call(app,
|
|
152
|
+
const r = await call(app, conn, "get_current_view");
|
|
101
153
|
expect(!r.isError, "get_current_view failed");
|
|
102
154
|
const d = data(r);
|
|
103
155
|
expect("view" in d && "focus" in d, "the result has view and focus");
|
|
@@ -106,29 +158,31 @@ export function apcConformance(app) {
|
|
|
106
158
|
expect(attentionOf(r) === undefined, "no pin, so no attention");
|
|
107
159
|
}),
|
|
108
160
|
check("a pin rides as attention on any result until it is read, and next reads it", async () => {
|
|
109
|
-
const
|
|
161
|
+
const conn = await app.connect();
|
|
162
|
+
const { grantId } = conn;
|
|
110
163
|
expect(await app.page.pin(grantId, first), "the app refused a pin on the first target");
|
|
111
|
-
const probed = await call(app,
|
|
164
|
+
const probed = await call(app, conn, app.probe.tool, app.probe.args);
|
|
112
165
|
expect(!probed.isError, `${app.probe.tool} failed`);
|
|
113
166
|
const attention = attentionOf(probed);
|
|
114
167
|
expect(isObject(attention) && typeof attention.note === "string", "an unread pin puts attention { focus, note } on the result");
|
|
115
168
|
checkFocus(attention.focus, first, "attention");
|
|
116
|
-
const view = await call(app,
|
|
169
|
+
const view = await call(app, conn, "get_current_view");
|
|
117
170
|
const focus = checkFocus(data(view).focus, first, "get_current_view");
|
|
118
|
-
const after = await call(app,
|
|
171
|
+
const after = await call(app, conn, app.probe.tool, app.probe.args);
|
|
119
172
|
expect(attentionOf(after) === undefined, "reading the focus marks it seen: no attention after");
|
|
120
|
-
const read = await call(app,
|
|
173
|
+
const read = await call(app, conn, focus.next, focus.next_args);
|
|
121
174
|
expect(!read.isError, `next (${String(focus.next)}) with next_args failed: ${JSON.stringify(read.content).slice(0, 200)}`);
|
|
122
175
|
// One pin per grant: a new drop replaces it, unread again.
|
|
123
176
|
expect(await app.page.pin(grantId, second), "the app refused a pin on the second target");
|
|
124
|
-
const again = attentionOf(await call(app,
|
|
177
|
+
const again = attentionOf(await call(app, conn, app.probe.tool, app.probe.args));
|
|
125
178
|
expect(isObject(again), "a new pin is unread again");
|
|
126
179
|
checkFocus(again.focus, second, "attention after a second pin");
|
|
127
180
|
}),
|
|
128
181
|
check("wait_for_focus returns a pin dropped during the wait", async () => {
|
|
129
|
-
const
|
|
182
|
+
const conn = await app.connect();
|
|
183
|
+
const { grantId } = conn;
|
|
130
184
|
await app.page.setOpen(true);
|
|
131
|
-
const waiting = timed(call(app,
|
|
185
|
+
const waiting = timed(call(app, conn, "wait_for_focus", { timeout_s: 10 }));
|
|
132
186
|
await sleep(300);
|
|
133
187
|
expect(await app.page.pin(grantId, second), "the app refused the pin");
|
|
134
188
|
const { value: r, ms } = await waiting;
|
|
@@ -137,13 +191,13 @@ export function apcConformance(app) {
|
|
|
137
191
|
expect(d.timed_out === false, "a wait that got a pin did not time out");
|
|
138
192
|
checkFocus(d.focus, second, "wait_for_focus");
|
|
139
193
|
expect(ms < 9000, `it returned at ${ms} ms, not soon after the pin`);
|
|
140
|
-
const after = await call(app,
|
|
194
|
+
const after = await call(app, conn, app.probe.tool, app.probe.args);
|
|
141
195
|
expect(attentionOf(after) === undefined, "the wait marks the pin seen");
|
|
142
196
|
}),
|
|
143
197
|
check("wait_for_focus times out cleanly", async () => {
|
|
144
|
-
const
|
|
198
|
+
const conn = await app.connect();
|
|
145
199
|
await app.page.setOpen(true);
|
|
146
|
-
const { value: r, ms } = await timed(call(app,
|
|
200
|
+
const { value: r, ms } = await timed(call(app, conn, "wait_for_focus", { timeout_s: 1 }));
|
|
147
201
|
expect(!r.isError, "wait_for_focus failed");
|
|
148
202
|
const d = data(r);
|
|
149
203
|
expect(d.focus === null && d.timed_out === true, `a wait with no pin times out: ${JSON.stringify(d)}`);
|
|
@@ -153,9 +207,9 @@ export function apcConformance(app) {
|
|
|
153
207
|
expect(text(r).then === undefined && d.then === undefined, "a wait that came back empty ends the loop: no then");
|
|
154
208
|
}),
|
|
155
209
|
check("wait_for_focus ends when the person has no page open", async () => {
|
|
156
|
-
const
|
|
210
|
+
const conn = await app.connect();
|
|
157
211
|
await app.page.setOpen(false);
|
|
158
|
-
const { value: r, ms } = await timed(call(app,
|
|
212
|
+
const { value: r, ms } = await timed(call(app, conn, "wait_for_focus", { timeout_s: 3 }));
|
|
159
213
|
const d = data(r);
|
|
160
214
|
expect(d.focus === null, "no pin, no focus");
|
|
161
215
|
if (d.page_open === undefined)
|
|
@@ -166,25 +220,33 @@ export function apcConformance(app) {
|
|
|
166
220
|
}
|
|
167
221
|
await app.page.setOpen(true);
|
|
168
222
|
}),
|
|
169
|
-
check("then rides on results only while the person's page is open", async () => {
|
|
170
|
-
const
|
|
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();
|
|
171
225
|
await app.page.setOpen(true);
|
|
172
|
-
const
|
|
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);
|
|
173
234
|
// resultKey: the same value in the structured result and the text (a text-only model follows the text).
|
|
174
235
|
const then = resultKey(open, "then");
|
|
175
236
|
expect(typeof then.value === "string" && typeof text(open).then === "string", "with the page open, a result carries then, structured and in the text");
|
|
176
237
|
await app.page.setOpen(false);
|
|
177
|
-
const closed = await call(app,
|
|
238
|
+
const closed = await call(app, conn, app.probe.tool, app.probe.args);
|
|
178
239
|
expect(resultKey(closed, "then").value === undefined && text(closed).then === undefined, "with no page open, no then");
|
|
179
240
|
await app.page.setOpen(true);
|
|
180
241
|
}),
|
|
181
242
|
check("presence names what a read touched, with ids only", async () => {
|
|
182
|
-
const
|
|
243
|
+
const conn = await app.connect();
|
|
244
|
+
const { grantId } = conn;
|
|
183
245
|
await app.page.setOpen(true);
|
|
184
246
|
expect(await app.page.pin(grantId, first), "the app refused the pin");
|
|
185
|
-
const focus = checkFocus(data(await call(app,
|
|
247
|
+
const focus = checkFocus(data(await call(app, conn, "get_current_view")).focus, first, "get_current_view");
|
|
186
248
|
app.page.takePresence();
|
|
187
|
-
const read = await call(app,
|
|
249
|
+
const read = await call(app, conn, focus.next, focus.next_args);
|
|
188
250
|
expect(!read.isError, "next failed");
|
|
189
251
|
const events = app.page.takePresence().filter((e) => e.grant_id === grantId && e.tool === focus.next);
|
|
190
252
|
expect(events.length === 1, `one presence event for the read, got ${events.length}`);
|
|
@@ -203,13 +265,12 @@ export function apcConformance(app) {
|
|
|
203
265
|
expect(await app.page.pin(a.grantId, first), "the app refused the pin");
|
|
204
266
|
expect(await app.page.revoke(a.grantId), "the app refused to revoke");
|
|
205
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 }]]) {
|
|
206
|
-
const { status, body } = await send(app, a
|
|
207
|
-
|
|
208
|
-
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);
|
|
209
270
|
}
|
|
210
271
|
// Whatever the HTTP method: a GET (the SSE stream) or a DELETE (ending a session) is answered "revoked" too, not 405.
|
|
211
272
|
for (const method of ["GET", "DELETE"]) {
|
|
212
|
-
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" } });
|
|
213
274
|
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
214
275
|
const text = await response.text();
|
|
215
276
|
let body = null;
|
|
@@ -219,17 +280,17 @@ export function apcConformance(app) {
|
|
|
219
280
|
catch {
|
|
220
281
|
// not JSON
|
|
221
282
|
}
|
|
222
|
-
|
|
223
|
-
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);
|
|
224
284
|
}
|
|
225
285
|
expect(!(await app.page.pin(a.grantId, second)), "a revoked agent cannot be pinned");
|
|
226
|
-
const other = await call(app, b
|
|
286
|
+
const other = await call(app, b, "get_current_view");
|
|
227
287
|
expect(!other.isError && "view" in data(other), "another agent is not touched");
|
|
228
288
|
}),
|
|
229
289
|
check("a call already running when the grant is revoked ends revoked, with no data", async () => {
|
|
230
|
-
const
|
|
290
|
+
const conn = await app.connect();
|
|
291
|
+
const { grantId } = conn;
|
|
231
292
|
await app.page.setOpen(true);
|
|
232
|
-
const waiting = timed(call(app,
|
|
293
|
+
const waiting = timed(call(app, conn, "wait_for_focus", { timeout_s: 10 }));
|
|
233
294
|
await sleep(300);
|
|
234
295
|
expect(await app.page.revoke(grantId), "the app refused to revoke");
|
|
235
296
|
const { value: r, ms } = await waiting;
|
|
@@ -239,6 +300,61 @@ export function apcConformance(app) {
|
|
|
239
300
|
expect(!("focus" in d) && !("view" in d) && r.structuredContent === undefined, "it carries no focus, view or other data");
|
|
240
301
|
expect(ms < 9000, `it ended at ${ms} ms, not soon after the revoke`);
|
|
241
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
|
+
})),
|
|
242
358
|
...mobileChecks(app, app.page.mobile),
|
|
243
359
|
...(app.link ? linkChecks(app, app.link) : []),
|
|
244
360
|
];
|
|
@@ -252,8 +368,8 @@ function mobileChecks(app, mobile) {
|
|
|
252
368
|
check("the page adapter for a phone is given (required of an app that claims 0.6.0 or later)", async () => {
|
|
253
369
|
if (mobile)
|
|
254
370
|
return;
|
|
255
|
-
const
|
|
256
|
-
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" } });
|
|
257
373
|
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
258
374
|
const claimed = (await response.json().catch(() => ({}))).standard;
|
|
259
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)`);
|
|
@@ -272,18 +388,19 @@ function mobileChecks(app, mobile) {
|
|
|
272
388
|
check("a person pins with a finger, no mouse drag, and the agent reads the pin; unpin works by touch too", async () => {
|
|
273
389
|
if (!mobile)
|
|
274
390
|
return;
|
|
275
|
-
const
|
|
391
|
+
const conn = await app.connect();
|
|
392
|
+
const { grantId } = conn;
|
|
276
393
|
await app.page.setOpen(true);
|
|
277
394
|
await app.page.showView();
|
|
278
395
|
expect(await mobile.pinByTouch(grantId, first), "the page refused a pin made by tapping the agent and then the target");
|
|
279
|
-
const r = await call(app,
|
|
396
|
+
const r = await call(app, conn, "get_current_view");
|
|
280
397
|
expect(!r.isError, "get_current_view failed");
|
|
281
398
|
checkFocus(data(r).focus, first, "get_current_view after a pin by touch");
|
|
282
399
|
// The same again on another target: one pin per grant, replaced.
|
|
283
400
|
expect(await mobile.pinByTouch(grantId, second), "the page refused a second pin by touch");
|
|
284
|
-
checkFocus(data(await call(app,
|
|
401
|
+
checkFocus(data(await call(app, conn, "get_current_view")).focus, second, "get_current_view after a second pin by touch");
|
|
285
402
|
expect(await mobile.unpinByTouch(grantId), "the page offers no way to remove the pin with a finger");
|
|
286
|
-
const after = await call(app,
|
|
403
|
+
const after = await call(app, conn, "get_current_view");
|
|
287
404
|
expect(data(after).focus === null, "the pin is still there after the person removed it by touch");
|
|
288
405
|
}),
|
|
289
406
|
];
|
|
@@ -293,7 +410,7 @@ async function arrive(app, conn, client) {
|
|
|
293
410
|
const params = conn.path === "direct"
|
|
294
411
|
? { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: client, version: "1.0.0" } }
|
|
295
412
|
: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "apc-hub", title: `${client} via dock`, version: "0.1.0" }, _meta: { [APC_AGENT_CLIENT_META]: client } };
|
|
296
|
-
await result(app, conn
|
|
413
|
+
await result(app, conn, "initialize", params);
|
|
297
414
|
}
|
|
298
415
|
/** The offer on a result, where the model sees it: in the structured result and the same in the text. */
|
|
299
416
|
function offerOf(r) {
|
|
@@ -320,13 +437,13 @@ function linkChecks(app, link) {
|
|
|
320
437
|
await arrive(app, direct, name);
|
|
321
438
|
const dock = await connect("dock");
|
|
322
439
|
await arrive(app, dock, name);
|
|
323
|
-
const offered = await call(app, dock
|
|
440
|
+
const offered = await call(app, dock, app.probe.tool, app.probe.args);
|
|
324
441
|
expect(!offered.isError, `${app.probe.tool} failed`);
|
|
325
442
|
const offer = offerOf(offered);
|
|
326
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`);
|
|
327
444
|
return { name, direct, dock, offer };
|
|
328
445
|
};
|
|
329
|
-
const redeem = (conn, code) => call(app, conn
|
|
446
|
+
const redeem = (conn, code) => call(app, conn, APC_LINK_TOOL, { code });
|
|
330
447
|
const refused = (r, reason, where) => {
|
|
331
448
|
expect(r.isError === true, `${where}: refused, as a tool error (${JSON.stringify(r).slice(0, 200)})`);
|
|
332
449
|
const d = text(r);
|
|
@@ -334,13 +451,13 @@ function linkChecks(app, link) {
|
|
|
334
451
|
};
|
|
335
452
|
const linkedEntry = async (a, b) => (await link.agents()).find((e) => e.grants.includes(a) && e.grants.includes(b));
|
|
336
453
|
const works = async (conn, where) => {
|
|
337
|
-
const r = await call(app, conn
|
|
454
|
+
const r = await call(app, conn, "get_current_view");
|
|
338
455
|
expect(!r.isError && "view" in data(r), `${where} still reads the app`);
|
|
339
456
|
};
|
|
340
457
|
return [
|
|
341
458
|
check("the same client on the other path is offered a code once per session, and the code links them", async () => {
|
|
342
459
|
const { direct, dock, offer } = await pair();
|
|
343
|
-
const again = await call(app, dock
|
|
460
|
+
const again = await call(app, dock, app.probe.tool, app.probe.args);
|
|
344
461
|
expect(offerOf(again) === undefined, "asked at most once per MCP session");
|
|
345
462
|
const done = await redeem(direct, offer.code);
|
|
346
463
|
expect(!done.isError && data(done).linked === true, `the other connection redeems it: ${JSON.stringify(done).slice(0, 200)}`);
|
|
@@ -372,22 +489,22 @@ function linkChecks(app, link) {
|
|
|
372
489
|
await sleep((APC_LINK_TTL_S + 1) * 1000);
|
|
373
490
|
refused(await redeem(direct, offer.code), "expired", "after expires_in");
|
|
374
491
|
expect(!(await linkedEntry(direct.grantId, dock.grantId)), "it stays two agents");
|
|
375
|
-
expect(offerOf(await call(app, dock
|
|
492
|
+
expect(offerOf(await call(app, dock, app.probe.tool, app.probe.args)) === undefined, "not asked again in that session");
|
|
376
493
|
}),
|
|
377
494
|
check("a connection of another client is offered nothing", async () => {
|
|
378
495
|
const direct = await connect("direct");
|
|
379
496
|
await arrive(app, direct, client());
|
|
380
497
|
const dock = await connect("dock");
|
|
381
498
|
await arrive(app, dock, client());
|
|
382
|
-
expect(offerOf(await call(app, dock
|
|
499
|
+
expect(offerOf(await call(app, dock, app.probe.tool, app.probe.args)) === undefined, "no offer between two clients");
|
|
383
500
|
}),
|
|
384
501
|
check("revoke on a linked agent cuts both grants", async () => {
|
|
385
502
|
const { direct, dock, offer } = await pair();
|
|
386
503
|
expect(!(await redeem(direct, offer.code)).isError, "linked");
|
|
387
504
|
expect(await app.page.revoke(dock.grantId), "the app refused to revoke");
|
|
388
505
|
for (const [conn, where] of [[direct, "Direct"], [dock, "Dock"]]) {
|
|
389
|
-
const { status, body } = await send(app, conn
|
|
390
|
-
|
|
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);
|
|
391
508
|
}
|
|
392
509
|
}),
|
|
393
510
|
check("unlink shows two agents again, each with its own grant", async () => {
|
|
@@ -408,7 +525,7 @@ export async function runConformance(app) {
|
|
|
408
525
|
for (const c of apcConformance(app)) {
|
|
409
526
|
try {
|
|
410
527
|
await c.run();
|
|
411
|
-
results.push({ name: c.name, ok: true });
|
|
528
|
+
results.push({ name: c.name, ok: true, ...(c.note ? { note: c.note } : {}) });
|
|
412
529
|
}
|
|
413
530
|
catch (error) {
|
|
414
531
|
results.push({ name: c.name, ok: false, error: error instanceof Error ? error.message : String(error) });
|
package/dist/shared.d.ts
CHANGED
|
@@ -41,3 +41,16 @@ export declare function resultKey(r: ToolCallResult, key: string): {
|
|
|
41
41
|
export declare function attentionOf(r: ToolCallResult): unknown;
|
|
42
42
|
/** What `tools/list` must say about the core tools (shared by the suite and the CLI). */
|
|
43
43
|
export declare function checkCoreToolList(listed: unknown, coreTools: readonly string[]): void;
|
|
44
|
+
/**
|
|
45
|
+
* Whether checks written for the standard `own` can judge an app that claims `claimed` in its apc.json:
|
|
46
|
+
* any version from 0.5.0 (apc.json exists since) up to `own`, and also a newer patch of the same minor
|
|
47
|
+
* (`own` 0.7.0, `claimed` 0.7.1), since a patch never changes the contract: that passes with a note. A newer
|
|
48
|
+
* minor or major may change the contract, so it fails: the checks need updating.
|
|
49
|
+
*/
|
|
50
|
+
export declare function standardSupport(claimed: string, own: string): {
|
|
51
|
+
ok: true;
|
|
52
|
+
note?: string;
|
|
53
|
+
} | {
|
|
54
|
+
ok: false;
|
|
55
|
+
reason: string;
|
|
56
|
+
};
|
package/dist/shared.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* What the in-process suite (`index.ts`) and the HTTP checks (`http.ts`) share: the error a check
|
|
3
3
|
* throws, how a JSON-RPC reply is read off Streamable HTTP, and the shapes of the core's view and focus.
|
|
4
4
|
*/
|
|
5
|
-
import { parseParams, parseTarget } from "@tineira/apc-protocol";
|
|
5
|
+
import { compareStandard, parseParams, parseTarget } from "@tineira/apc-protocol";
|
|
6
6
|
export class ConformanceError extends Error {
|
|
7
7
|
}
|
|
8
8
|
export function expect(condition, message) {
|
|
@@ -122,3 +122,23 @@ export function checkCoreToolList(listed, coreTools) {
|
|
|
122
122
|
expect(timeout && (timeout.type === "integer" || timeout.type === "number") && timeout.minimum === 1, "wait_for_focus takes timeout_s, a whole number from 1");
|
|
123
123
|
expect(!(wait.inputSchema?.required ?? []).includes("timeout_s"), "timeout_s is optional");
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Whether checks written for the standard `own` can judge an app that claims `claimed` in its apc.json:
|
|
127
|
+
* any version from 0.5.0 (apc.json exists since) up to `own`, and also a newer patch of the same minor
|
|
128
|
+
* (`own` 0.7.0, `claimed` 0.7.1), since a patch never changes the contract: that passes with a note. A newer
|
|
129
|
+
* minor or major may change the contract, so it fails: the checks need updating.
|
|
130
|
+
*/
|
|
131
|
+
export function standardSupport(claimed, own) {
|
|
132
|
+
if (!/^\d+\.\d+\.\d+$/.test(claimed))
|
|
133
|
+
return { ok: false, reason: `standard is ${claimed}, not a version` };
|
|
134
|
+
if (compareStandard(claimed, "0.5.0") < 0)
|
|
135
|
+
return { ok: false, reason: `standard is ${claimed}: apc.json exists since 0.5.0` };
|
|
136
|
+
if (compareStandard(claimed, own) <= 0)
|
|
137
|
+
return { ok: true };
|
|
138
|
+
const [cMajor, cMinor] = claimed.split(".");
|
|
139
|
+
const [oMajor, oMinor] = own.split(".");
|
|
140
|
+
if (cMajor === oMajor && cMinor === oMinor) {
|
|
141
|
+
return { ok: true, note: `standard ${claimed} is a newer patch of the ${own} these checks know; a patch never changes the contract, so they apply (update @tineira/apc-conformance when you can)` };
|
|
142
|
+
}
|
|
143
|
+
return { ok: false, reason: `standard is ${claimed}, newer than the ${own} these checks know: update @tineira/apc-conformance` };
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tineira/apc-conformance",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
48
|
+
"@tineira/apc-protocol": "^0.8.0"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {},
|
|
51
51
|
"types": "./dist/index.d.ts",
|