@datalayer/agent-runtimes 1.5.8 → 1.5.9
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-embed/datalayer-app-main.js +3 -3
- package/lib/apps/apps/appspec.js +40 -1
- package/lib/protocols/mcp/client.d.ts +73 -0
- package/lib/protocols/mcp/client.js +136 -0
- package/lib/protocols/mcp/index.d.ts +1 -0
- package/lib/protocols/mcp/index.js +3 -1
- package/lib/protocols/web-bot-auth/tap.d.ts +56 -0
- package/lib/protocols/web-bot-auth/tap.js +185 -0
- package/lib/specs/apps.js +22 -14
- package/lib/specs/appspecSchema.js +105 -3
- package/lib/specs/protocols.js +5 -5
- package/lib/types/agentspecs.d.ts +34 -2
- package/package.json +3 -1
package/lib/apps/apps/appspec.js
CHANGED
|
@@ -304,12 +304,47 @@ function parsePap(data) {
|
|
|
304
304
|
operations: records(company.operations).map(operation => ({
|
|
305
305
|
action: text(operation.action),
|
|
306
306
|
tool: text(operation.tool),
|
|
307
|
+
...(isData(operation.allows)
|
|
308
|
+
? { allows: parsePapStanding(operation.allows) }
|
|
309
|
+
: {}),
|
|
307
310
|
summary: text(operation.summary),
|
|
308
311
|
userApprovalRequired: flag(operation.user_approval_required, true),
|
|
309
312
|
})),
|
|
310
313
|
},
|
|
311
314
|
};
|
|
312
315
|
}
|
|
316
|
+
/** A standing permission an operation gives (PAP_DATALAYER R-07), as agentspecs reads it. */
|
|
317
|
+
function parsePapStanding(data) {
|
|
318
|
+
return {
|
|
319
|
+
connection: text(data.connection),
|
|
320
|
+
action: oneOf(data.action, ['write', 'buy'], 'buy'),
|
|
321
|
+
limits: records(data.limits).map(limit => ({
|
|
322
|
+
path: text(limit.path),
|
|
323
|
+
...(typeof limit.maximum === 'string'
|
|
324
|
+
? { maximum: text(limit.maximum) }
|
|
325
|
+
: {}),
|
|
326
|
+
...(typeof limit.allowed === 'string'
|
|
327
|
+
? { allowed: text(limit.allowed) }
|
|
328
|
+
: {}),
|
|
329
|
+
})),
|
|
330
|
+
uses: text(data.uses),
|
|
331
|
+
days: text(data.days),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** A standing permission as its document says it: every field, none has a default. */
|
|
335
|
+
function dumpPapStanding(standing) {
|
|
336
|
+
return {
|
|
337
|
+
connection: standing.connection,
|
|
338
|
+
action: standing.action,
|
|
339
|
+
limits: standing.limits.map(limit => ({
|
|
340
|
+
path: limit.path,
|
|
341
|
+
...(limit.maximum !== undefined ? { maximum: limit.maximum } : {}),
|
|
342
|
+
...(limit.allowed !== undefined ? { allowed: limit.allowed } : {}),
|
|
343
|
+
})),
|
|
344
|
+
uses: standing.uses,
|
|
345
|
+
days: standing.days,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
313
348
|
/** Personal Agent Protocol as its document says it, nothing at its default. */
|
|
314
349
|
function dumpPap(pap) {
|
|
315
350
|
if (!pap)
|
|
@@ -337,7 +372,11 @@ function dumpPap(pap) {
|
|
|
337
372
|
.list('registered_agents', pap.company.registeredAgents)
|
|
338
373
|
.list('operations', pap.company.operations.map(operation => new Writer()
|
|
339
374
|
.text('action', operation.action, '\u0000')
|
|
340
|
-
|
|
375
|
+
// An operation that allows a standing permission calls no tool (R-07).
|
|
376
|
+
.text('tool', operation.tool, operation.allows ? '' : '\u0000')
|
|
377
|
+
.present('allows', operation.allows
|
|
378
|
+
? dumpPapStanding(operation.allows)
|
|
379
|
+
: undefined)
|
|
341
380
|
.text('summary', operation.summary, '\u0000')
|
|
342
381
|
.value('user_approval_required', operation.userApprovalRequired, true).data)).data).data;
|
|
343
382
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
|
+
import type { CallToolResult, GetPromptResult, ReadResourceResult, Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
/** A listed tool as a client reads it. */
|
|
4
|
+
export interface McpToolReading {
|
|
5
|
+
name: string;
|
|
6
|
+
title: string | null;
|
|
7
|
+
description: string | null;
|
|
8
|
+
/** Its parameters' names, sorted. */
|
|
9
|
+
parameters: string[];
|
|
10
|
+
/** Those it requires, sorted. */
|
|
11
|
+
required: string[];
|
|
12
|
+
/** Whether it says an output schema (answers structured content). */
|
|
13
|
+
structured: boolean;
|
|
14
|
+
readOnly: boolean;
|
|
15
|
+
destructive: boolean;
|
|
16
|
+
idempotent: boolean;
|
|
17
|
+
openWorld: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** A tool's answer as a client reads it. */
|
|
20
|
+
export interface McpCallReading {
|
|
21
|
+
isError: boolean;
|
|
22
|
+
/** Its text parts, joined by a newline. */
|
|
23
|
+
text: string;
|
|
24
|
+
structured: Record<string, unknown> | null;
|
|
25
|
+
}
|
|
26
|
+
/** A text resource's contents as a client reads them. */
|
|
27
|
+
export interface McpResourceReading {
|
|
28
|
+
uri: string;
|
|
29
|
+
mimeType: string | null;
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
/** A prompt's message as a client reads it. */
|
|
33
|
+
export interface McpPromptReading {
|
|
34
|
+
role: 'user' | 'assistant';
|
|
35
|
+
text: string;
|
|
36
|
+
}
|
|
37
|
+
/** A listed tool: its parameters, whether it answers structured, its hints. */
|
|
38
|
+
export declare function toolReading(tool: Tool): McpToolReading;
|
|
39
|
+
/** A tool's answer: refused or not, its text parts joined, its structured content. */
|
|
40
|
+
export declare function callReading(result: CallToolResult): McpCallReading;
|
|
41
|
+
/** A resource's text contents; a binary one is refused, never decoded here. */
|
|
42
|
+
export declare function resourceReading(result: ReadResourceResult): McpResourceReading[];
|
|
43
|
+
/** A prompt's messages as text; a message of another kind is refused. */
|
|
44
|
+
export declare function promptReading(result: GetPromptResult): McpPromptReading[];
|
|
45
|
+
/** Where an `McpClient` connects: a Streamable HTTP endpoint, or any transport. */
|
|
46
|
+
export type McpServerAddress = {
|
|
47
|
+
url: string;
|
|
48
|
+
/** Headers sent with every request (a bearer, for one). */
|
|
49
|
+
headers?: Record<string, string>;
|
|
50
|
+
} | {
|
|
51
|
+
transport: Transport;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* An MCP client: connects, lists and calls tools, reads resources, gets
|
|
55
|
+
* prompts, and answers each as the readings above. The SDK's `Client` does
|
|
56
|
+
* the protocol (the initialize handshake, the version negotiated, a
|
|
57
|
+
* structured answer checked against the tool's output schema).
|
|
58
|
+
*/
|
|
59
|
+
export declare class McpClient {
|
|
60
|
+
private readonly client;
|
|
61
|
+
private readonly agreed;
|
|
62
|
+
private constructor();
|
|
63
|
+
/** Connected to `server`, said to it as `name` at `version`. */
|
|
64
|
+
static connect(server: McpServerAddress, name?: string, version?: string): Promise<McpClient>;
|
|
65
|
+
/** The protocol version agreed with the server. */
|
|
66
|
+
get protocolVersion(): string;
|
|
67
|
+
/** Every tool the server lists, through its pages. */
|
|
68
|
+
listTools(): Promise<McpToolReading[]>;
|
|
69
|
+
callTool(name: string, args: Record<string, unknown>): Promise<McpCallReading>;
|
|
70
|
+
readResource(uri: string): Promise<McpResourceReading[]>;
|
|
71
|
+
getPrompt(name: string, args: Record<string, string>): Promise<McpPromptReading[]>;
|
|
72
|
+
close(): Promise<void>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* An MCP client in TypeScript, on the official `@modelcontextprotocol/sdk`
|
|
7
|
+
* (AGENT_PROTOCOLS PR-51, decided 2026-10-13), and what it reads from a
|
|
8
|
+
* server's answers — the twin of `agent_runtimes.protocols.mcp.reading`.
|
|
9
|
+
*
|
|
10
|
+
* The readings are the plain shape both languages agree on, held to
|
|
11
|
+
* `agent_runtimes/protocols/mcp/fixtures/session.json` by the pytest and the
|
|
12
|
+
* vitest. A tool's missing annotations are read with the specification's
|
|
13
|
+
* defaults (`readOnlyHint` false, `destructiveHint` true, `idempotentHint`
|
|
14
|
+
* false, `openWorldHint` true), so a server that says nothing is never read
|
|
15
|
+
* as harmless; `destructiveHint` means something only when the tool is not
|
|
16
|
+
* read-only, so a read-only tool is never destructive.
|
|
17
|
+
*
|
|
18
|
+
* @module protocols/mcp/client
|
|
19
|
+
*/
|
|
20
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
21
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
22
|
+
const sorted = (values) => [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
23
|
+
/** A listed tool: its parameters, whether it answers structured, its hints. */
|
|
24
|
+
export function toolReading(tool) {
|
|
25
|
+
const hints = tool.annotations;
|
|
26
|
+
const readOnly = hints?.readOnlyHint === true;
|
|
27
|
+
return {
|
|
28
|
+
name: tool.name,
|
|
29
|
+
title: tool.title ?? null,
|
|
30
|
+
description: tool.description ?? null,
|
|
31
|
+
parameters: sorted(Object.keys(tool.inputSchema.properties ?? {})),
|
|
32
|
+
required: sorted(tool.inputSchema.required ?? []),
|
|
33
|
+
structured: tool.outputSchema !== undefined,
|
|
34
|
+
readOnly,
|
|
35
|
+
destructive: !readOnly && hints?.destructiveHint !== false,
|
|
36
|
+
idempotent: hints?.idempotentHint === true,
|
|
37
|
+
openWorld: hints?.openWorldHint !== false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** A tool's answer: refused or not, its text parts joined, its structured content. */
|
|
41
|
+
export function callReading(result) {
|
|
42
|
+
return {
|
|
43
|
+
isError: result.isError === true,
|
|
44
|
+
text: result.content
|
|
45
|
+
.flatMap(part => (part.type === 'text' ? [part.text] : []))
|
|
46
|
+
.join('\n'),
|
|
47
|
+
structured: result.structuredContent ?? null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** A resource's text contents; a binary one is refused, never decoded here. */
|
|
51
|
+
export function resourceReading(result) {
|
|
52
|
+
return result.contents.map(part => {
|
|
53
|
+
if (!('text' in part) || typeof part.text !== 'string') {
|
|
54
|
+
throw new Error(`${part.uri} is not text.`);
|
|
55
|
+
}
|
|
56
|
+
return { uri: part.uri, mimeType: part.mimeType ?? null, text: part.text };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** A prompt's messages as text; a message of another kind is refused. */
|
|
60
|
+
export function promptReading(result) {
|
|
61
|
+
return result.messages.map(message => {
|
|
62
|
+
if (message.content.type !== 'text') {
|
|
63
|
+
throw new Error(`A ${message.role} message is not text.`);
|
|
64
|
+
}
|
|
65
|
+
return { role: message.role, text: message.content.text };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* An MCP client: connects, lists and calls tools, reads resources, gets
|
|
70
|
+
* prompts, and answers each as the readings above. The SDK's `Client` does
|
|
71
|
+
* the protocol (the initialize handshake, the version negotiated, a
|
|
72
|
+
* structured answer checked against the tool's output schema).
|
|
73
|
+
*/
|
|
74
|
+
export class McpClient {
|
|
75
|
+
client;
|
|
76
|
+
agreed;
|
|
77
|
+
constructor(client, agreed) {
|
|
78
|
+
this.client = client;
|
|
79
|
+
this.agreed = agreed;
|
|
80
|
+
}
|
|
81
|
+
/** Connected to `server`, said to it as `name` at `version`. */
|
|
82
|
+
static async connect(server, name = 'agent-runtimes', version = '1.0.0') {
|
|
83
|
+
const transport = 'transport' in server
|
|
84
|
+
? server.transport
|
|
85
|
+
: new StreamableHTTPClientTransport(new URL(server.url), {
|
|
86
|
+
requestInit: server.headers
|
|
87
|
+
? { headers: server.headers }
|
|
88
|
+
: undefined,
|
|
89
|
+
});
|
|
90
|
+
// The SDK hands the version agreed at initialize to the transport alone.
|
|
91
|
+
let agreed;
|
|
92
|
+
const setProtocolVersion = transport.setProtocolVersion?.bind(transport);
|
|
93
|
+
transport.setProtocolVersion = (protocolVersion) => {
|
|
94
|
+
agreed = protocolVersion;
|
|
95
|
+
setProtocolVersion?.(protocolVersion);
|
|
96
|
+
};
|
|
97
|
+
const client = new Client({ name, version });
|
|
98
|
+
await client.connect(transport);
|
|
99
|
+
return new McpClient(client, () => agreed);
|
|
100
|
+
}
|
|
101
|
+
/** The protocol version agreed with the server. */
|
|
102
|
+
get protocolVersion() {
|
|
103
|
+
const version = this.agreed();
|
|
104
|
+
if (!version) {
|
|
105
|
+
throw new Error('The MCP client is not connected.');
|
|
106
|
+
}
|
|
107
|
+
return version;
|
|
108
|
+
}
|
|
109
|
+
/** Every tool the server lists, through its pages. */
|
|
110
|
+
async listTools() {
|
|
111
|
+
const tools = [];
|
|
112
|
+
let cursor;
|
|
113
|
+
do {
|
|
114
|
+
const page = await this.client.listTools(cursor ? { cursor } : {});
|
|
115
|
+
tools.push(...page.tools.map(toolReading));
|
|
116
|
+
cursor = page.nextCursor;
|
|
117
|
+
} while (cursor);
|
|
118
|
+
return tools;
|
|
119
|
+
}
|
|
120
|
+
async callTool(name, args) {
|
|
121
|
+
const result = await this.client.callTool({ name, arguments: args });
|
|
122
|
+
if (!Array.isArray(result.content)) {
|
|
123
|
+
throw new Error(`${name} answered in the form before MCP 2024-11-05.`);
|
|
124
|
+
}
|
|
125
|
+
return callReading(result);
|
|
126
|
+
}
|
|
127
|
+
async readResource(uri) {
|
|
128
|
+
return resourceReading(await this.client.readResource({ uri }));
|
|
129
|
+
}
|
|
130
|
+
async getPrompt(name, args) {
|
|
131
|
+
return promptReading(await this.client.getPrompt({ name, arguments: args }));
|
|
132
|
+
}
|
|
133
|
+
async close() {
|
|
134
|
+
await this.client.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -2,3 +2,4 @@ export declare const MCP_PLUGIN = "agent-runtimes.protocols.mcp";
|
|
|
2
2
|
/** The MCP specification version of the `@modelcontextprotocol/sdk` installed (1.32: `LATEST_PROTOCOL_VERSION`); Python's `mcp` 2.1 speaks 2026-07-28. */
|
|
3
3
|
export declare const MCP_SPECIFICATION_VERSION = "2025-11-25";
|
|
4
4
|
export declare const McpPlugin: import("@datalayer/reactor").ReactorPlugin<Record<string, never>, unknown, unknown>;
|
|
5
|
+
export * from './client';
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* declares no point yet: MCP's optional capabilities and extensions become
|
|
8
8
|
* `mcp.extension.<name>` points when the MCP client and server read them
|
|
9
9
|
* from contributions (AGENT_PROTOCOLS PR-01). The twin of
|
|
10
|
-
* `agent_runtimes.protocols.mcp`.
|
|
10
|
+
* `agent_runtimes.protocols.mcp`. Its client, on the official
|
|
11
|
+
* `@modelcontextprotocol/sdk`, is `./client` (PR-51).
|
|
11
12
|
*
|
|
12
13
|
* @module protocols/mcp
|
|
13
14
|
*/
|
|
@@ -36,3 +37,4 @@ export const McpPlugin = definePlugin({
|
|
|
36
37
|
}, { id: 'mcp', order: DEFAULT_ORDER }),
|
|
37
38
|
],
|
|
38
39
|
});
|
|
40
|
+
export * from './client';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type HttpSigner, type NonceStore } from './index';
|
|
2
|
+
/** Draft until Visa confirms it. */
|
|
3
|
+
export declare const TAP_STATUS = "draft";
|
|
4
|
+
/** Where the profile was read, 2026-10-13. */
|
|
5
|
+
export declare const TAP_SOURCES: readonly ["https://developer.visa.com/capabilities/trusted-agent-protocol/trusted-agent-protocol-specifications", "https://developer.visa.com/capabilities/trusted-agent-protocol/overview", "https://github.com/visa/trusted-agent-protocol"];
|
|
6
|
+
/** Visa's key directory, as its page says it. */
|
|
7
|
+
export declare const VISA_JWKS = "https://mcp.visa.com/.well-known/jwks";
|
|
8
|
+
export declare const TAP_BROWSE_TAG = "agent-browser-auth";
|
|
9
|
+
export declare const TAP_PAY_TAG = "agent-payer-auth";
|
|
10
|
+
export type TapIntent = 'browse' | 'pay';
|
|
11
|
+
export declare const TAP_TAGS: Record<TapIntent, string>;
|
|
12
|
+
export declare const TAP_REQUIRED_COMPONENTS: readonly ["@authority", "@path"];
|
|
13
|
+
export declare const TAP_REQUIRED_PARAMS: readonly ["created", "keyid", "alg", "expires", "nonce", "tag"];
|
|
14
|
+
/** `alg` as Visa writes it. */
|
|
15
|
+
export declare const TAP_ALG = "Ed25519";
|
|
16
|
+
/** The longest window, and how long a nonce is remembered. */
|
|
17
|
+
export declare const TAP_MAX_WINDOW_SECONDS: number;
|
|
18
|
+
export interface TapSignOptions {
|
|
19
|
+
intent: TapIntent;
|
|
20
|
+
label?: string;
|
|
21
|
+
/** Covered: at least `@authority` and `@path` (the default). */
|
|
22
|
+
components?: string[];
|
|
23
|
+
/** Unix seconds; now by default. */
|
|
24
|
+
created?: number;
|
|
25
|
+
/** Seconds, at most 8 minutes (the default). */
|
|
26
|
+
lifetime?: number;
|
|
27
|
+
nonce?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface TapSignedHeaders {
|
|
30
|
+
'Signature-Input': string;
|
|
31
|
+
Signature: string;
|
|
32
|
+
}
|
|
33
|
+
/** What verifying a TAP signature found; `reason` when it does not hold. */
|
|
34
|
+
export interface TapVerification {
|
|
35
|
+
verified: boolean;
|
|
36
|
+
reason?: string;
|
|
37
|
+
intent?: TapIntent;
|
|
38
|
+
keyid?: string;
|
|
39
|
+
nonce?: string;
|
|
40
|
+
}
|
|
41
|
+
/** Sign one request as a TAP agent browsing or paying, under an Ed25519 key. */
|
|
42
|
+
export declare function signTapRequest(request: {
|
|
43
|
+
method: string;
|
|
44
|
+
url: string;
|
|
45
|
+
}, signer: HttpSigner, options: TapSignOptions): Promise<TapSignedHeaders>;
|
|
46
|
+
/**
|
|
47
|
+
* Verify the TAP signature of one request against Ed25519 keys known by
|
|
48
|
+
* `keyid` (Visa's directory, or a registry's). A request with no TAP tag is
|
|
49
|
+
* no trusted agent's; the merchant decides what to do with it.
|
|
50
|
+
*/
|
|
51
|
+
export declare function verifyTapRequest(request: Request, keys: Map<string, JsonWebKey>, options: {
|
|
52
|
+
nonceStore: NonceStore;
|
|
53
|
+
now?: number;
|
|
54
|
+
}): Promise<TapVerification>;
|
|
55
|
+
/** The `keyid` of an Ed25519 public JWK, as a TAP key list is indexed when it says none. */
|
|
56
|
+
export declare function tapKeys(jwks: JsonWebKey[]): Promise<Map<string, JsonWebKey>>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Visa's Trusted Agent Protocol as a profile over Web Bot Auth
|
|
7
|
+
* (AGENT_PROTOCOLS PR-24) — the twin of
|
|
8
|
+
* `agent_runtimes.protocols.web_bot_auth.tap`.
|
|
9
|
+
*
|
|
10
|
+
* **Draft.** Built on 2026-10-13 from Visa's public *Merchant
|
|
11
|
+
* Specifications* and its reference repository (`TAP_SOURCES`), not
|
|
12
|
+
* confirmed by Visa: the product says it *arriving* until it is. Visa
|
|
13
|
+
* publishes no test vector; `fixtures/tap.json` is one computed in Python
|
|
14
|
+
* and reproduced here byte for byte.
|
|
15
|
+
*
|
|
16
|
+
* The agent-recognition signature: `Signature-Input` and `Signature` alone
|
|
17
|
+
* (Visa names no `Signature-Agent`, so keys are handed in by `keyid`),
|
|
18
|
+
* covering at least `@authority` and `@path`, with `created`, `keyid`,
|
|
19
|
+
* `alg="Ed25519"`, `expires`, `nonce` and `tag` — `agent-browser-auth` to
|
|
20
|
+
* browse, `agent-payer-auth` to pay —, `created` and `expires` at most 8
|
|
21
|
+
* minutes apart, a nonce seen in the last 8 minutes a replay. Signed and
|
|
22
|
+
* verified with Cloudflare's `http-message-sig`, the RFC 9421 library under
|
|
23
|
+
* `web-bot-auth`.
|
|
24
|
+
*
|
|
25
|
+
* @module protocols/web-bot-auth/tap
|
|
26
|
+
*/
|
|
27
|
+
import { createSignature, verifySignature, webcrypto } from 'http-message-sig';
|
|
28
|
+
import { generateNonce, jwkThumbprint, } from './index';
|
|
29
|
+
/** Draft until Visa confirms it. */
|
|
30
|
+
export const TAP_STATUS = 'draft';
|
|
31
|
+
/** Where the profile was read, 2026-10-13. */
|
|
32
|
+
export const TAP_SOURCES = [
|
|
33
|
+
'https://developer.visa.com/capabilities/trusted-agent-protocol/trusted-agent-protocol-specifications',
|
|
34
|
+
'https://developer.visa.com/capabilities/trusted-agent-protocol/overview',
|
|
35
|
+
'https://github.com/visa/trusted-agent-protocol',
|
|
36
|
+
];
|
|
37
|
+
/** Visa's key directory, as its page says it. */
|
|
38
|
+
export const VISA_JWKS = 'https://mcp.visa.com/.well-known/jwks';
|
|
39
|
+
export const TAP_BROWSE_TAG = 'agent-browser-auth';
|
|
40
|
+
export const TAP_PAY_TAG = 'agent-payer-auth';
|
|
41
|
+
export const TAP_TAGS = {
|
|
42
|
+
browse: TAP_BROWSE_TAG,
|
|
43
|
+
pay: TAP_PAY_TAG,
|
|
44
|
+
};
|
|
45
|
+
export const TAP_REQUIRED_COMPONENTS = ['@authority', '@path'];
|
|
46
|
+
export const TAP_REQUIRED_PARAMS = [
|
|
47
|
+
'created',
|
|
48
|
+
'keyid',
|
|
49
|
+
'alg',
|
|
50
|
+
'expires',
|
|
51
|
+
'nonce',
|
|
52
|
+
'tag',
|
|
53
|
+
];
|
|
54
|
+
/** `alg` as Visa writes it. */
|
|
55
|
+
export const TAP_ALG = 'Ed25519';
|
|
56
|
+
/** The longest window, and how long a nonce is remembered. */
|
|
57
|
+
export const TAP_MAX_WINDOW_SECONDS = 8 * 60;
|
|
58
|
+
/** Sign one request as a TAP agent browsing or paying, under an Ed25519 key. */
|
|
59
|
+
export async function signTapRequest(request, signer, options) {
|
|
60
|
+
if (signer.algorithm !== 'ed25519') {
|
|
61
|
+
throw new Error('a TAP signature is Ed25519');
|
|
62
|
+
}
|
|
63
|
+
const components = options.components ?? [...TAP_REQUIRED_COMPONENTS];
|
|
64
|
+
const missing = TAP_REQUIRED_COMPONENTS.filter(name => !components.includes(name));
|
|
65
|
+
if (missing.length) {
|
|
66
|
+
throw new Error(`a TAP signature covers ${missing.join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
const lifetime = options.lifetime ?? TAP_MAX_WINDOW_SECONDS;
|
|
69
|
+
if (!(lifetime > 0 && lifetime <= TAP_MAX_WINDOW_SECONDS)) {
|
|
70
|
+
throw new Error(`a TAP signature lives at most ${TAP_MAX_WINDOW_SECONDS}s (8 minutes)`);
|
|
71
|
+
}
|
|
72
|
+
const created = options.created ?? Math.floor(Date.now() / 1000);
|
|
73
|
+
const fields = await createSignature({
|
|
74
|
+
kind: 'request',
|
|
75
|
+
method: request.method,
|
|
76
|
+
targetUri: request.url,
|
|
77
|
+
fields: [],
|
|
78
|
+
}, {
|
|
79
|
+
label: options.label ?? 'sig2',
|
|
80
|
+
components,
|
|
81
|
+
parameters: {
|
|
82
|
+
created,
|
|
83
|
+
keyid: signer.keyid,
|
|
84
|
+
alg: TAP_ALG,
|
|
85
|
+
expires: created + lifetime,
|
|
86
|
+
nonce: options.nonce ?? generateNonce(),
|
|
87
|
+
tag: TAP_TAGS[options.intent],
|
|
88
|
+
},
|
|
89
|
+
signer: { algorithm: TAP_ALG, sign: data => signer.sign(data) },
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
'Signature-Input': fields.signatureInput,
|
|
93
|
+
Signature: fields.signature,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const TAP_LABEL = /(?:^|,)\s*([a-z*][a-z0-9_.*-]*)=\([^)]*\)[^,]*;tag="(?:agent-browser-auth|agent-payer-auth)"/g;
|
|
97
|
+
/**
|
|
98
|
+
* Verify the TAP signature of one request against Ed25519 keys known by
|
|
99
|
+
* `keyid` (Visa's directory, or a registry's). A request with no TAP tag is
|
|
100
|
+
* no trusted agent's; the merchant decides what to do with it.
|
|
101
|
+
*/
|
|
102
|
+
export async function verifyTapRequest(request, keys, options) {
|
|
103
|
+
const input = request.headers.get('signature-input');
|
|
104
|
+
if (!input || !request.headers.get('signature')) {
|
|
105
|
+
return { verified: false, reason: 'the request is not signed' };
|
|
106
|
+
}
|
|
107
|
+
const labels = [...input.matchAll(TAP_LABEL)].map(match => match[1]);
|
|
108
|
+
if (labels.length === 0) {
|
|
109
|
+
return { verified: false, reason: 'no signature carries a TAP tag' };
|
|
110
|
+
}
|
|
111
|
+
if (labels.length > 1) {
|
|
112
|
+
return {
|
|
113
|
+
verified: false,
|
|
114
|
+
reason: 'more than one signature carries a TAP tag',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const now = options.now ?? Math.floor(Date.now() / 1000);
|
|
118
|
+
let keyid;
|
|
119
|
+
let unknownKey = false;
|
|
120
|
+
try {
|
|
121
|
+
const verified = await verifySignature(request, {
|
|
122
|
+
label: labels[0],
|
|
123
|
+
policy: {
|
|
124
|
+
algorithms: [TAP_ALG],
|
|
125
|
+
requiredComponents: [...TAP_REQUIRED_COMPONENTS],
|
|
126
|
+
requiredParameters: [...TAP_REQUIRED_PARAMS],
|
|
127
|
+
now,
|
|
128
|
+
validate: signature => {
|
|
129
|
+
const { created, expires, tag } = signature.parameters;
|
|
130
|
+
return ((tag === TAP_BROWSE_TAG || tag === TAP_PAY_TAG) &&
|
|
131
|
+
created !== undefined &&
|
|
132
|
+
expires !== undefined &&
|
|
133
|
+
expires - created <= TAP_MAX_WINDOW_SECONDS &&
|
|
134
|
+
now < expires);
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
resolveVerifier: async (candidate) => {
|
|
138
|
+
keyid = candidate.parameters.keyid;
|
|
139
|
+
const jwk = keyid === undefined ? undefined : keys.get(keyid);
|
|
140
|
+
if (!jwk || jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') {
|
|
141
|
+
unknownKey = true;
|
|
142
|
+
throw new Error('the key is not a known Ed25519 key');
|
|
143
|
+
}
|
|
144
|
+
const key = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, false, ['verify']);
|
|
145
|
+
const verifier = webcrypto.verifier(key);
|
|
146
|
+
return {
|
|
147
|
+
algorithm: TAP_ALG,
|
|
148
|
+
verify: (data, signature) => verifier.verify(data, signature),
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const { tag, nonce } = verified.parameters;
|
|
153
|
+
if (nonce === undefined || keyid === undefined) {
|
|
154
|
+
return { verified: false, reason: 'the signature has no nonce', keyid };
|
|
155
|
+
}
|
|
156
|
+
const fresh = await options.nonceStore.checkAndRecord(`tap ${keyid}`, nonce, new Date((now + TAP_MAX_WINDOW_SECONDS) * 1000));
|
|
157
|
+
if (!fresh) {
|
|
158
|
+
return { verified: false, reason: 'the nonce was seen before', keyid };
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
verified: true,
|
|
162
|
+
intent: tag === TAP_PAY_TAG ? 'pay' : 'browse',
|
|
163
|
+
keyid,
|
|
164
|
+
nonce,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return {
|
|
169
|
+
verified: false,
|
|
170
|
+
reason: unknownKey
|
|
171
|
+
? 'the key is not a known Ed25519 key'
|
|
172
|
+
: error.message,
|
|
173
|
+
keyid,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** The `keyid` of an Ed25519 public JWK, as a TAP key list is indexed when it says none. */
|
|
178
|
+
export async function tapKeys(jwks) {
|
|
179
|
+
const keys = new Map();
|
|
180
|
+
for (const jwk of jwks) {
|
|
181
|
+
const kid = jwk.kid;
|
|
182
|
+
keys.set(kid ?? (await jwkThumbprint(jwk)), jwk);
|
|
183
|
+
}
|
|
184
|
+
return keys;
|
|
185
|
+
}
|
package/lib/specs/apps.js
CHANGED
|
@@ -4444,13 +4444,15 @@ export const SHOPPING_DESK_APP_0_0_1 = {
|
|
|
4444
4444
|
},
|
|
4445
4445
|
],
|
|
4446
4446
|
verified: {
|
|
4447
|
-
live: [
|
|
4447
|
+
live: [
|
|
4448
|
+
"Drilled on r1 on 13 October 2026 against the hosted test merchants, as its Preview's runtime reaches the gateway's ucp and acp toolsets: at Example Store (store.examples.datalayer.run) a search, a checkout filled, the exact purchase asked of the person, approved on Waiting for you, bought once and refused a second time, its order read; at Example Outfitters (outfitters.examples.datalayer.run) the same checkout over ACP. Test merchants only: paid with a test instrument.",
|
|
4449
|
+
],
|
|
4448
4450
|
recorded: [
|
|
4449
4451
|
"The purchase is the developers' UCP example (examples/protocols/ucp/shopping.py) against its local test store, Example Store, run in its own tests.",
|
|
4450
4452
|
"The checkout at Example Outfitters is the developers' ACP example (examples/protocols/agentic_commerce/checkout.py), run in its own tests.",
|
|
4451
4453
|
],
|
|
4452
4454
|
unverified: [
|
|
4453
|
-
'
|
|
4455
|
+
"A model's turns in the Studio's Preview were not watched in that drill (no dev server ran).",
|
|
4454
4456
|
],
|
|
4455
4457
|
},
|
|
4456
4458
|
},
|
|
@@ -4480,8 +4482,8 @@ export const SHOPPING_DESK_APP_0_0_1 = {
|
|
|
4480
4482
|
documents: [],
|
|
4481
4483
|
alternatives: [],
|
|
4482
4484
|
},
|
|
4483
|
-
enabled:
|
|
4484
|
-
unavailable_because: '
|
|
4485
|
+
enabled: true,
|
|
4486
|
+
unavailable_because: '',
|
|
4485
4487
|
tags: ['example', 'companies'],
|
|
4486
4488
|
icon: 'apps',
|
|
4487
4489
|
emoji: '🛍️',
|
|
@@ -5281,12 +5283,15 @@ export const TRAVEL_DESK_APP_0_0_1 = {
|
|
|
5281
5283
|
},
|
|
5282
5284
|
],
|
|
5283
5285
|
verified: {
|
|
5284
|
-
live: [
|
|
5286
|
+
live: [
|
|
5287
|
+
"Drilled on r1 on 13 October 2026 against the hosted Example Air (air.examples.datalayer.run), as its Preview's runtime reaches the gateway's pact toolset: its card read, asked as the application alone, the person's sign-in by device code approved at the Brand, then asked for the person and answered from the booking (bookings:read used, its receipt verified).",
|
|
5288
|
+
],
|
|
5285
5289
|
recorded: [
|
|
5286
5290
|
"The journey is the developers' PACT example (examples/protocols/pact/personal_agent.py) against its local Provider, Example Air, run in its own tests (version 1.3.112).",
|
|
5287
5291
|
],
|
|
5288
5292
|
unverified: [
|
|
5289
|
-
'
|
|
5293
|
+
'The hosted Example Air keeps registrations in memory: an application is registered there (POST /example/register, its issuer and keys) again after each restart.',
|
|
5294
|
+
"A model's turns in the Studio's Preview were not watched in that drill (no dev server ran); the step-up to bookings:change was not drilled live.",
|
|
5290
5295
|
],
|
|
5291
5296
|
},
|
|
5292
5297
|
},
|
|
@@ -5316,8 +5321,8 @@ export const TRAVEL_DESK_APP_0_0_1 = {
|
|
|
5316
5321
|
documents: [],
|
|
5317
5322
|
alternatives: [],
|
|
5318
5323
|
},
|
|
5319
|
-
enabled:
|
|
5320
|
-
unavailable_because: '
|
|
5324
|
+
enabled: true,
|
|
5325
|
+
unavailable_because: '',
|
|
5321
5326
|
tags: ['example', 'companies'],
|
|
5322
5327
|
icon: 'apps',
|
|
5323
5328
|
emoji: '✈️',
|
|
@@ -8338,12 +8343,15 @@ export const APP_SOURCES = {
|
|
|
8338
8343
|
},
|
|
8339
8344
|
],
|
|
8340
8345
|
verified: {
|
|
8346
|
+
live: [
|
|
8347
|
+
"Drilled on r1 on 13 October 2026 against the hosted test merchants, as its Preview's runtime reaches the gateway's ucp and acp toolsets: at Example Store (store.examples.datalayer.run) a search, a checkout filled, the exact purchase asked of the person, approved on Waiting for you, bought once and refused a second time, its order read; at Example Outfitters (outfitters.examples.datalayer.run) the same checkout over ACP. Test merchants only: paid with a test instrument.",
|
|
8348
|
+
],
|
|
8341
8349
|
recorded: [
|
|
8342
8350
|
"The purchase is the developers' UCP example (examples/protocols/ucp/shopping.py) against its local test store, Example Store, run in its own tests.",
|
|
8343
8351
|
"The checkout at Example Outfitters is the developers' ACP example (examples/protocols/agentic_commerce/checkout.py), run in its own tests.",
|
|
8344
8352
|
],
|
|
8345
8353
|
unverified: [
|
|
8346
|
-
'
|
|
8354
|
+
"A model's turns in the Studio's Preview were not watched in that drill (no dev server ran).",
|
|
8347
8355
|
],
|
|
8348
8356
|
},
|
|
8349
8357
|
},
|
|
@@ -8354,8 +8362,6 @@ export const APP_SOURCES = {
|
|
|
8354
8362
|
deployment: {
|
|
8355
8363
|
hosted: {},
|
|
8356
8364
|
},
|
|
8357
|
-
enabled: false,
|
|
8358
|
-
unavailable_because: 'It buys at shops for you over UCP and ACP, and the services that reach a shop for you are not shipped yet.',
|
|
8359
8365
|
tags: ['example', 'companies'],
|
|
8360
8366
|
emoji: '🛍️',
|
|
8361
8367
|
},
|
|
@@ -8883,11 +8889,15 @@ export const APP_SOURCES = {
|
|
|
8883
8889
|
},
|
|
8884
8890
|
],
|
|
8885
8891
|
verified: {
|
|
8892
|
+
live: [
|
|
8893
|
+
"Drilled on r1 on 13 October 2026 against the hosted Example Air (air.examples.datalayer.run), as its Preview's runtime reaches the gateway's pact toolset: its card read, asked as the application alone, the person's sign-in by device code approved at the Brand, then asked for the person and answered from the booking (bookings:read used, its receipt verified).",
|
|
8894
|
+
],
|
|
8886
8895
|
recorded: [
|
|
8887
8896
|
"The journey is the developers' PACT example (examples/protocols/pact/personal_agent.py) against its local Provider, Example Air, run in its own tests (version 1.3.112).",
|
|
8888
8897
|
],
|
|
8889
8898
|
unverified: [
|
|
8890
|
-
'
|
|
8899
|
+
'The hosted Example Air keeps registrations in memory: an application is registered there (POST /example/register, its issuer and keys) again after each restart.',
|
|
8900
|
+
"A model's turns in the Studio's Preview were not watched in that drill (no dev server ran); the step-up to bookings:change was not drilled live.",
|
|
8891
8901
|
],
|
|
8892
8902
|
},
|
|
8893
8903
|
},
|
|
@@ -8898,8 +8908,6 @@ export const APP_SOURCES = {
|
|
|
8898
8908
|
deployment: {
|
|
8899
8909
|
hosted: {},
|
|
8900
8910
|
},
|
|
8901
|
-
enabled: false,
|
|
8902
|
-
unavailable_because: 'It asks an airline for you over PACT, and the services that reach a business for you are not shipped yet.',
|
|
8903
8911
|
tags: ['example', 'companies'],
|
|
8904
8912
|
emoji: '✈️',
|
|
8905
8913
|
},
|