@tineira/apc-conformance 0.0.0-stage → 0.7.1
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/LICENSE +21 -0
- package/README.md +35 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +129 -0
- package/dist/http.d.ts +85 -0
- package/dist/http.js +458 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +434 -0
- package/dist/shared.d.ts +56 -0
- package/dist/shared.js +144 -0
- package/dist/workers.d.ts +27 -0
- package/dist/workers.js +95 -0
- package/package.json +52 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tineira
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tineira/apc-conformance
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Checks an app against the Agent Pointed Context (APC) standard: live over HTTP from a terminal (`apc-conformance`), or the whole suite in your own tests with a small adapter for your page. Node 22.15+. Docs: https://apc.tineira.com/docs
|
|
4
|
+
|
|
5
|
+
## A live app, from a terminal
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx @tineira/apc-conformance@next https://your.app
|
|
9
|
+
npx @tineira/apc-conformance@next https://your.app --grant https://your.app/mcp/<…>/<secret> --revoked <the MCP URL of a grant you revoked> --json
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
It reads `https://your.app/.well-known/apc.json` (its shape, its origin, its profiles, its standard) and, with `--grant` (the MCP URL of an agent connected directly), talks to the grant as an agent does: `initialize`, `tools/list` (the core tools and their arguments), `get_current_view` (the view and focus in the core's format, `attention` and `then` the same in the structured result and the text), `wait_for_focus({ timeout_s: 1 })` (it times out at 1 s, or ends at once when no page is open, with a note and no `then`) and an unknown grant answering 404. With `--revoked` it checks the revoked answer (403 `{ error: "revoked", revoked_by, revoked_at }` to every method, POST, GET and DELETE).
|
|
13
|
+
|
|
14
|
+
Each check prints `PASS`, `FAIL` or `SKIP`; the exit status is 1 when one failed (2 on a usage error), and `--json` prints the report as JSON. It is safe to point at any URL: https only (`--allow-http` lets `http:` through for a dev server on the hosts `localhost`, `127.0.0.1` and `[::1]` only), grant URLs on the app's origin only, no redirect followed, a size cap on every answer and a timeout on every request (`--timeout <s>`, default 15; `wait_for_focus` is cut at 5 s). A grant URL is a secret: the report never shows it, not even inside an answer that echoes it, and an invalid `--grant` is refused without being printed.
|
|
15
|
+
|
|
16
|
+
What needs the person's page cannot run over HTTP, and is reported as `SKIP`: a pin riding as `attention` until it is read, `wait_for_focus` returning a pin dropped during the wait or ending when the page closes, `then` only while the page is open, presence, Revoke pressed in the page (and a call running when it is), the mobile checks and `apc_link`. Run those in process, below.
|
|
17
|
+
|
|
18
|
+
## The whole suite, in your tests
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { test } from "node:test";
|
|
22
|
+
import { apcConformance } from "@tineira/apc-conformance";
|
|
23
|
+
|
|
24
|
+
for (const check of apcConformance({
|
|
25
|
+
name: "My app",
|
|
26
|
+
profile: "direct",
|
|
27
|
+
connect: async () => ({ mcpUrl, grantId }), // a new agent with full access, no pin yet
|
|
28
|
+
targets: [firstTarget, secondTarget], // two things the person can point at
|
|
29
|
+
probe: { tool: "list_things", args: {} }, // one of your read tools
|
|
30
|
+
page: { setOpen, showView, pin, takePresence, revoke }, // your page, as your server sees it
|
|
31
|
+
})) test(check.name, check.run);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`runConformance(adapter)` runs them all and returns a report; `httpConformance` / `runHttpConformance` are the HTTP checks as a library. `@tineira/apc-conformance/workers` runs a Cloudflare Durable Object on Node's SQLite with fake pages, so the suite can run against a Worker with no network.
|
|
35
|
+
|
|
36
|
+
Source and changelog: https://github.com/tineira/agent-pointed-context. MIT.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `apc-conformance`: the HTTP conformance checks (`http.ts`) against a live app, from a terminal.
|
|
4
|
+
*
|
|
5
|
+
* npx @tineira/apc-conformance@next https://pulse.example --grant https://pulse.example/mcp/<sid>/<secret>
|
|
6
|
+
*
|
|
7
|
+
* PASS, FAIL or SKIP per check; exit 1 on any FAIL, 2 on a usage error; `--json` for the report as JSON.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
import { HttpConformanceUsageError, redactSecrets, runHttpConformance } from "./http.js";
|
|
12
|
+
const HELP = `apc-conformance: checks a live APC app over HTTP (https://apc.tineira.com/docs)
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
apc-conformance <app-origin> [--grant <mcp-url>] [--revoked <mcp-url>] [--json] [--allow-http] [--timeout <s>]
|
|
16
|
+
|
|
17
|
+
<app-origin> The app's origin, e.g. https://pulse.apc.tineira.com
|
|
18
|
+
--grant <mcp-url> The MCP URL of a live agent connected directly (Direct profile). Runs the MCP checks:
|
|
19
|
+
initialize, tools/list, get_current_view, wait_for_focus, unknown grant 404.
|
|
20
|
+
--revoked <mcp-url> The MCP URL of a grant you revoked in the app: checks the revoked answer.
|
|
21
|
+
--json Print the report as JSON.
|
|
22
|
+
--allow-http Allow http:, for a dev server on the host localhost, 127.0.0.1 or [::1] only (not *.localhost).
|
|
23
|
+
--timeout <s> Seconds per request (default 15).
|
|
24
|
+
--version, --help
|
|
25
|
+
|
|
26
|
+
What it reads: {origin}/.well-known/apc.json and the MCP URLs you give, on that origin only. It never follows
|
|
27
|
+
a redirect, caps every answer's size and times out every request. A grant URL is a secret: the report shows it
|
|
28
|
+
with its last segment replaced by <secret>.
|
|
29
|
+
|
|
30
|
+
The checks that need the person's page (a pin dropped, the page opened or closed, presence, Revoke pressed, a
|
|
31
|
+
phone's touch, apc_link) are reported as SKIP: run apcConformance(adapter) from @tineira/apc-conformance in the app's
|
|
32
|
+
own tests for those.
|
|
33
|
+
|
|
34
|
+
Exit status: 0 when nothing failed, 1 when a check failed, 2 on a usage error.`;
|
|
35
|
+
function version() {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "unknown";
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return "unknown";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function format(report) {
|
|
44
|
+
const lines = [`APC conformance over HTTP: ${report.origin}`];
|
|
45
|
+
if (report.app)
|
|
46
|
+
lines.push(`${report.app.name}, standard ${report.app.standard}, profiles ${report.app.profiles.join(", ")} (checks know up to ${report.checks_standard})`);
|
|
47
|
+
lines.push("");
|
|
48
|
+
for (const r of report.results) {
|
|
49
|
+
const tag = r.status === "pass" ? "PASS" : r.status === "fail" ? "FAIL" : "SKIP";
|
|
50
|
+
lines.push(`${tag} ${r.name}${r.status === "pass" && r.ms !== undefined ? ` (${r.ms} ms)` : ""}`);
|
|
51
|
+
if (r.detail)
|
|
52
|
+
lines.push(` ${r.detail}`);
|
|
53
|
+
if (r.note)
|
|
54
|
+
lines.push(` note: ${r.note}`);
|
|
55
|
+
}
|
|
56
|
+
lines.push("", `${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped`);
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
59
|
+
async function main(argv) {
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = parseArgs({
|
|
63
|
+
args: argv,
|
|
64
|
+
allowPositionals: true,
|
|
65
|
+
options: {
|
|
66
|
+
grant: { type: "string" },
|
|
67
|
+
revoked: { type: "string" },
|
|
68
|
+
json: { type: "boolean", default: false },
|
|
69
|
+
"allow-http": { type: "boolean", default: false },
|
|
70
|
+
timeout: { type: "string" },
|
|
71
|
+
help: { type: "boolean", short: "h", default: false },
|
|
72
|
+
version: { type: "boolean", short: "v", default: false },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
process.stderr.write(`${redactSecrets(message, argv.filter((a) => a.includes("://")))}\n\n${HELP}\n`);
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
const { values, positionals } = parsed;
|
|
82
|
+
if (values.help) {
|
|
83
|
+
process.stdout.write(`${HELP}\n`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (values.version) {
|
|
87
|
+
process.stdout.write(`${version()}\n`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
if (positionals.length !== 1) {
|
|
91
|
+
process.stderr.write(`Give exactly one app origin.\n\n${HELP}\n`);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
let timeoutMs;
|
|
95
|
+
if (values.timeout !== undefined) {
|
|
96
|
+
const s = Number(values.timeout);
|
|
97
|
+
if (!Number.isFinite(s) || s <= 0 || s > 300) {
|
|
98
|
+
process.stderr.write("--timeout is a number of seconds, from more than 0 to 300\n");
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
timeoutMs = Math.round(s * 1000);
|
|
102
|
+
}
|
|
103
|
+
let report;
|
|
104
|
+
try {
|
|
105
|
+
report = await runHttpConformance({
|
|
106
|
+
origin: positionals[0],
|
|
107
|
+
...(values.grant !== undefined ? { grant: values.grant } : {}),
|
|
108
|
+
...(values.revoked !== undefined ? { revoked: values.revoked } : {}),
|
|
109
|
+
allowHttp: values["allow-http"],
|
|
110
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof HttpConformanceUsageError) {
|
|
115
|
+
process.stderr.write(`${error.message}\n`);
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
process.stdout.write(`${values.json ? JSON.stringify(report, null, 2) : format(report)}\n`);
|
|
121
|
+
return report.failed > 0 ? 1 : 0;
|
|
122
|
+
}
|
|
123
|
+
main(process.argv.slice(2)).then((code) => {
|
|
124
|
+
process.exitCode = code;
|
|
125
|
+
}, (error) => {
|
|
126
|
+
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
|
127
|
+
process.stderr.write(`apc-conformance: ${redactSecrets(message, process.argv.slice(2).filter((a) => a.includes("://")))}\n`);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
});
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export interface HttpConformanceOptions {
|
|
2
|
+
/** The app's origin, e.g. `https://pulse.example` (a trailing slash is fine; no path). */
|
|
3
|
+
origin: string;
|
|
4
|
+
/** The MCP URL of a live grant (an agent connected directly). Without it the MCP checks are skipped. */
|
|
5
|
+
grant?: string;
|
|
6
|
+
/** The MCP URL of a grant the person revoked in the app. Without it the revoked-answer check is skipped. */
|
|
7
|
+
revoked?: string;
|
|
8
|
+
/** Lets `http:` through, on a loopback host only (localhost, 127.0.0.1, [::1]): a dev server. */
|
|
9
|
+
allowHttp?: boolean;
|
|
10
|
+
/** Per request, in ms (default 15000). */
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
/** The global fetch when left out. Called with `redirect: "manual"`; a test can route it in process. */
|
|
13
|
+
fetch?: (request: Request) => Promise<Response>;
|
|
14
|
+
}
|
|
15
|
+
export type HttpCheckStatus = "pass" | "fail" | "skip";
|
|
16
|
+
export interface HttpCheckResult {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
status: HttpCheckStatus;
|
|
20
|
+
/** Why it failed, or why it was skipped. */
|
|
21
|
+
detail?: string;
|
|
22
|
+
/** On a pass: what it could not check (e.g. a result with no structuredContent, read from its text only). */
|
|
23
|
+
note?: string;
|
|
24
|
+
ms?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface HttpConformanceReport {
|
|
27
|
+
origin: string;
|
|
28
|
+
/** What apc.json says, when it could be read. */
|
|
29
|
+
app?: {
|
|
30
|
+
name: string;
|
|
31
|
+
standard: string;
|
|
32
|
+
profiles: string[];
|
|
33
|
+
};
|
|
34
|
+
/** The standard this version of the checks knows. */
|
|
35
|
+
checks_standard: string;
|
|
36
|
+
passed: number;
|
|
37
|
+
failed: number;
|
|
38
|
+
skipped: number;
|
|
39
|
+
results: HttpCheckResult[];
|
|
40
|
+
}
|
|
41
|
+
export interface HttpCheck {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
/**
|
|
45
|
+
* Resolves to a reason string when the check does not apply (skipped), to `{ note }` when it passed with a
|
|
46
|
+
* caveat; throws `ConformanceError` when it fails. Messages never carry a grant's secret.
|
|
47
|
+
*/
|
|
48
|
+
run(): Promise<void | string | {
|
|
49
|
+
note: string;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The checks of the suite that need the person's page (an adapter in process), not reachable over HTTP:
|
|
54
|
+
* `apcConformance(adapter)` runs them in the app's own tests.
|
|
55
|
+
*/
|
|
56
|
+
export declare const IN_PROCESS_ONLY: ReadonlyArray<{
|
|
57
|
+
name: string;
|
|
58
|
+
why: string;
|
|
59
|
+
}>;
|
|
60
|
+
/** Why a URL the person gave cannot be used; thrown before any request is sent. */
|
|
61
|
+
export declare class HttpConformanceUsageError extends Error {
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A URL the checks may call: https (or http on a loopback host with `allowHttp`), no credentials. The input is
|
|
65
|
+
* never echoed when it does not parse: a grant URL holds a secret.
|
|
66
|
+
*/
|
|
67
|
+
export declare function safeUrl(raw: string, what: string, allowHttp: boolean): URL;
|
|
68
|
+
/** The options, checked: the origin as an origin, and every grant URL on it. Throws `HttpConformanceUsageError`. */
|
|
69
|
+
export declare function checkOptions(options: HttpConformanceOptions): {
|
|
70
|
+
origin: string;
|
|
71
|
+
grant?: URL;
|
|
72
|
+
revoked?: URL;
|
|
73
|
+
};
|
|
74
|
+
/** A grant's MCP URL as it may be shown: the last path segment (its secret) replaced. */
|
|
75
|
+
export declare function redactGrantUrl(url: string): string;
|
|
76
|
+
/**
|
|
77
|
+
* Any text with every grant's secret (the last path segment of each URL in `urls`) replaced by `<secret>`,
|
|
78
|
+
* wherever it appears (in a URL, a quoted body, an echoed error), in any letter case, as sent, decoded or
|
|
79
|
+
* percent-encoded.
|
|
80
|
+
*/
|
|
81
|
+
export declare function redactSecrets(text: string, urls: ReadonlyArray<string | URL | undefined>): string;
|
|
82
|
+
/** The HTTP checks against a live app, in order. Throws `HttpConformanceUsageError` when the options name an unsafe URL. */
|
|
83
|
+
export declare function httpConformance(options: HttpConformanceOptions): HttpCheck[];
|
|
84
|
+
/** Every HTTP check, one after another, then the in-process ones as skipped: the report. */
|
|
85
|
+
export declare function runHttpConformance(options: HttpConformanceOptions): Promise<HttpConformanceReport>;
|