@tineira/apc-conformance 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +24 -1
- package/dist/http.d.ts +7 -0
- package/dist/http.js +47 -15
- package/dist/hub.d.ts +43 -0
- package/dist/hub.js +147 -0
- package/dist/index.d.ts +36 -2
- package/dist/index.js +286 -61
- 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,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
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
|
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 ===
|
|
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 ===
|
|
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
|
|
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/hub.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type StandInHubMode = "ack" | "no_ack" | "redirect" | "error_500";
|
|
2
|
+
export interface StandInPairing {
|
|
3
|
+
id: string;
|
|
4
|
+
agent: string;
|
|
5
|
+
mcp_url: string;
|
|
6
|
+
app_origin: string;
|
|
7
|
+
status: "pending" | "paired";
|
|
8
|
+
/** The code the app sent, raw, until it is typed right; null for an app that sent none. */
|
|
9
|
+
pairing_code: string | null;
|
|
10
|
+
attempts: number;
|
|
11
|
+
}
|
|
12
|
+
export interface StandInHubOptions {
|
|
13
|
+
/** The hub's origin; "https://dock.test" by default. */
|
|
14
|
+
origin?: string;
|
|
15
|
+
/** How the hub reaches the app (its `/apc/revoke` and the grant's MCP URL): in process, the app's own handler. Without it the hub calls nobody. */
|
|
16
|
+
app?: (request: Request) => Promise<Response>;
|
|
17
|
+
/** Milliseconds since the epoch, for tickets; Date.now by default. */
|
|
18
|
+
now?: () => number;
|
|
19
|
+
}
|
|
20
|
+
export interface StandInHub {
|
|
21
|
+
readonly origin: string;
|
|
22
|
+
/** What the app's server calls the hub with (pass it as the app's fetch, or route the hub's origin to it). */
|
|
23
|
+
readonly fetch: typeof fetch;
|
|
24
|
+
/** How the hub answers `/apc/pair`: "ack" (a hub of 0.9) unless a check sets another. */
|
|
25
|
+
mode: StandInHubMode;
|
|
26
|
+
/** A new single-use ticket for this agent, as the dock puts on a drag. */
|
|
27
|
+
ticket(agent: string): string;
|
|
28
|
+
/** The pairings it holds, pending and paired. */
|
|
29
|
+
readonly pairings: StandInPairing[];
|
|
30
|
+
/** Every `/apc/pair` body the app sent, oldest first, whatever the hub answered. */
|
|
31
|
+
readonly received: Array<Record<string, unknown>>;
|
|
32
|
+
/** The MCP URL of the agent's latest pairing. */
|
|
33
|
+
urlOf(agent: string): string | undefined;
|
|
34
|
+
/** The dock owner approves (with what they typed) or denies a pairing. */
|
|
35
|
+
decide(pairingId: string, action: "approve" | "deny", code?: unknown): Promise<{
|
|
36
|
+
ok: boolean;
|
|
37
|
+
error?: string;
|
|
38
|
+
attempts_left?: number;
|
|
39
|
+
}>;
|
|
40
|
+
/** The pairing code's time ran out: every pending pairing with a code goes, its grant given back. */
|
|
41
|
+
expire(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export declare function standInHub(options?: StandInHubOptions): StandInHub;
|
package/dist/hub.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stand-in for a dock's hub (the Dock profile), for testing an app in process: what `@tineira/apc-conformance`
|
|
3
|
+
* checks a Dock app against, and what an app's own tests can pair with. It is faithful to the parts of a hub an
|
|
4
|
+
* app meets, as of APC 0.9:
|
|
5
|
+
*
|
|
6
|
+
* - **Tickets**: `ticket(agent)` mints a single-use ticket for an agent, valid `TICKET_TTL_MS`.
|
|
7
|
+
* - **`POST {hub}/apc/pair`**: checks the body before it spends the ticket (a `pairing_code` present but not a
|
|
8
|
+
* code is `invalid_pairing_code`), keeps the code with the pending pairing and answers the acknowledgement
|
|
9
|
+
* (`pairing_code: "required"`). An app that sends no code still pairs, as a hub in `when_sent` mode lets it.
|
|
10
|
+
* - **`POST {hub}/apc/confirm`**: whether it still pairs that agent with that MCP URL.
|
|
11
|
+
* - **The dock owner's answer** (`decide`): approving a pairing with a code needs that code; wrong ones count, and
|
|
12
|
+
* the last of `PAIRING_CODE_MAX_ATTEMPTS` removes the pairing and gives the grant back
|
|
13
|
+
* (`POST {app}/apc/revoke`); the right one pairs it and introduces the hub to the app (`initialize` on the
|
|
14
|
+
* grant). `expire()` is the pairing code's time running out: every pending pairing with a code goes, its
|
|
15
|
+
* grant given back.
|
|
16
|
+
* - **A broken hub**, for the checks that an app fails closed (`mode`): `"no_ack"` answers a yes without the
|
|
17
|
+
* acknowledgement (a hub from before 0.9), `"redirect"` redirects `/apc/pair` to another host that says yes,
|
|
18
|
+
* `"error_500"` says yes with HTTP 500.
|
|
19
|
+
*
|
|
20
|
+
* It never shows a code: `received` keeps what the app sent, for the checks to compare with what the app showed.
|
|
21
|
+
*/
|
|
22
|
+
import { normalizePairingCode, PAIRING_CODE_ACK, PAIRING_CODE_MAX_ATTEMPTS, pairingCodesMatch, TICKET_TTL_MS } from "@tineira/apc-protocol";
|
|
23
|
+
const randomHex = (bytes) => [...crypto.getRandomValues(new Uint8Array(bytes))].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
24
|
+
export function standInHub(options = {}) {
|
|
25
|
+
const origin = options.origin ?? "https://dock.test";
|
|
26
|
+
const now = options.now ?? Date.now;
|
|
27
|
+
const did = randomHex(16);
|
|
28
|
+
const tickets = new Map();
|
|
29
|
+
const pairings = [];
|
|
30
|
+
const received = [];
|
|
31
|
+
const json = (body, status = 200) => Response.json(body, { status });
|
|
32
|
+
const callApp = async (url, body) => {
|
|
33
|
+
if (!options.app)
|
|
34
|
+
return;
|
|
35
|
+
const response = await options.app(new Request(url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify(body) }));
|
|
36
|
+
await response.body?.cancel().catch(() => { });
|
|
37
|
+
};
|
|
38
|
+
const giveBack = async (p) => {
|
|
39
|
+
pairings.splice(pairings.indexOf(p), 1);
|
|
40
|
+
await callApp(`${p.app_origin}/apc/revoke`, { mcp_url: p.mcp_url });
|
|
41
|
+
};
|
|
42
|
+
const live = (ticket, agent) => {
|
|
43
|
+
const t = typeof ticket === "string" ? tickets.get(ticket) : undefined;
|
|
44
|
+
return !!t && t.agent === agent && t.expires_at > now();
|
|
45
|
+
};
|
|
46
|
+
const pair = (b) => {
|
|
47
|
+
if (typeof b.ticket !== "string" || typeof b.agent !== "string" || typeof b.mcp_url !== "string")
|
|
48
|
+
return json({ ok: false, error: "invalid" }, 400);
|
|
49
|
+
const app = b.app;
|
|
50
|
+
if (typeof app?.name !== "string" || !app.name || typeof app.origin !== "string")
|
|
51
|
+
return json({ ok: false, error: "app_not_allowed" }, 400);
|
|
52
|
+
// The body first: a malformed code leaves the ticket good.
|
|
53
|
+
const code = b.pairing_code === undefined ? null : normalizePairingCode(b.pairing_code);
|
|
54
|
+
if (b.pairing_code !== undefined && !code)
|
|
55
|
+
return json({ ok: false, error: "invalid_pairing_code" }, 400);
|
|
56
|
+
if (!live(b.ticket, b.agent))
|
|
57
|
+
return json({ ok: false, error: "ticket_expired" }, 400);
|
|
58
|
+
tickets.delete(b.ticket);
|
|
59
|
+
const p = { id: `pr_${randomHex(6)}`, agent: b.agent, mcp_url: b.mcp_url, app_origin: app.origin, status: "pending", pairing_code: code, attempts: 0 };
|
|
60
|
+
pairings.push(p);
|
|
61
|
+
return json({ ok: true, pairing_id: p.id, status: "pending", agent: { name: "Agent via dock", icon: null }, ...(code ? { pairing_code: PAIRING_CODE_ACK } : {}) });
|
|
62
|
+
};
|
|
63
|
+
const hub = {
|
|
64
|
+
origin,
|
|
65
|
+
mode: "ack",
|
|
66
|
+
pairings,
|
|
67
|
+
received,
|
|
68
|
+
ticket(agent) {
|
|
69
|
+
const ticket = `${did}.${randomHex(16)}`;
|
|
70
|
+
tickets.set(ticket, { agent, expires_at: now() + TICKET_TTL_MS });
|
|
71
|
+
return ticket;
|
|
72
|
+
},
|
|
73
|
+
urlOf: (agent) => pairings.filter((p) => p.agent === agent).at(-1)?.mcp_url,
|
|
74
|
+
fetch: (async (input, init) => {
|
|
75
|
+
const request = input instanceof Request ? input : new Request(input, init);
|
|
76
|
+
const url = new URL(request.url);
|
|
77
|
+
if (url.origin !== origin || request.method !== "POST")
|
|
78
|
+
return json({ ok: false, error: "not_found" }, 404);
|
|
79
|
+
const body = ((await request.json().catch(() => null)) ?? {});
|
|
80
|
+
if (url.pathname === "/apc/pair") {
|
|
81
|
+
received.push(body);
|
|
82
|
+
if (hub.mode === "redirect") {
|
|
83
|
+
// A fetch that refuses redirects (as an app's must) fails; one that follows lands on another host that says yes.
|
|
84
|
+
if ((init?.redirect ?? request.redirect) === "error")
|
|
85
|
+
throw new TypeError(`redirect from ${request.url} refused`);
|
|
86
|
+
const elsewhere = json({ ok: true, pairing_id: "pr_elsewhere", status: "pending", agent: { name: "Agent via dock", icon: null }, pairing_code: PAIRING_CODE_ACK });
|
|
87
|
+
Object.defineProperty(elsewhere, "redirected", { value: true });
|
|
88
|
+
return elsewhere;
|
|
89
|
+
}
|
|
90
|
+
if (hub.mode === "error_500")
|
|
91
|
+
return json({ ok: true, pairing_id: "pr_500", status: "pending", agent: { name: "Agent via dock", icon: null }, pairing_code: PAIRING_CODE_ACK }, 500);
|
|
92
|
+
const answer = pair(body);
|
|
93
|
+
if (hub.mode === "no_ack" && answer.ok) {
|
|
94
|
+
const rest = (await answer.json());
|
|
95
|
+
delete rest.pairing_code;
|
|
96
|
+
return json(rest);
|
|
97
|
+
}
|
|
98
|
+
return answer;
|
|
99
|
+
}
|
|
100
|
+
if (url.pathname === "/apc/confirm") {
|
|
101
|
+
if (!live(body.ticket, body.agent))
|
|
102
|
+
return json({ ok: false, error: "ticket_expired" }, 400);
|
|
103
|
+
const p = pairings.find((x) => x.agent === body.agent && x.mcp_url === body.mcp_url);
|
|
104
|
+
if (!p)
|
|
105
|
+
return json({ ok: false, error: "not_paired" }, 400);
|
|
106
|
+
tickets.delete(body.ticket);
|
|
107
|
+
return json({ ok: true, status: p.status, agent: { name: "Agent via dock", icon: null } });
|
|
108
|
+
}
|
|
109
|
+
return json({ ok: false, error: "not_found" }, 404);
|
|
110
|
+
}),
|
|
111
|
+
async decide(pairingId, action, code) {
|
|
112
|
+
const p = pairings.find((x) => x.id === pairingId);
|
|
113
|
+
if (!p)
|
|
114
|
+
return { ok: false, error: "unknown_pairing" };
|
|
115
|
+
if (action === "deny") {
|
|
116
|
+
await giveBack(p);
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
if (p.status !== "pending")
|
|
120
|
+
return { ok: false, error: "invalid_action" };
|
|
121
|
+
if (p.pairing_code) {
|
|
122
|
+
if (code === undefined || code === null || code === "")
|
|
123
|
+
return { ok: false, error: "pairing_code_required" };
|
|
124
|
+
const given = normalizePairingCode(code);
|
|
125
|
+
if (!given)
|
|
126
|
+
return { ok: false, error: "invalid_pairing_code" };
|
|
127
|
+
if (!pairingCodesMatch(p.pairing_code, given)) {
|
|
128
|
+
p.attempts++;
|
|
129
|
+
if (p.attempts < PAIRING_CODE_MAX_ATTEMPTS)
|
|
130
|
+
return { ok: false, error: "wrong_pairing_code", attempts_left: PAIRING_CODE_MAX_ATTEMPTS - p.attempts };
|
|
131
|
+
await giveBack(p);
|
|
132
|
+
return { ok: false, error: "pairing_code_refused" };
|
|
133
|
+
}
|
|
134
|
+
p.pairing_code = null;
|
|
135
|
+
}
|
|
136
|
+
p.status = "paired";
|
|
137
|
+
// The hub introduces itself to the app, as a real one does on approval.
|
|
138
|
+
await callApp(p.mcp_url, { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "apc-hub", title: "Agent via dock", version: "0.9.0" } } });
|
|
139
|
+
return { ok: true };
|
|
140
|
+
},
|
|
141
|
+
async expire() {
|
|
142
|
+
for (const p of pairings.filter((x) => x.status === "pending" && x.pairing_code))
|
|
143
|
+
await giveBack(p);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
return hub;
|
|
147
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
* (`GET {origin}/.well-known/apc.json`, through the adapter's `fetch`): its shape, its origin, the
|
|
9
9
|
* standard, and that its profiles and `mcp.path` match what the app serves. Since 0.6 an adapter may
|
|
10
10
|
* also give `page.mobile` (the page on a phone): the suite then checks that every agent is shown in the
|
|
11
|
-
* page, that the bar cannot be collapsed, and that a finger can pin and unpin with no mouse drag.
|
|
11
|
+
* page, that the bar cannot be collapsed, and that a finger can pin and unpin with no mouse drag. Since 0.9
|
|
12
|
+
* an app on the Dock profile gives `dock` too: the suite pairs it with a stand-in hub (`standInHub`) and checks
|
|
13
|
+
* the pairing code (the consent shows one, new each time, and sends that one to the hub; the app keeps no grant
|
|
14
|
+
* when the hub does not acknowledge the code; wrong codes and the code's time running out give the grant back;
|
|
15
|
+
* the right code pairs).
|
|
12
16
|
*
|
|
13
17
|
* Framework-free: `apcConformance(app)` returns named checks that throw `ConformanceError`, for any
|
|
14
18
|
* test runner (`for (const c of apcConformance(app)) test(c.name, c.run)` under `node --test`), and
|
|
@@ -19,12 +23,20 @@
|
|
|
19
23
|
* and a revoked grant's answer); the `apc-conformance` command (`cli.ts`) runs them from a terminal.
|
|
20
24
|
*/
|
|
21
25
|
import { type ApcProfile, type FocusTarget, type PresenceEvent } from "@tineira/apc-protocol";
|
|
26
|
+
import type { StandInHub } from "./hub.js";
|
|
22
27
|
export { ConformanceError } from "./shared.js";
|
|
28
|
+
export { standInHub, type StandInHub, type StandInHubMode, type StandInHubOptions, type StandInPairing } from "./hub.js";
|
|
23
29
|
export { HttpConformanceUsageError, IN_PROCESS_ONLY, httpConformance, redactGrantUrl, redactSecrets, runHttpConformance, type HttpCheck, type HttpCheckResult, type HttpConformanceOptions, type HttpConformanceReport, } from "./http.js";
|
|
24
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* One agent's way into the app: the MCP URL its holder calls (the agent itself, or a dock's hub), the
|
|
32
|
+
* app's id for the grant, and (APC 0.8) the headers that carry the grant when the URL alone does not: an
|
|
33
|
+
* app whose grants share one MCP URL gives `{ authorization: "Bearer …" }`. With a secret URL, leave
|
|
34
|
+
* `headers` out.
|
|
35
|
+
*/
|
|
25
36
|
export interface AgentConnection {
|
|
26
37
|
mcpUrl: string;
|
|
27
38
|
grantId: string;
|
|
39
|
+
headers?: Record<string, string>;
|
|
28
40
|
}
|
|
29
41
|
/**
|
|
30
42
|
* What the `apc_link` checks need (APC 0.4), from an app that offers both profiles. The suite makes the
|
|
@@ -63,6 +75,26 @@ export interface ApcMobilePage {
|
|
|
63
75
|
/** The person removes the agent's pin with a finger (the × beside the agent in the bar). False when the page offers no way. */
|
|
64
76
|
unpinByTouch(grantId: string): Promise<boolean>;
|
|
65
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The Dock profile's pairing (APC 0.9), from an app on that profile: required of one that claims 0.9.0 or later.
|
|
80
|
+
* The app pairs with `hub`, a stand-in from this package (`standInHub({ app })`, whose `app` routes to the app's
|
|
81
|
+
* handler), which the app's server reaches as its hub.
|
|
82
|
+
*/
|
|
83
|
+
export interface ApcDockAdapter {
|
|
84
|
+
hub: StandInHub;
|
|
85
|
+
/**
|
|
86
|
+
* The person drops an agent the app holds no grant for, with a ticket from `hub.ticket(agent)`: the pairing code
|
|
87
|
+
* the app's consent shows, as the person sees it (formatted or not), and their Allow on that consent, with what
|
|
88
|
+
* the app answered the page.
|
|
89
|
+
*/
|
|
90
|
+
drop(agent: string, ticket: string): Promise<{
|
|
91
|
+
shown: string;
|
|
92
|
+
allow(): Promise<{
|
|
93
|
+
ok: boolean;
|
|
94
|
+
error?: string;
|
|
95
|
+
}>;
|
|
96
|
+
}>;
|
|
97
|
+
}
|
|
66
98
|
/** What the suite needs from an app. */
|
|
67
99
|
export interface ApcAppAdapter {
|
|
68
100
|
/** The app's name, for the report. */
|
|
@@ -96,6 +128,8 @@ export interface ApcAppAdapter {
|
|
|
96
128
|
};
|
|
97
129
|
/** An app that offers both profiles: the `apc_link` checks run too. */
|
|
98
130
|
link?: ApcLinkAdapter;
|
|
131
|
+
/** The Dock profile's pairing code (APC 0.9): required with `profile: "dock"` of an app that claims 0.9.0 or later. */
|
|
132
|
+
dock?: ApcDockAdapter;
|
|
99
133
|
}
|
|
100
134
|
export interface ConformanceCheck {
|
|
101
135
|
name: string;
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
* (`GET {origin}/.well-known/apc.json`, through the adapter's `fetch`): its shape, its origin, the
|
|
9
9
|
* standard, and that its profiles and `mcp.path` match what the app serves. Since 0.6 an adapter may
|
|
10
10
|
* also give `page.mobile` (the page on a phone): the suite then checks that every agent is shown in the
|
|
11
|
-
* page, that the bar cannot be collapsed, and that a finger can pin and unpin with no mouse drag.
|
|
11
|
+
* page, that the bar cannot be collapsed, and that a finger can pin and unpin with no mouse drag. Since 0.9
|
|
12
|
+
* an app on the Dock profile gives `dock` too: the suite pairs it with a stand-in hub (`standInHub`) and checks
|
|
13
|
+
* the pairing code (the consent shows one, new each time, and sends that one to the hub; the app keeps no grant
|
|
14
|
+
* when the hub does not acknowledge the code; wrong codes and the code's time running out give the grant back;
|
|
15
|
+
* the right code pairs).
|
|
12
16
|
*
|
|
13
17
|
* Framework-free: `apcConformance(app)` returns named checks that throw `ConformanceError`, for any
|
|
14
18
|
* test runner (`for (const c of apcConformance(app)) test(c.name, c.run)` under `node --test`), and
|
|
@@ -18,10 +22,13 @@
|
|
|
18
22
|
* of the suite that HTTP reaches (apc.json, and with a grant's MCP URL the core tools' shapes and timing,
|
|
19
23
|
* and a revoked grant's answer); the `apc-conformance` command (`cli.ts`) runs them from a terminal.
|
|
20
24
|
*/
|
|
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";
|
|
25
|
+
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, normalizePairingCode, PAIRING_CODE_MAX_ATTEMPTS, parseApcManifest, } from "@tineira/apc-protocol";
|
|
22
26
|
import { ConformanceError, ISO, attentionOf, checkCoreToolList, checkFocusShape, checkView, data, expect, isObject, resultKey, rpcBody, standardSupport, text } from "./shared.js";
|
|
23
27
|
export { ConformanceError } from "./shared.js";
|
|
28
|
+
export { standInHub } from "./hub.js";
|
|
24
29
|
export { HttpConformanceUsageError, IN_PROCESS_ONLY, httpConformance, redactGrantUrl, redactSecrets, runHttpConformance, } from "./http.js";
|
|
30
|
+
/** A grant's credential travels in a header (a bearer token), not in its URL: several grants may share the URL (APC 0.8). */
|
|
31
|
+
const sharesUrl = (conn) => !!conn.headers && Object.keys(conn.headers).some((h) => h.toLowerCase() === "authorization");
|
|
25
32
|
/** A check whose body may resolve to `{ note }`: the note lands on the check, and `run()` stays a test body. */
|
|
26
33
|
function noted(name, body) {
|
|
27
34
|
const check = {
|
|
@@ -36,27 +43,53 @@ function noted(name, body) {
|
|
|
36
43
|
}
|
|
37
44
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
45
|
/** 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,
|
|
40
|
-
const request = new Request(
|
|
46
|
+
async function send(app, to, method, params, signal) {
|
|
47
|
+
const request = new Request(to.mcpUrl, {
|
|
41
48
|
method: "POST",
|
|
42
|
-
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" },
|
|
49
|
+
headers: { ...to.headers, "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" },
|
|
43
50
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
51
|
+
...(signal ? { signal } : {}),
|
|
44
52
|
});
|
|
45
53
|
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
46
54
|
const body = rpcBody(response.headers.get("content-type") ?? "", await response.text());
|
|
47
|
-
return { status: response.status, body };
|
|
55
|
+
return { status: response.status, body, headers: response.headers };
|
|
48
56
|
}
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
/** A JSON-RPC notification (no id) in a POST of its own: the HTTP status (202 for a Streamable HTTP server). */
|
|
58
|
+
async function notify(app, to, method, params) {
|
|
59
|
+
const request = new Request(to.mcpUrl, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { ...to.headers, "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" },
|
|
62
|
+
body: JSON.stringify({ jsonrpc: "2.0", method, params }),
|
|
63
|
+
});
|
|
64
|
+
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
65
|
+
await response.body?.cancel().catch(() => { });
|
|
66
|
+
return response.status;
|
|
67
|
+
}
|
|
68
|
+
/** What a check of a 0.8 rule says of an app that claims an earlier standard: passed, not checked. */
|
|
69
|
+
const NOT_08 = { note: "not checked: the app's apc.json claims a standard before 0.8.0, which does not have this rule" };
|
|
70
|
+
async function result(app, to, method, params = {}, signal) {
|
|
71
|
+
const { status, body } = await send(app, to, method, params, signal);
|
|
51
72
|
expect(status === 200, `${method}: HTTP ${status}, expected 200 (${JSON.stringify(body).slice(0, 200)})`);
|
|
52
73
|
expect(isObject(body) && !("error" in body && body.error), `${method}: JSON-RPC error ${JSON.stringify(body).slice(0, 200)}`);
|
|
53
74
|
return body.result;
|
|
54
75
|
}
|
|
55
|
-
async function call(app,
|
|
56
|
-
const r = await result(app,
|
|
76
|
+
async function call(app, to, tool, args = {}, signal) {
|
|
77
|
+
const r = await result(app, to, "tools/call", { name: tool, arguments: args }, signal);
|
|
57
78
|
expect(isObject(r), `${tool}: no result`);
|
|
58
79
|
return r;
|
|
59
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* A revoked grant's answer (APC 0.8): a `RevokedAnswer` body, with the binding's status: 403 for a secret URL, 401 with
|
|
83
|
+
* `WWW-Authenticate: Bearer error="invalid_token"` for a bearer token.
|
|
84
|
+
*/
|
|
85
|
+
function checkRevoked(conn, status, body, what, headers) {
|
|
86
|
+
const bearer = sharesUrl(conn);
|
|
87
|
+
const expected = bearer ? REVOKED_HTTP_STATUS_BEARER : REVOKED_HTTP_STATUS;
|
|
88
|
+
expect(status === expected, `${what} on a revoked grant: HTTP ${status}, expected ${expected}`);
|
|
89
|
+
if (bearer)
|
|
90
|
+
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"`);
|
|
91
|
+
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)}`);
|
|
92
|
+
}
|
|
60
93
|
/** A focus in the core's format, naming `target`. */
|
|
61
94
|
function checkFocus(focus, target, where) {
|
|
62
95
|
const parsed = checkFocusShape(focus, where);
|
|
@@ -72,10 +105,17 @@ async function timed(work) {
|
|
|
72
105
|
export function apcConformance(app) {
|
|
73
106
|
const [first, second] = app.targets;
|
|
74
107
|
const check = (name, run) => noted(`${app.name} (${app.profile}): ${name}`, run);
|
|
108
|
+
/** Whether the app's apc.json claims `version` or later: a rule added in a version is checked only of apps that claim it. */
|
|
109
|
+
const claims = async (conn, version) => {
|
|
110
|
+
const request = new Request(`${new URL(conn.mcpUrl).origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
|
|
111
|
+
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
112
|
+
const standard = (await response.json().catch(() => ({}))).standard;
|
|
113
|
+
return typeof standard === "string" && compareStandard(standard, version) >= 0;
|
|
114
|
+
};
|
|
75
115
|
return [
|
|
76
116
|
check("serves apc.json from its own origin, naming the profiles it serves and where its MCP URLs live", async () => {
|
|
77
|
-
const
|
|
78
|
-
const origin = new URL(mcpUrl).origin;
|
|
117
|
+
const conn = await app.connect();
|
|
118
|
+
const origin = new URL(conn.mcpUrl).origin;
|
|
79
119
|
const request = new Request(`${origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
|
|
80
120
|
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
81
121
|
expect(response.status === 200, `GET ${APC_JSON_PATH}: HTTP ${response.status}, expected 200`);
|
|
@@ -98,21 +138,23 @@ export function apcConformance(app) {
|
|
|
98
138
|
expect(m.profiles.includes(app.profile), `profiles ${JSON.stringify(m.profiles)} leave out ${app.profile}, which the app serves`);
|
|
99
139
|
if (app.link)
|
|
100
140
|
expect(m.profiles.includes("direct") && m.profiles.includes("dock"), `profiles ${JSON.stringify(m.profiles)}: an app that links both paths offers both`);
|
|
101
|
-
|
|
141
|
+
// A Direct app whose grants share one URL (a bearer token names the grant) may serve it at mcp.path itself (APC 0.8).
|
|
142
|
+
const shared = app.profile === "direct" && sharesUrl(conn);
|
|
143
|
+
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
144
|
if (m.profiles.includes("direct"))
|
|
103
145
|
expect(m.direct && new URL(m.direct.connect).origin === origin, "direct.connect is the app's own page");
|
|
104
146
|
if (support.note)
|
|
105
147
|
return { note: support.note };
|
|
106
148
|
}),
|
|
107
149
|
check("lists the core tools, with their arguments", async () => {
|
|
108
|
-
const
|
|
109
|
-
checkCoreToolList(await result(app,
|
|
150
|
+
const conn = await app.connect();
|
|
151
|
+
checkCoreToolList(await result(app, conn, "tools/list"), CORE_TOOLS);
|
|
110
152
|
}),
|
|
111
153
|
check("get_current_view returns the view as identifiers, and no focus before a pin", async () => {
|
|
112
|
-
const
|
|
154
|
+
const conn = await app.connect();
|
|
113
155
|
await app.page.setOpen(true);
|
|
114
156
|
await app.page.showView();
|
|
115
|
-
const r = await call(app,
|
|
157
|
+
const r = await call(app, conn, "get_current_view");
|
|
116
158
|
expect(!r.isError, "get_current_view failed");
|
|
117
159
|
const d = data(r);
|
|
118
160
|
expect("view" in d && "focus" in d, "the result has view and focus");
|
|
@@ -121,29 +163,31 @@ export function apcConformance(app) {
|
|
|
121
163
|
expect(attentionOf(r) === undefined, "no pin, so no attention");
|
|
122
164
|
}),
|
|
123
165
|
check("a pin rides as attention on any result until it is read, and next reads it", async () => {
|
|
124
|
-
const
|
|
166
|
+
const conn = await app.connect();
|
|
167
|
+
const { grantId } = conn;
|
|
125
168
|
expect(await app.page.pin(grantId, first), "the app refused a pin on the first target");
|
|
126
|
-
const probed = await call(app,
|
|
169
|
+
const probed = await call(app, conn, app.probe.tool, app.probe.args);
|
|
127
170
|
expect(!probed.isError, `${app.probe.tool} failed`);
|
|
128
171
|
const attention = attentionOf(probed);
|
|
129
172
|
expect(isObject(attention) && typeof attention.note === "string", "an unread pin puts attention { focus, note } on the result");
|
|
130
173
|
checkFocus(attention.focus, first, "attention");
|
|
131
|
-
const view = await call(app,
|
|
174
|
+
const view = await call(app, conn, "get_current_view");
|
|
132
175
|
const focus = checkFocus(data(view).focus, first, "get_current_view");
|
|
133
|
-
const after = await call(app,
|
|
176
|
+
const after = await call(app, conn, app.probe.tool, app.probe.args);
|
|
134
177
|
expect(attentionOf(after) === undefined, "reading the focus marks it seen: no attention after");
|
|
135
|
-
const read = await call(app,
|
|
178
|
+
const read = await call(app, conn, focus.next, focus.next_args);
|
|
136
179
|
expect(!read.isError, `next (${String(focus.next)}) with next_args failed: ${JSON.stringify(read.content).slice(0, 200)}`);
|
|
137
180
|
// One pin per grant: a new drop replaces it, unread again.
|
|
138
181
|
expect(await app.page.pin(grantId, second), "the app refused a pin on the second target");
|
|
139
|
-
const again = attentionOf(await call(app,
|
|
182
|
+
const again = attentionOf(await call(app, conn, app.probe.tool, app.probe.args));
|
|
140
183
|
expect(isObject(again), "a new pin is unread again");
|
|
141
184
|
checkFocus(again.focus, second, "attention after a second pin");
|
|
142
185
|
}),
|
|
143
186
|
check("wait_for_focus returns a pin dropped during the wait", async () => {
|
|
144
|
-
const
|
|
187
|
+
const conn = await app.connect();
|
|
188
|
+
const { grantId } = conn;
|
|
145
189
|
await app.page.setOpen(true);
|
|
146
|
-
const waiting = timed(call(app,
|
|
190
|
+
const waiting = timed(call(app, conn, "wait_for_focus", { timeout_s: 10 }));
|
|
147
191
|
await sleep(300);
|
|
148
192
|
expect(await app.page.pin(grantId, second), "the app refused the pin");
|
|
149
193
|
const { value: r, ms } = await waiting;
|
|
@@ -152,13 +196,13 @@ export function apcConformance(app) {
|
|
|
152
196
|
expect(d.timed_out === false, "a wait that got a pin did not time out");
|
|
153
197
|
checkFocus(d.focus, second, "wait_for_focus");
|
|
154
198
|
expect(ms < 9000, `it returned at ${ms} ms, not soon after the pin`);
|
|
155
|
-
const after = await call(app,
|
|
199
|
+
const after = await call(app, conn, app.probe.tool, app.probe.args);
|
|
156
200
|
expect(attentionOf(after) === undefined, "the wait marks the pin seen");
|
|
157
201
|
}),
|
|
158
202
|
check("wait_for_focus times out cleanly", async () => {
|
|
159
|
-
const
|
|
203
|
+
const conn = await app.connect();
|
|
160
204
|
await app.page.setOpen(true);
|
|
161
|
-
const { value: r, ms } = await timed(call(app,
|
|
205
|
+
const { value: r, ms } = await timed(call(app, conn, "wait_for_focus", { timeout_s: 1 }));
|
|
162
206
|
expect(!r.isError, "wait_for_focus failed");
|
|
163
207
|
const d = data(r);
|
|
164
208
|
expect(d.focus === null && d.timed_out === true, `a wait with no pin times out: ${JSON.stringify(d)}`);
|
|
@@ -168,9 +212,9 @@ export function apcConformance(app) {
|
|
|
168
212
|
expect(text(r).then === undefined && d.then === undefined, "a wait that came back empty ends the loop: no then");
|
|
169
213
|
}),
|
|
170
214
|
check("wait_for_focus ends when the person has no page open", async () => {
|
|
171
|
-
const
|
|
215
|
+
const conn = await app.connect();
|
|
172
216
|
await app.page.setOpen(false);
|
|
173
|
-
const { value: r, ms } = await timed(call(app,
|
|
217
|
+
const { value: r, ms } = await timed(call(app, conn, "wait_for_focus", { timeout_s: 3 }));
|
|
174
218
|
const d = data(r);
|
|
175
219
|
expect(d.focus === null, "no pin, no focus");
|
|
176
220
|
if (d.page_open === undefined)
|
|
@@ -181,25 +225,33 @@ export function apcConformance(app) {
|
|
|
181
225
|
}
|
|
182
226
|
await app.page.setOpen(true);
|
|
183
227
|
}),
|
|
184
|
-
check("then rides on results only while the person's page is open", async () => {
|
|
185
|
-
const
|
|
228
|
+
check("then rides on results only while the person's page is open and the agent was pinned lately", async () => {
|
|
229
|
+
const conn = await app.connect();
|
|
186
230
|
await app.page.setOpen(true);
|
|
187
|
-
const
|
|
231
|
+
const v08 = await claims(conn, "0.8.0");
|
|
232
|
+
if (v08) {
|
|
233
|
+
const unpinned = await call(app, conn, app.probe.tool, app.probe.args);
|
|
234
|
+
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");
|
|
235
|
+
}
|
|
236
|
+
expect(await app.page.pin(conn.grantId, first), "the app refused the pin");
|
|
237
|
+
expect(!(await call(app, conn, "get_current_view")).isError, "get_current_view failed");
|
|
238
|
+
const open = await call(app, conn, app.probe.tool, app.probe.args);
|
|
188
239
|
// resultKey: the same value in the structured result and the text (a text-only model follows the text).
|
|
189
240
|
const then = resultKey(open, "then");
|
|
190
241
|
expect(typeof then.value === "string" && typeof text(open).then === "string", "with the page open, a result carries then, structured and in the text");
|
|
191
242
|
await app.page.setOpen(false);
|
|
192
|
-
const closed = await call(app,
|
|
243
|
+
const closed = await call(app, conn, app.probe.tool, app.probe.args);
|
|
193
244
|
expect(resultKey(closed, "then").value === undefined && text(closed).then === undefined, "with no page open, no then");
|
|
194
245
|
await app.page.setOpen(true);
|
|
195
246
|
}),
|
|
196
247
|
check("presence names what a read touched, with ids only", async () => {
|
|
197
|
-
const
|
|
248
|
+
const conn = await app.connect();
|
|
249
|
+
const { grantId } = conn;
|
|
198
250
|
await app.page.setOpen(true);
|
|
199
251
|
expect(await app.page.pin(grantId, first), "the app refused the pin");
|
|
200
|
-
const focus = checkFocus(data(await call(app,
|
|
252
|
+
const focus = checkFocus(data(await call(app, conn, "get_current_view")).focus, first, "get_current_view");
|
|
201
253
|
app.page.takePresence();
|
|
202
|
-
const read = await call(app,
|
|
254
|
+
const read = await call(app, conn, focus.next, focus.next_args);
|
|
203
255
|
expect(!read.isError, "next failed");
|
|
204
256
|
const events = app.page.takePresence().filter((e) => e.grant_id === grantId && e.tool === focus.next);
|
|
205
257
|
expect(events.length === 1, `one presence event for the read, got ${events.length}`);
|
|
@@ -218,13 +270,12 @@ export function apcConformance(app) {
|
|
|
218
270
|
expect(await app.page.pin(a.grantId, first), "the app refused the pin");
|
|
219
271
|
expect(await app.page.revoke(a.grantId), "the app refused to revoke");
|
|
220
272
|
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
|
|
222
|
-
|
|
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)}`);
|
|
273
|
+
const { status, body, headers } = await send(app, a, method, params);
|
|
274
|
+
checkRevoked(a, status, body, method, headers);
|
|
224
275
|
}
|
|
225
276
|
// Whatever the HTTP method: a GET (the SSE stream) or a DELETE (ending a session) is answered "revoked" too, not 405.
|
|
226
277
|
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" } });
|
|
278
|
+
const request = new Request(a.mcpUrl, { method, headers: { ...a.headers, accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-06-18" } });
|
|
228
279
|
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
229
280
|
const text = await response.text();
|
|
230
281
|
let body = null;
|
|
@@ -234,17 +285,17 @@ export function apcConformance(app) {
|
|
|
234
285
|
catch {
|
|
235
286
|
// not JSON
|
|
236
287
|
}
|
|
237
|
-
|
|
238
|
-
expect(isRevokedAnswer(body), `${method} on a revoked grant answers { error: "revoked", revoked_by, revoked_at }: ${text.slice(0, 200)}`);
|
|
288
|
+
checkRevoked(a, response.status, body, method, response.headers);
|
|
239
289
|
}
|
|
240
290
|
expect(!(await app.page.pin(a.grantId, second)), "a revoked agent cannot be pinned");
|
|
241
|
-
const other = await call(app, b
|
|
291
|
+
const other = await call(app, b, "get_current_view");
|
|
242
292
|
expect(!other.isError && "view" in data(other), "another agent is not touched");
|
|
243
293
|
}),
|
|
244
294
|
check("a call already running when the grant is revoked ends revoked, with no data", async () => {
|
|
245
|
-
const
|
|
295
|
+
const conn = await app.connect();
|
|
296
|
+
const { grantId } = conn;
|
|
246
297
|
await app.page.setOpen(true);
|
|
247
|
-
const waiting = timed(call(app,
|
|
298
|
+
const waiting = timed(call(app, conn, "wait_for_focus", { timeout_s: 10 }));
|
|
248
299
|
await sleep(300);
|
|
249
300
|
expect(await app.page.revoke(grantId), "the app refused to revoke");
|
|
250
301
|
const { value: r, ms } = await waiting;
|
|
@@ -254,10 +305,183 @@ export function apcConformance(app) {
|
|
|
254
305
|
expect(!("focus" in d) && !("view" in d) && r.structuredContent === undefined, "it carries no focus, view or other data");
|
|
255
306
|
expect(ms < 9000, `it ended at ${ms} ms, not soon after the revoke`);
|
|
256
307
|
}),
|
|
308
|
+
check("one wait per grant: a newer wait takes over, and the older one delivers nothing (APC 0.8)", async () => {
|
|
309
|
+
const conn = await app.connect();
|
|
310
|
+
if (!(await claims(conn, "0.8.0")))
|
|
311
|
+
return NOT_08;
|
|
312
|
+
await app.page.setOpen(true);
|
|
313
|
+
const older = call(app, conn, "wait_for_focus", { timeout_s: 10 });
|
|
314
|
+
await sleep((WAIT_CLAIM_GAP_S + 0.3) * 1000);
|
|
315
|
+
const newer = timed(call(app, conn, "wait_for_focus", { timeout_s: 10 }));
|
|
316
|
+
const old = data(await older);
|
|
317
|
+
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)}`);
|
|
318
|
+
expect(await app.page.pin(conn.grantId, first), "the app refused the pin");
|
|
319
|
+
const { value: got, ms } = await newer;
|
|
320
|
+
checkFocus(data(got).focus, first, "the newer wait");
|
|
321
|
+
expect(ms < 9000, `the newer wait returned at ${ms} ms, not soon after the pin`);
|
|
322
|
+
}),
|
|
323
|
+
check("a second wait started right after the first is refused with retry_after_s, and the first still delivers (APC 0.8)", async () => {
|
|
324
|
+
const conn = await app.connect();
|
|
325
|
+
if (!(await claims(conn, "0.8.0")))
|
|
326
|
+
return NOT_08;
|
|
327
|
+
await app.page.setOpen(true);
|
|
328
|
+
const running = call(app, conn, "wait_for_focus", { timeout_s: 10 });
|
|
329
|
+
await sleep(200);
|
|
330
|
+
const refused = data(await call(app, conn, "wait_for_focus", { timeout_s: 10 }));
|
|
331
|
+
expect(refused.focus === null && refused.timed_out === false, `the second wait is refused: ${JSON.stringify(refused).slice(0, 200)}`);
|
|
332
|
+
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)}`);
|
|
333
|
+
expect(typeof refused.note === "string" && refused.note.length > 0, "and a note");
|
|
334
|
+
expect(await app.page.pin(conn.grantId, second), "the app refused the pin");
|
|
335
|
+
checkFocus(data(await running).focus, second, "the first wait, still running");
|
|
336
|
+
}),
|
|
337
|
+
...["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 () => {
|
|
338
|
+
const conn = await app.connect();
|
|
339
|
+
if (!(await claims(conn, "0.8.0")))
|
|
340
|
+
return NOT_08;
|
|
341
|
+
await app.page.setOpen(true);
|
|
342
|
+
const controller = new AbortController();
|
|
343
|
+
// Not awaited before the pin: a handler that ignores the cancel would still be waiting, and would take the pin.
|
|
344
|
+
const waiting = call(app, conn, "wait_for_focus", { timeout_s: 10 }, how === "an HTTP abort" ? controller.signal : undefined).catch(() => null);
|
|
345
|
+
await sleep(300);
|
|
346
|
+
if (how === "an HTTP abort")
|
|
347
|
+
controller.abort();
|
|
348
|
+
// MCP's cancel, in a POST of its own, as a client of a stateless server sends it.
|
|
349
|
+
else
|
|
350
|
+
expect((await notify(app, conn, "notifications/cancelled", { requestId: 1, reason: "apc-conformance" })) < 300, "notifications/cancelled was not accepted");
|
|
351
|
+
await sleep(300);
|
|
352
|
+
expect(await app.page.pin(conn.grantId, first), "the app refused the pin");
|
|
353
|
+
// 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.
|
|
354
|
+
await sleep(2500);
|
|
355
|
+
const attention = attentionOf(await call(app, conn, app.probe.tool, app.probe.args));
|
|
356
|
+
expect(isObject(attention), `the pin dropped after the cancelled wait is still unread: it rides as attention (the cancelled wait took it)`);
|
|
357
|
+
checkFocus(attention.focus, first, "attention after a cancelled wait");
|
|
358
|
+
const again = data(await call(app, conn, "wait_for_focus", { timeout_s: 2 }));
|
|
359
|
+
expect(again.retry_after_s === undefined, `a cancelled wait frees its place at once: ${JSON.stringify(again).slice(0, 200)}`);
|
|
360
|
+
checkFocus(again.focus, first, "the next wait gets the pin");
|
|
361
|
+
await Promise.race([waiting, sleep(11_000)]);
|
|
362
|
+
})),
|
|
257
363
|
...mobileChecks(app, app.page.mobile),
|
|
364
|
+
...(app.profile === "dock" ? dockChecks(app, app.dock) : []),
|
|
258
365
|
...(app.link ? linkChecks(app, app.link) : []),
|
|
259
366
|
];
|
|
260
367
|
}
|
|
368
|
+
/** What a check of a 0.9 rule says of an app that claims an earlier standard: passed, not checked. */
|
|
369
|
+
const NOT_09 = { note: "not checked: the app's apc.json claims a standard before 0.9.0, which does not have the pairing code" };
|
|
370
|
+
/** The standard an app's apc.json claims, read from the origin of one of its grants. */
|
|
371
|
+
async function claimedStandard(app, conn) {
|
|
372
|
+
const request = new Request(`${new URL(conn.mcpUrl).origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
|
|
373
|
+
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
374
|
+
return (await response.json().catch(() => ({}))).standard;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* The pairing code (APC 0.9), on the Dock profile: the consent shows a well-formed code, new for every consent,
|
|
378
|
+
* and the app sends that code to the hub; the app keeps no grant unless the hub acknowledges the code with a
|
|
379
|
+
* plain 200; five wrong codes, or the code's time running out, give the grant back; the right code pairs.
|
|
380
|
+
*/
|
|
381
|
+
function dockChecks(app, dock) {
|
|
382
|
+
const check = (name, run) => noted(`${app.name} (dock): pairing code: ${name}`, run);
|
|
383
|
+
let n = 0;
|
|
384
|
+
/** A new agent's drop, Allowed: what the consent showed, the app's answer, and the pairing the hub got. */
|
|
385
|
+
const allowNew = async (d) => {
|
|
386
|
+
const agent = `ag_code_${++n}_${Date.now().toString(36)}`;
|
|
387
|
+
const drop = await d.drop(agent, d.hub.ticket(agent));
|
|
388
|
+
const answer = await drop.allow();
|
|
389
|
+
return { agent, shown: drop.shown, answer, sent: d.hub.received.at(-1), pairing: d.hub.pairings.find((p) => p.agent === agent) };
|
|
390
|
+
};
|
|
391
|
+
/** The HTTP status the app gives a grant's MCP URL: 404 once it holds no grant there. */
|
|
392
|
+
const statusOf = async (mcpUrl) => (await send(app, { mcpUrl, grantId: "" }, "initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "apc-conformance", version: "1" } })).status;
|
|
393
|
+
/** Whether the app claims 0.9.0 or later, read through a grant made the usual way. */
|
|
394
|
+
const claims09 = async () => {
|
|
395
|
+
const claimed = await claimedStandard(app, await app.connect());
|
|
396
|
+
return typeof claimed === "string" && compareStandard(claimed, "0.9.0") >= 0;
|
|
397
|
+
};
|
|
398
|
+
return [
|
|
399
|
+
check("the dock adapter is given (required of an app that claims 0.9.0 or later on the Dock profile)", async () => {
|
|
400
|
+
if (dock)
|
|
401
|
+
return;
|
|
402
|
+
const claimed = await claimedStandard(app, await app.connect());
|
|
403
|
+
expect(typeof claimed === "string" && compareStandard(claimed, "0.9.0") < 0, `apc.json claims standard ${String(claimed)}, which requires the pairing code to be checked: give dock in the adapter (ApcDockAdapter: hub, drop)`);
|
|
404
|
+
}),
|
|
405
|
+
check("the consent shows a well-formed code, and the app sends that code, raw, to the hub", async () => {
|
|
406
|
+
if (!dock)
|
|
407
|
+
return;
|
|
408
|
+
if (!(await claims09()))
|
|
409
|
+
return NOT_09;
|
|
410
|
+
const { shown, answer, sent } = await allowNew(dock);
|
|
411
|
+
const code = normalizePairingCode(shown);
|
|
412
|
+
expect(code, `the consent shows ${JSON.stringify(shown)}, not a pairing code (6 characters of Crockford base32)`);
|
|
413
|
+
expect(answer.ok, `the Allow did not pair: ${answer.error}`);
|
|
414
|
+
expect(isObject(sent), "the app sent the hub no /apc/pair");
|
|
415
|
+
expect(sent.pairing_code !== undefined, "the app sent /apc/pair without pairing_code: an app of 0.9 must send the code its consent showed");
|
|
416
|
+
expect(normalizePairingCode(sent.pairing_code) === sent.pairing_code, `the app sent pairing_code ${JSON.stringify(sent.pairing_code)}: send it raw, 6 characters, no dash`);
|
|
417
|
+
expect(sent.pairing_code === code, "the app sent the hub another code than the one its consent showed");
|
|
418
|
+
}),
|
|
419
|
+
check("every consent shows a new code", async () => {
|
|
420
|
+
if (!dock)
|
|
421
|
+
return;
|
|
422
|
+
if (!(await claims09()))
|
|
423
|
+
return NOT_09;
|
|
424
|
+
const a = `ag_two_${++n}`;
|
|
425
|
+
const b = `ag_two_${++n}`;
|
|
426
|
+
const first = normalizePairingCode((await dock.drop(a, dock.hub.ticket(a))).shown);
|
|
427
|
+
const second = normalizePairingCode((await dock.drop(b, dock.hub.ticket(b))).shown);
|
|
428
|
+
expect(first && second, "a consent shows no pairing code");
|
|
429
|
+
expect(first !== second, `two consents showed the same code, ${first}`);
|
|
430
|
+
}),
|
|
431
|
+
check("a hub that does not acknowledge the code, redirects, or says yes with HTTP 500 holds no grant", async () => {
|
|
432
|
+
if (!dock)
|
|
433
|
+
return;
|
|
434
|
+
if (!(await claims09()))
|
|
435
|
+
return NOT_09;
|
|
436
|
+
try {
|
|
437
|
+
for (const mode of ["no_ack", "redirect", "error_500"]) {
|
|
438
|
+
dock.hub.mode = mode;
|
|
439
|
+
const { answer, sent } = await allowNew(dock);
|
|
440
|
+
expect(!answer.ok, `with a hub that answers ${mode}, the Allow says it paired: the app must refuse it`);
|
|
441
|
+
expect(isObject(sent) && typeof sent.mcp_url === "string", `with a hub that answers ${mode}, the app sent no MCP URL to check`);
|
|
442
|
+
const status = await statusOf(sent.mcp_url);
|
|
443
|
+
expect(status === 404, `with a hub that answers ${mode}, the grant the app made still answers (HTTP ${status}): it must be deleted before the app answers its page`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
dock.hub.mode = "ack";
|
|
448
|
+
}
|
|
449
|
+
}),
|
|
450
|
+
check(`${PAIRING_CODE_MAX_ATTEMPTS} wrong codes, or the code's time running out, give the grant back`, async () => {
|
|
451
|
+
if (!dock)
|
|
452
|
+
return;
|
|
453
|
+
if (!(await claims09()))
|
|
454
|
+
return NOT_09;
|
|
455
|
+
const wrong = await allowNew(dock);
|
|
456
|
+
expect(wrong.answer.ok && wrong.pairing, `the Allow did not pair: ${wrong.answer.error}`);
|
|
457
|
+
const code = normalizePairingCode(wrong.shown);
|
|
458
|
+
const other = code.startsWith("A") ? "BBBBBB" : "AAAAAA";
|
|
459
|
+
for (let i = 1; i <= PAIRING_CODE_MAX_ATTEMPTS; i++)
|
|
460
|
+
await dock.hub.decide(wrong.pairing.id, "approve", other);
|
|
461
|
+
expect(!dock.hub.pairings.includes(wrong.pairing), "the stand-in hub kept a pairing after the last wrong code");
|
|
462
|
+
const afterWrong = await statusOf(wrong.pairing.mcp_url);
|
|
463
|
+
expect(afterWrong === 404, `after ${PAIRING_CODE_MAX_ATTEMPTS} wrong codes the hub gave the grant back, and it still answers (HTTP ${afterWrong})`);
|
|
464
|
+
const late = await allowNew(dock);
|
|
465
|
+
expect(late.answer.ok && late.pairing, `the Allow did not pair: ${late.answer.error}`);
|
|
466
|
+
await dock.hub.expire();
|
|
467
|
+
const afterExpiry = await statusOf(late.pairing.mcp_url);
|
|
468
|
+
expect(afterExpiry === 404, `the code's time ran out and the hub gave the grant back, and it still answers (HTTP ${afterExpiry})`);
|
|
469
|
+
}),
|
|
470
|
+
check("the right code pairs, and the grant answers", async () => {
|
|
471
|
+
if (!dock)
|
|
472
|
+
return;
|
|
473
|
+
if (!(await claims09()))
|
|
474
|
+
return NOT_09;
|
|
475
|
+
const { shown, answer, pairing } = await allowNew(dock);
|
|
476
|
+
expect(answer.ok && pairing, `the Allow did not pair: ${answer.error}`);
|
|
477
|
+
const decided = await dock.hub.decide(pairing.id, "approve", shown);
|
|
478
|
+
expect(decided.ok, `the code the consent showed did not approve the pairing: ${decided.error}`);
|
|
479
|
+
const conn = { mcpUrl: pairing.mcp_url, grantId: "" };
|
|
480
|
+
const listed = await send(app, conn, "tools/list", {});
|
|
481
|
+
expect(listed.status === 200, `the paired grant answers tools/list with HTTP ${listed.status}`);
|
|
482
|
+
}),
|
|
483
|
+
];
|
|
484
|
+
}
|
|
261
485
|
/** The mobile rule (APC 0.6): on a phone the agents are in the page, never collapsed, and a finger can pin and unpin. */
|
|
262
486
|
function mobileChecks(app, mobile) {
|
|
263
487
|
const [first, second] = app.targets;
|
|
@@ -267,8 +491,8 @@ function mobileChecks(app, mobile) {
|
|
|
267
491
|
check("the page adapter for a phone is given (required of an app that claims 0.6.0 or later)", async () => {
|
|
268
492
|
if (mobile)
|
|
269
493
|
return;
|
|
270
|
-
const
|
|
271
|
-
const request = new Request(`${new URL(mcpUrl).origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
|
|
494
|
+
const conn = await app.connect();
|
|
495
|
+
const request = new Request(`${new URL(conn.mcpUrl).origin}${APC_JSON_PATH}`, { headers: { accept: "application/json" } });
|
|
272
496
|
const response = await (app.fetch ? app.fetch(request) : fetch(request));
|
|
273
497
|
const claimed = (await response.json().catch(() => ({}))).standard;
|
|
274
498
|
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 +511,19 @@ function mobileChecks(app, mobile) {
|
|
|
287
511
|
check("a person pins with a finger, no mouse drag, and the agent reads the pin; unpin works by touch too", async () => {
|
|
288
512
|
if (!mobile)
|
|
289
513
|
return;
|
|
290
|
-
const
|
|
514
|
+
const conn = await app.connect();
|
|
515
|
+
const { grantId } = conn;
|
|
291
516
|
await app.page.setOpen(true);
|
|
292
517
|
await app.page.showView();
|
|
293
518
|
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,
|
|
519
|
+
const r = await call(app, conn, "get_current_view");
|
|
295
520
|
expect(!r.isError, "get_current_view failed");
|
|
296
521
|
checkFocus(data(r).focus, first, "get_current_view after a pin by touch");
|
|
297
522
|
// The same again on another target: one pin per grant, replaced.
|
|
298
523
|
expect(await mobile.pinByTouch(grantId, second), "the page refused a second pin by touch");
|
|
299
|
-
checkFocus(data(await call(app,
|
|
524
|
+
checkFocus(data(await call(app, conn, "get_current_view")).focus, second, "get_current_view after a second pin by touch");
|
|
300
525
|
expect(await mobile.unpinByTouch(grantId), "the page offers no way to remove the pin with a finger");
|
|
301
|
-
const after = await call(app,
|
|
526
|
+
const after = await call(app, conn, "get_current_view");
|
|
302
527
|
expect(data(after).focus === null, "the pin is still there after the person removed it by touch");
|
|
303
528
|
}),
|
|
304
529
|
];
|
|
@@ -308,7 +533,7 @@ async function arrive(app, conn, client) {
|
|
|
308
533
|
const params = conn.path === "direct"
|
|
309
534
|
? { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: client, version: "1.0.0" } }
|
|
310
535
|
: { 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
|
|
536
|
+
await result(app, conn, "initialize", params);
|
|
312
537
|
}
|
|
313
538
|
/** The offer on a result, where the model sees it: in the structured result and the same in the text. */
|
|
314
539
|
function offerOf(r) {
|
|
@@ -335,13 +560,13 @@ function linkChecks(app, link) {
|
|
|
335
560
|
await arrive(app, direct, name);
|
|
336
561
|
const dock = await connect("dock");
|
|
337
562
|
await arrive(app, dock, name);
|
|
338
|
-
const offered = await call(app, dock
|
|
563
|
+
const offered = await call(app, dock, app.probe.tool, app.probe.args);
|
|
339
564
|
expect(!offered.isError, `${app.probe.tool} failed`);
|
|
340
565
|
const offer = offerOf(offered);
|
|
341
566
|
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
567
|
return { name, direct, dock, offer };
|
|
343
568
|
};
|
|
344
|
-
const redeem = (conn, code) => call(app, conn
|
|
569
|
+
const redeem = (conn, code) => call(app, conn, APC_LINK_TOOL, { code });
|
|
345
570
|
const refused = (r, reason, where) => {
|
|
346
571
|
expect(r.isError === true, `${where}: refused, as a tool error (${JSON.stringify(r).slice(0, 200)})`);
|
|
347
572
|
const d = text(r);
|
|
@@ -349,13 +574,13 @@ function linkChecks(app, link) {
|
|
|
349
574
|
};
|
|
350
575
|
const linkedEntry = async (a, b) => (await link.agents()).find((e) => e.grants.includes(a) && e.grants.includes(b));
|
|
351
576
|
const works = async (conn, where) => {
|
|
352
|
-
const r = await call(app, conn
|
|
577
|
+
const r = await call(app, conn, "get_current_view");
|
|
353
578
|
expect(!r.isError && "view" in data(r), `${where} still reads the app`);
|
|
354
579
|
};
|
|
355
580
|
return [
|
|
356
581
|
check("the same client on the other path is offered a code once per session, and the code links them", async () => {
|
|
357
582
|
const { direct, dock, offer } = await pair();
|
|
358
|
-
const again = await call(app, dock
|
|
583
|
+
const again = await call(app, dock, app.probe.tool, app.probe.args);
|
|
359
584
|
expect(offerOf(again) === undefined, "asked at most once per MCP session");
|
|
360
585
|
const done = await redeem(direct, offer.code);
|
|
361
586
|
expect(!done.isError && data(done).linked === true, `the other connection redeems it: ${JSON.stringify(done).slice(0, 200)}`);
|
|
@@ -387,22 +612,22 @@ function linkChecks(app, link) {
|
|
|
387
612
|
await sleep((APC_LINK_TTL_S + 1) * 1000);
|
|
388
613
|
refused(await redeem(direct, offer.code), "expired", "after expires_in");
|
|
389
614
|
expect(!(await linkedEntry(direct.grantId, dock.grantId)), "it stays two agents");
|
|
390
|
-
expect(offerOf(await call(app, dock
|
|
615
|
+
expect(offerOf(await call(app, dock, app.probe.tool, app.probe.args)) === undefined, "not asked again in that session");
|
|
391
616
|
}),
|
|
392
617
|
check("a connection of another client is offered nothing", async () => {
|
|
393
618
|
const direct = await connect("direct");
|
|
394
619
|
await arrive(app, direct, client());
|
|
395
620
|
const dock = await connect("dock");
|
|
396
621
|
await arrive(app, dock, client());
|
|
397
|
-
expect(offerOf(await call(app, dock
|
|
622
|
+
expect(offerOf(await call(app, dock, app.probe.tool, app.probe.args)) === undefined, "no offer between two clients");
|
|
398
623
|
}),
|
|
399
624
|
check("revoke on a linked agent cuts both grants", async () => {
|
|
400
625
|
const { direct, dock, offer } = await pair();
|
|
401
626
|
expect(!(await redeem(direct, offer.code)).isError, "linked");
|
|
402
627
|
expect(await app.page.revoke(dock.grantId), "the app refused to revoke");
|
|
403
628
|
for (const [conn, where] of [[direct, "Direct"], [dock, "Dock"]]) {
|
|
404
|
-
const { status, body } = await send(app, conn
|
|
405
|
-
|
|
629
|
+
const { status, body, headers } = await send(app, conn, "tools/call", { name: "get_current_view", arguments: {} });
|
|
630
|
+
checkRevoked(conn, status, body, `get_current_view on the ${where} grant`, headers);
|
|
406
631
|
}
|
|
407
632
|
}),
|
|
408
633
|
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.
|
|
3
|
+
"version": "0.9.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.9.0"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {},
|
|
51
51
|
"types": "./dist/index.d.ts",
|