@integraledger/lcp-mcp-server 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/CHANGELOG.md +40 -0
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +121 -0
- package/dist/annotations.d.ts +41 -0
- package/dist/annotations.d.ts.map +1 -0
- package/dist/annotations.js +41 -0
- package/dist/annotations.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +5 -0
- package/dist/bin.js.map +1 -0
- package/dist/dispatch.d.ts +53 -0
- package/dist/dispatch.d.ts.map +1 -0
- package/dist/dispatch.js +81 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/node-ports.d.ts +21 -0
- package/dist/node-ports.d.ts.map +1 -0
- package/dist/node-ports.js +33 -0
- package/dist/node-ports.js.map +1 -0
- package/dist/ports.d.ts +23 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +2 -0
- package/dist/ports.js.map +1 -0
- package/dist/server.d.ts +45 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +67 -0
- package/dist/server.js.map +1 -0
- package/dist/stdio.d.ts +15 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +17 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools/compute-atrhash.d.ts +15 -0
- package/dist/tools/compute-atrhash.d.ts.map +1 -0
- package/dist/tools/compute-atrhash.js +66 -0
- package/dist/tools/compute-atrhash.js.map +1 -0
- package/dist/tools/extract-reference.d.ts +19 -0
- package/dist/tools/extract-reference.d.ts.map +1 -0
- package/dist/tools/extract-reference.js +65 -0
- package/dist/tools/extract-reference.js.map +1 -0
- package/dist/tools/generate-legal-context.d.ts +20 -0
- package/dist/tools/generate-legal-context.d.ts.map +1 -0
- package/dist/tools/generate-legal-context.js +99 -0
- package/dist/tools/generate-legal-context.js.map +1 -0
- package/dist/tools/place-reference.d.ts +19 -0
- package/dist/tools/place-reference.d.ts.map +1 -0
- package/dist/tools/place-reference.js +69 -0
- package/dist/tools/place-reference.js.map +1 -0
- package/dist/tools/scaffold-integration.d.ts +15 -0
- package/dist/tools/scaffold-integration.d.ts.map +1 -0
- package/dist/tools/scaffold-integration.js +168 -0
- package/dist/tools/scaffold-integration.js.map +1 -0
- package/dist/tools/verify-before-pay.d.ts +29 -0
- package/dist/tools/verify-before-pay.d.ts.map +1 -0
- package/dist/tools/verify-before-pay.js +158 -0
- package/dist/tools/verify-before-pay.js.map +1 -0
- package/dist/version.d.ts +25 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +37 -0
- package/dist/version.js.map +1 -0
- package/dist/well-known.d.ts +33 -0
- package/dist/well-known.d.ts.map +1 -0
- package/dist/well-known.js +45 -0
- package/dist/well-known.js.map +1 -0
- package/package.json +73 -0
- package/src/annotations.ts +53 -0
- package/src/bin.ts +5 -0
- package/src/dispatch.ts +122 -0
- package/src/index.ts +15 -0
- package/src/node-ports.ts +38 -0
- package/src/ports.ts +23 -0
- package/src/server.ts +70 -0
- package/src/stdio.ts +25 -0
- package/src/tools/compute-atrhash.ts +87 -0
- package/src/tools/extract-reference.ts +86 -0
- package/src/tools/generate-legal-context.ts +127 -0
- package/src/tools/place-reference.ts +96 -0
- package/src/tools/scaffold-integration.ts +183 -0
- package/src/tools/verify-before-pay.ts +194 -0
- package/src/version.ts +42 -0
- package/src/well-known.ts +51 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emit,
|
|
3
|
+
isKnownTermsFormat,
|
|
4
|
+
KNOWN_TERMS_FORMATS,
|
|
5
|
+
type LegalContextJson,
|
|
6
|
+
} from "@integraledger/lcp-discovery";
|
|
7
|
+
import { hashAtr } from "@integraledger/lcp-kernel";
|
|
8
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { readOnlyToolAnnotations } from "../annotations.js";
|
|
11
|
+
import type { LcpMcpPorts } from "../ports.js";
|
|
12
|
+
|
|
13
|
+
const inputSchema = z.object({
|
|
14
|
+
termsUrl: z
|
|
15
|
+
.url()
|
|
16
|
+
.describe("HTTPS URL of the terms document. Fetched, so it must be live."),
|
|
17
|
+
termsFormat: z
|
|
18
|
+
.string()
|
|
19
|
+
.describe(
|
|
20
|
+
`The LCP §2.5 format token for the terms document — one of: ${KNOWN_TERMS_FORMATS.join(", ")}.`,
|
|
21
|
+
),
|
|
22
|
+
acceptanceRequired: z
|
|
23
|
+
.boolean()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe(
|
|
26
|
+
"Set true where the service requires explicit signed acceptance before transacting (LCP §3, Level 3).",
|
|
27
|
+
),
|
|
28
|
+
disputeResolution: z
|
|
29
|
+
.record(z.string(), z.unknown())
|
|
30
|
+
.optional()
|
|
31
|
+
.describe(
|
|
32
|
+
"The service's dispute-resolution metadata (LCP §3, Level 4), e.g. { method, jurisdiction }.",
|
|
33
|
+
),
|
|
34
|
+
returns: z.string().optional().describe("URL of the returns policy."),
|
|
35
|
+
api: z
|
|
36
|
+
.url()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Entry point to richer legal functionality (LCP §3, Level 4)."),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const outputSchema = z.object({
|
|
42
|
+
terms: z.string().describe("The terms document URL, echoed from the input."),
|
|
43
|
+
termsFormat: z.string().describe("The LCP §2.5 format token."),
|
|
44
|
+
atrHash: z
|
|
45
|
+
.string()
|
|
46
|
+
.describe("SHA-256 over the bytes served at `terms`, computed here."),
|
|
47
|
+
acceptanceRequired: z
|
|
48
|
+
.boolean()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("Present only where the input declared it."),
|
|
51
|
+
disputeResolution: z
|
|
52
|
+
.record(z.string(), z.unknown())
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("Present only where the input declared it."),
|
|
55
|
+
returns: z
|
|
56
|
+
.string()
|
|
57
|
+
.optional()
|
|
58
|
+
.describe("Present only where the input declared it."),
|
|
59
|
+
api: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("Present only where the input declared it."),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `lcp_generate_legal_context` — build a ready-to-publish `/.well-known/legal-context.json`.
|
|
67
|
+
*
|
|
68
|
+
* Grounded on `discovery.emit`, which drops undefined fields and then VALIDATES what is left, so a profile
|
|
69
|
+
* this tool cannot legally emit fails loudly here rather than 404-ing an agent later. The `atrHash` is
|
|
70
|
+
* `kernel.hashAtr` over the bytes actually served at `termsUrl` — never a value the caller supplies, because
|
|
71
|
+
* a hash the author asserts rather than computes is the one field an author can get wrong and never notice.
|
|
72
|
+
*
|
|
73
|
+
* `termsFormat` is checked against `discovery.KNOWN_TERMS_FORMATS` rather than re-declared as an enum here:
|
|
74
|
+
* the token set is the protocol's, and a second copy in this package would be free to drift from it. The
|
|
75
|
+
* free-string `termsFormat` invites values like `text/plain` and `application/pdf`,
|
|
76
|
+
* neither of which is an LCP §2.5 token.
|
|
77
|
+
*
|
|
78
|
+
* IT RETURNS THE DOCUMENT; IT DOES NOT PUBLISH ONE. Serving it is the deployment's act, on the deployment's
|
|
79
|
+
* origin, under the deployment's name.
|
|
80
|
+
*/
|
|
81
|
+
export function registerGenerateLegalContext(
|
|
82
|
+
server: McpServer,
|
|
83
|
+
ports: LcpMcpPorts,
|
|
84
|
+
): void {
|
|
85
|
+
server.registerTool(
|
|
86
|
+
"lcp_generate_legal_context",
|
|
87
|
+
{
|
|
88
|
+
description:
|
|
89
|
+
"Build a ready-to-publish `/.well-known/legal-context.json` document. Fetches `termsUrl`, " +
|
|
90
|
+
"computes the ATR hash over the served bytes, validates the result against the LCP discovery " +
|
|
91
|
+
"schema, and returns the document to serve. It does not publish anything.",
|
|
92
|
+
inputSchema,
|
|
93
|
+
outputSchema,
|
|
94
|
+
annotations: readOnlyToolAnnotations(
|
|
95
|
+
"Generate LCP legal-context.json",
|
|
96
|
+
"network",
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
async (args) => {
|
|
100
|
+
if (!isKnownTermsFormat(args.termsFormat))
|
|
101
|
+
throw new Error(
|
|
102
|
+
`termsFormat "${args.termsFormat}" is not an LCP §2.5 token — expected one of: ${KNOWN_TERMS_FORMATS.join(", ")}`,
|
|
103
|
+
);
|
|
104
|
+
const bytes = (await ports.fetcher.fetch(args.termsUrl)).bytes;
|
|
105
|
+
const profile: LegalContextJson = {
|
|
106
|
+
terms: args.termsUrl,
|
|
107
|
+
termsFormat: args.termsFormat,
|
|
108
|
+
atrHash: await hashAtr(bytes),
|
|
109
|
+
...(args.acceptanceRequired !== undefined
|
|
110
|
+
? { acceptanceRequired: args.acceptanceRequired }
|
|
111
|
+
: {}),
|
|
112
|
+
...(args.disputeResolution !== undefined
|
|
113
|
+
? { disputeResolution: args.disputeResolution }
|
|
114
|
+
: {}),
|
|
115
|
+
...(args.returns !== undefined ? { returns: args.returns } : {}),
|
|
116
|
+
...(args.api !== undefined ? { api: args.api } : {}),
|
|
117
|
+
};
|
|
118
|
+
const doc = emit(profile);
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{ type: "text" as const, text: JSON.stringify(doc, null, 2) },
|
|
122
|
+
],
|
|
123
|
+
structuredContent: doc,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { decodeLegalContextString } from "@integraledger/lcp-binding-core";
|
|
2
|
+
import { supportedProtocols } from "@integraledger/lcp-placements";
|
|
3
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { readOnlyToolAnnotations } from "../annotations.js";
|
|
6
|
+
import {
|
|
7
|
+
manifestSummary,
|
|
8
|
+
PLACEMENT_SUMMARY_SCHEMA,
|
|
9
|
+
refusalResult,
|
|
10
|
+
resolveAdapter,
|
|
11
|
+
} from "../dispatch.js";
|
|
12
|
+
import type { LcpMcpPorts } from "../ports.js";
|
|
13
|
+
|
|
14
|
+
/** A host protocol document, as JSON. One shape, used for the argument and for the result. */
|
|
15
|
+
const documentShape = z.record(z.string(), z.unknown());
|
|
16
|
+
|
|
17
|
+
const inputSchema = z.object({
|
|
18
|
+
protocol: z
|
|
19
|
+
.string()
|
|
20
|
+
.describe(
|
|
21
|
+
"The commerce protocol whose document this is. See `lcp_place_reference`'s description for the set this build can place into.",
|
|
22
|
+
),
|
|
23
|
+
reference: z
|
|
24
|
+
.string()
|
|
25
|
+
.describe(
|
|
26
|
+
"The LCP §8.1 carrier string, `lcp:{type}:{value}` — exactly what `lcp_compute_atrhash` returns as `reference`.",
|
|
27
|
+
),
|
|
28
|
+
document: documentShape.describe(
|
|
29
|
+
"The host protocol's own document, as JSON. Returned unchanged with the reference added; the input is never mutated.",
|
|
30
|
+
),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const outputSchema = z.object({
|
|
34
|
+
document: documentShape.describe(
|
|
35
|
+
"The host document with the reference placed.",
|
|
36
|
+
),
|
|
37
|
+
placement: PLACEMENT_SUMMARY_SCHEMA,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `lcp_place_reference` — put an LCP reference into any commerce protocol's own document.
|
|
42
|
+
*
|
|
43
|
+
* The tool that makes this server protocol-independent in the sense LCP §10 means: an agent transacting
|
|
44
|
+
* under ACP, x402, UCP, AP2, ACK, MPP, A2A, Visa TAP or Mastercard VI calls the SAME tool, and the
|
|
45
|
+
* registry decides where the reference belongs. Adding a protocol is a registry release, not a change here.
|
|
46
|
+
*
|
|
47
|
+
* The returned `placement` block is the manifest's own account of what happened — which tier it is (does
|
|
48
|
+
* this work against stock implementations today, §8.3), which pattern, and the exact field — so a caller
|
|
49
|
+
* can tell a declared protocol extension from an advisory metadata ride without reading our documentation.
|
|
50
|
+
*
|
|
51
|
+
* IT RETURNS A DOCUMENT; IT DOES NOT SEND ONE. The placement adapters are pure and never mutate their
|
|
52
|
+
* input, and this tool adds no transport. Whether the document goes on the wire is the agent's decision,
|
|
53
|
+
* made with its own credentials — this server holds none.
|
|
54
|
+
*/
|
|
55
|
+
export function registerPlaceReference(
|
|
56
|
+
server: McpServer,
|
|
57
|
+
ports: LcpMcpPorts,
|
|
58
|
+
): void {
|
|
59
|
+
server.registerTool(
|
|
60
|
+
"lcp_place_reference",
|
|
61
|
+
{
|
|
62
|
+
description:
|
|
63
|
+
"Place an LCP legal-context reference into a commerce protocol's own document, at the field that " +
|
|
64
|
+
"protocol's placement manifest declares. Supported protocols in this build: " +
|
|
65
|
+
`${supportedProtocols().join(", ")}. Returns the updated document plus the placement manifest's ` +
|
|
66
|
+
"tier, pattern and field. This is a legally significant act: the resulting document asserts which " +
|
|
67
|
+
"terms govern the transaction. It returns the document — it does not transmit it.",
|
|
68
|
+
inputSchema,
|
|
69
|
+
outputSchema,
|
|
70
|
+
annotations: readOnlyToolAnnotations("Place an LCP reference", "closed"),
|
|
71
|
+
},
|
|
72
|
+
(args) => {
|
|
73
|
+
const adapter = resolveAdapter(args.protocol, ports);
|
|
74
|
+
const ref = decodeLegalContextString(args.reference);
|
|
75
|
+
if (ref === undefined)
|
|
76
|
+
throw new Error(
|
|
77
|
+
`"${args.reference}" is not a recognized LCP §8.1 carrier string — expected lcp:{type}:{value} with a registered type`,
|
|
78
|
+
);
|
|
79
|
+
const outcome = adapter.place(ref, args.document);
|
|
80
|
+
if (!("ok" in outcome)) return refusalResult(outcome);
|
|
81
|
+
// `place` is typed `Outcome<unknown>` because a host document is whatever the host protocol says it
|
|
82
|
+
// is. Parsing rather than casting keeps the widening honest: an adapter that somehow returned a
|
|
83
|
+
// non-object would fail here and say so, instead of reaching the wire as a malformed tool result.
|
|
84
|
+
const out = {
|
|
85
|
+
document: documentShape.parse(outcome.value),
|
|
86
|
+
placement: manifestSummary(adapter.manifest),
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
content: [
|
|
90
|
+
{ type: "text" as const, text: JSON.stringify(out, null, 2) },
|
|
91
|
+
],
|
|
92
|
+
structuredContent: out,
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readOnlyToolAnnotations } from "../annotations.js";
|
|
4
|
+
|
|
5
|
+
const inputSchema = z.object({
|
|
6
|
+
target: z
|
|
7
|
+
.enum(["seller", "buyer"])
|
|
8
|
+
.describe(
|
|
9
|
+
"Which side to scaffold: 'seller' (publish legal-context.json and place the reference) or " +
|
|
10
|
+
"'buyer' (verify before signing or paying).",
|
|
11
|
+
),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const outputSchema = z.object({
|
|
15
|
+
target: z.enum(["seller", "buyer"]).describe("The side that was scaffolded."),
|
|
16
|
+
scaffold: z
|
|
17
|
+
.string()
|
|
18
|
+
.describe("Markdown: the steps, and the code to paste, for that side."),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const SELLER_SCAFFOLD = `# LCP seller integration
|
|
22
|
+
|
|
23
|
+
Two obligations, and the second is the one most integrations forget.
|
|
24
|
+
|
|
25
|
+
## 1. Publish /.well-known/legal-context.json
|
|
26
|
+
|
|
27
|
+
Terms must be a standalone, downloadable artifact at a stable URL (LCP §2.2) — not a section of a page,
|
|
28
|
+
not dynamically rendered HTML. The atrHash is the SHA-256 over the exact bytes you serve.
|
|
29
|
+
|
|
30
|
+
\`\`\`ts
|
|
31
|
+
import { emit } from "@integraledger/lcp-discovery";
|
|
32
|
+
import { hashAtr } from "@integraledger/lcp-kernel";
|
|
33
|
+
|
|
34
|
+
// A bare \`fetch\` is right HERE and wrong on the buyer side, and the asymmetry is the point: this URL is
|
|
35
|
+
// YOUR OWN, chosen by you at build time. The buyer fetches a URL a counterparty chose, which is why the
|
|
36
|
+
// buyer scaffold below goes through the SSRF-guarded fetcher instead.
|
|
37
|
+
const bytes = new Uint8Array(await (await fetch(TERMS_URL)).arrayBuffer());
|
|
38
|
+
|
|
39
|
+
// emit() drops undefined fields and validates the result — a malformed profile throws here,
|
|
40
|
+
// at build time, rather than 404-ing an agent later.
|
|
41
|
+
export const legalContext = emit({
|
|
42
|
+
terms: TERMS_URL,
|
|
43
|
+
termsFormat: "markdown", // an LCP §2.5 token: markdown | json | plain | html | pdf
|
|
44
|
+
atrHash: await hashAtr(bytes),
|
|
45
|
+
acceptanceRequired: true, // Level 3: explicit signed acceptance before transacting
|
|
46
|
+
disputeResolution: { method: "...", jurisdiction: "..." }, // Level 4
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Serve it, with the exact bytes you hashed still at TERMS_URL:
|
|
50
|
+
// GET /.well-known/legal-context.json -> Response.json(legalContext)
|
|
51
|
+
\`\`\`
|
|
52
|
+
|
|
53
|
+
Recompute and republish the hash on EVERY terms change. A stale atrHash halts every conformant buyer.
|
|
54
|
+
|
|
55
|
+
## 2. Carry the reference on the transaction itself
|
|
56
|
+
|
|
57
|
+
The discovery document says what your terms are. It does not bind them to a particular transaction —
|
|
58
|
+
that is the placement's job, and where it goes is the host protocol's decision, not yours.
|
|
59
|
+
|
|
60
|
+
\`\`\`ts
|
|
61
|
+
import { placementFor } from "@integraledger/lcp-placements";
|
|
62
|
+
|
|
63
|
+
const adapter = placementFor("acp"); // or x402, ucp, ap2, ack, mpp, a2a, visa-tap …
|
|
64
|
+
const outcome = adapter?.place(
|
|
65
|
+
{ type: "sha256", value: legalContext.atrHash },
|
|
66
|
+
checkoutSession,
|
|
67
|
+
);
|
|
68
|
+
if (outcome === undefined || !("ok" in outcome)) throw new Error("no placement — halt");
|
|
69
|
+
send(outcome.value);
|
|
70
|
+
\`\`\`
|
|
71
|
+
|
|
72
|
+
Or call the \`lcp_place_reference\` tool on this server, which dispatches through the same registry.
|
|
73
|
+
|
|
74
|
+
An x402 seller gets both halves plus the settlement weld from Integra's separately licensed seller layer,
|
|
75
|
+
which is not part of this open one — flagged so you know what completes the picture, not as something you
|
|
76
|
+
can npm-install from here.
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const BUYER_SCAFFOLD = `# LCP buyer-agent integration — verify BEFORE you sign or pay (LCP §5.3)
|
|
80
|
+
|
|
81
|
+
The rule: fetch the terms the counterparty advertised, recompute the fingerprint, and HALT before the
|
|
82
|
+
signing key is invoked if it does not match.
|
|
83
|
+
|
|
84
|
+
## The check itself
|
|
85
|
+
|
|
86
|
+
Both URLs below are the COUNTERPARTY's, so both are fetched through the guarded fetcher — HTTPS-only,
|
|
87
|
+
\`redirect: "error"\`, every resolved address re-checked public unicast on every fetch, body capped while
|
|
88
|
+
streaming. A bare \`fetch\` here is an SSRF primitive an agent can be talked into aiming anywhere.
|
|
89
|
+
|
|
90
|
+
\`\`\`ts
|
|
91
|
+
import { makeCachingFetcher, nodeDnsLookup } from "@integraledger/agent-guard";
|
|
92
|
+
import { checkListingIntegrity, parseLegalContextJson } from "@integraledger/lcp-discovery";
|
|
93
|
+
import { isAtrHash } from "@integraledger/lcp-kernel";
|
|
94
|
+
|
|
95
|
+
const fetcher = makeCachingFetcher({
|
|
96
|
+
httpFetch: fetch,
|
|
97
|
+
now: () => new Date().toISOString(),
|
|
98
|
+
lookup: nodeDnsLookup,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const doc = await fetcher.fetch(\`\${SELLER_ORIGIN}/.well-known/legal-context.json\`);
|
|
102
|
+
const listing = parseLegalContextJson(JSON.parse(new TextDecoder().decode(doc.bytes)));
|
|
103
|
+
|
|
104
|
+
// \`atrHash\` is optional on the record: absent is LCP Level 1, where there is nothing to verify at all.
|
|
105
|
+
// Treat that as a halt, not a pass — the honest answer is that this cannot say the terms are the ones
|
|
106
|
+
// committed to.
|
|
107
|
+
if (listing.atrHash === undefined || !isAtrHash(listing.atrHash))
|
|
108
|
+
throw new Error("no verifiable fingerprint declared (Level 1) — refusing to pay on an unproven record");
|
|
109
|
+
|
|
110
|
+
const terms = await fetcher.fetch(listing.terms);
|
|
111
|
+
const dsc2 = await checkListingIntegrity(listing, terms.bytes, listing.atrHash);
|
|
112
|
+
|
|
113
|
+
if (!dsc2.ok) throw new Error(\`LCP verification failed: \${dsc2.detail} — refusing to pay\`);
|
|
114
|
+
\`\`\`
|
|
115
|
+
|
|
116
|
+
Or call the \`lcp_verify_before_pay\` tool on this server, which does exactly this.
|
|
117
|
+
|
|
118
|
+
## The gate around it
|
|
119
|
+
|
|
120
|
+
A hash check alone is not a policy. \`@integraledger/agent-guard\` runs the whole ladder — required level,
|
|
121
|
+
fingerprint, the buyer's stated policy over the TYPED envelope only, coverage gaps, seller assurance —
|
|
122
|
+
and returns a decision that \`transact\` enforces against a guarded signer, so the key is unreachable on
|
|
123
|
+
anything but Proceed:
|
|
124
|
+
|
|
125
|
+
\`\`\`ts
|
|
126
|
+
import { makeCachingFetcher, nodeDnsLookup, transact } from "@integraledger/agent-guard";
|
|
127
|
+
|
|
128
|
+
const fetcher = makeCachingFetcher({
|
|
129
|
+
httpFetch: fetch,
|
|
130
|
+
now: () => new Date().toISOString(),
|
|
131
|
+
lookup: nodeDnsLookup, // the SSRF guard: the URL came from the counterparty
|
|
132
|
+
});
|
|
133
|
+
const result = await transact(proposal, policy, { fetcher, now, log }, signer);
|
|
134
|
+
// result.kind === "signed" only on Proceed. On Decline or Escalate the signer is never called.
|
|
135
|
+
\`\`\`
|
|
136
|
+
|
|
137
|
+
Two properties do the work, and neither is a matter of discipline: the typed proposal CANNOT carry
|
|
138
|
+
natural-language prose, so the terms body can never reach policy evaluation (the prompt-injection
|
|
139
|
+
boundary is architectural, LCP §12.7); and a coverage gap is resolved by the buyer's STATED disposition,
|
|
140
|
+
never by a silent default.
|
|
141
|
+
|
|
142
|
+
Retain the fetched bytes. They are the evidence of what you saw (LCP §5.4).
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `lcp_scaffold_integration` — copy-pasteable starter code for either side of an LCP integration.
|
|
147
|
+
*
|
|
148
|
+
* BOTH SCAFFOLDS HAND THE INTEGRATOR THE SHIPPED PACKAGES, deliberately. The alternative an integrator
|
|
149
|
+
* reaches for unaided is a hand-rolled zero-dependency verifier — a `createHash("sha256")`, a hand-parsed
|
|
150
|
+
* JWS, and a fetch with no SSRF posture — which is exactly the code nobody should be writing themselves,
|
|
151
|
+
* and which drifts from the standard the moment the standard moves.
|
|
152
|
+
*
|
|
153
|
+
* The `target` tokens are `seller` and `buyer`, matching the vocabulary the rest of this stack uses. The
|
|
154
|
+
* seller scaffold covers BOTH obligations: publishing the discovery document is not the whole integration,
|
|
155
|
+
* because it binds no particular transaction.
|
|
156
|
+
*/
|
|
157
|
+
export function registerScaffoldIntegration(server: McpServer): void {
|
|
158
|
+
server.registerTool(
|
|
159
|
+
"lcp_scaffold_integration",
|
|
160
|
+
{
|
|
161
|
+
description:
|
|
162
|
+
"Return copy-pasteable starter code and steps for integrating LCP, for `target: 'seller'` " +
|
|
163
|
+
"(publish legal-context.json and carry the reference on the transaction) or `target: 'buyer'` " +
|
|
164
|
+
"(verify before signing or paying). The code uses the shipped @integraledger packages.",
|
|
165
|
+
inputSchema,
|
|
166
|
+
outputSchema,
|
|
167
|
+
annotations: readOnlyToolAnnotations(
|
|
168
|
+
"Scaffold an LCP integration",
|
|
169
|
+
"closed",
|
|
170
|
+
),
|
|
171
|
+
},
|
|
172
|
+
(args) => {
|
|
173
|
+
const out = {
|
|
174
|
+
target: args.target,
|
|
175
|
+
scaffold: args.target === "seller" ? SELLER_SCAFFOLD : BUYER_SCAFFOLD,
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
content: [{ type: "text" as const, text: out.scaffold }],
|
|
179
|
+
structuredContent: out,
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkListingIntegrity,
|
|
3
|
+
parseLegalContextJson,
|
|
4
|
+
} from "@integraledger/lcp-discovery";
|
|
5
|
+
import { isAtrHash } from "@integraledger/lcp-kernel";
|
|
6
|
+
import type { CallToolResult, McpServer } from "@modelcontextprotocol/server";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { readOnlyToolAnnotations } from "../annotations.js";
|
|
9
|
+
import type { LcpMcpPorts } from "../ports.js";
|
|
10
|
+
import { legalContextUrl } from "../well-known.js";
|
|
11
|
+
|
|
12
|
+
const inputSchema = z.object({
|
|
13
|
+
serviceUrl: z
|
|
14
|
+
.url()
|
|
15
|
+
.describe(
|
|
16
|
+
"The service's origin (`https://seller.example`) or its full legal-context URL. " +
|
|
17
|
+
"`/.well-known/legal-context.json` is appended when absent, and a query string, a fragment or " +
|
|
18
|
+
"credentials in the authority are REFUSED — LCP §2.1 defines the well-known URI with none of them.",
|
|
19
|
+
),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** The Level 1 readout, as ONE literal: it is the tool's most consequential sentence, and a test pins it. */
|
|
23
|
+
const LEVEL_1_DETAIL =
|
|
24
|
+
"the document declares no atrHash (LCP Level 1) — there is nothing to verify, so this tool cannot say the served terms are the ones committed to";
|
|
25
|
+
|
|
26
|
+
const outputSchema = z.object({
|
|
27
|
+
verdict: z
|
|
28
|
+
.enum(["verified", "mismatch", "unverifiable"])
|
|
29
|
+
.describe(
|
|
30
|
+
"verified: the served terms hash to the declared fingerprint. mismatch: they do not. " +
|
|
31
|
+
"unverifiable: no fingerprint was declared, or the terms format is not machine-readable.",
|
|
32
|
+
),
|
|
33
|
+
wouldHalt: z
|
|
34
|
+
.boolean()
|
|
35
|
+
.describe("True when an agent MUST NOT pay against these terms."),
|
|
36
|
+
atrHashMatch: z
|
|
37
|
+
.boolean()
|
|
38
|
+
.describe("True only on `verified` — the LCP §5.3 comparison itself."),
|
|
39
|
+
legalContextUrl: z.string().describe("The discovery document actually read."),
|
|
40
|
+
termsUrl: z.string().describe("The terms document that document points at."),
|
|
41
|
+
declaredAtrHash: z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("The fingerprint the service declared; absent at Level 1."),
|
|
45
|
+
computedAtrHash: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe(
|
|
49
|
+
"The fingerprint recomputed here; absent when nothing was fetched.",
|
|
50
|
+
),
|
|
51
|
+
termsBytes: z
|
|
52
|
+
.number()
|
|
53
|
+
.int()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("How many bytes of terms were hashed."),
|
|
56
|
+
acceptanceRequired: z
|
|
57
|
+
.boolean()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Present only where the document declares it (LCP Level 3)."),
|
|
60
|
+
disputeResolutionDeclared: z
|
|
61
|
+
.boolean()
|
|
62
|
+
.describe(
|
|
63
|
+
"Whether the document carries a disputeResolution block (Level 4).",
|
|
64
|
+
),
|
|
65
|
+
detail: z.string().describe("Why this verdict, in one sentence."),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `lcp_verify_before_pay` — the agent guardrail (LCP §5.3). Fetch the service's discovery document and the
|
|
70
|
+
* terms it points at, recompute the fingerprint, and say whether an agent must halt before paying.
|
|
71
|
+
*
|
|
72
|
+
* RE-GROUNDED on the shipped packages, and three things follow from that which a hand-rolled verifier
|
|
73
|
+
* typically gets wrong:
|
|
74
|
+
*
|
|
75
|
+
* 1. The intake is `discovery.parseLegalContextJson`, which THROWS on a non-conformant document. Reading
|
|
76
|
+
* fields off whatever JSON came back would let a document that is not a legal-context document at all
|
|
77
|
+
* still produce a verdict. A loud failure is the honest answer.
|
|
78
|
+
* 2. The comparison is `discovery.checkListingIntegrity` — DSC-2, the shipped rule — not a local
|
|
79
|
+
* `sha256` and `===`. It carries a check a hand-rolled comparison omits: a listing whose `termsFormat` is not
|
|
80
|
+
* machine-readable is `unverifiable` rather than passed through, because an agent cannot evaluate
|
|
81
|
+
* terms it cannot read. That NARROWS the tool: `application/pdf` is refused rather than accepted.
|
|
82
|
+
* 3. **There is no ES256 `signing` block, and its absence is correct.** A verifier that reads a
|
|
83
|
+
* seller-signed ATR JWS out of a `signing` object is reading a field LCP does not define: Level 3 is
|
|
84
|
+
* the BUYER's signed acceptance over the fingerprint (§3, §4.2), and `legal-context.json` carries no
|
|
85
|
+
* seller signature at any level. Serving one would ship a private extension as though it were LCP.
|
|
86
|
+
*
|
|
87
|
+
* ABSENCE OF A FINGERPRINT IS NOT A PASS. A Level 1 document (terms, no `atrHash`) halts: the tool was
|
|
88
|
+
* asked whether the terms can be trusted before paying, and against a document with nothing to verify the
|
|
89
|
+
* only truthful answer is that it cannot say. A buyer that intends to transact at Level 1 anyway does so
|
|
90
|
+
* through its own policy engine (`@integraledger/agent-guard`'s stated `requiredLevel`), never by reading a
|
|
91
|
+
* green light out of a tool that verified nothing.
|
|
92
|
+
*/
|
|
93
|
+
export function registerVerifyBeforePay(
|
|
94
|
+
server: McpServer,
|
|
95
|
+
ports: LcpMcpPorts,
|
|
96
|
+
): void {
|
|
97
|
+
server.registerTool(
|
|
98
|
+
"lcp_verify_before_pay",
|
|
99
|
+
{
|
|
100
|
+
description:
|
|
101
|
+
"Verify a service's LCP terms BEFORE paying (LCP §5.3). Fetches /.well-known/legal-context.json and " +
|
|
102
|
+
"the terms it references, recomputes the SHA-256 ATR hash, and compares it to the declared value. " +
|
|
103
|
+
"Returns `{ verdict, wouldHalt, atrHashMatch, … }`. If `wouldHalt` is true, DO NOT PAY — this is a " +
|
|
104
|
+
"legally significant decision, and the absence of a declared fingerprint counts as a halt.",
|
|
105
|
+
inputSchema,
|
|
106
|
+
outputSchema,
|
|
107
|
+
annotations: readOnlyToolAnnotations("LCP verify-before-pay", "network"),
|
|
108
|
+
},
|
|
109
|
+
async (args) => {
|
|
110
|
+
const lcUrl = legalContextUrl(args.serviceUrl);
|
|
111
|
+
const doc = await ports.fetcher.fetch(lcUrl);
|
|
112
|
+
// Fail-fast at the trust boundary: a non-conformant document is a loud error, never a soft verdict.
|
|
113
|
+
const listing = parseLegalContextJson(
|
|
114
|
+
JSON.parse(new TextDecoder().decode(doc.bytes)),
|
|
115
|
+
);
|
|
116
|
+
const common = {
|
|
117
|
+
legalContextUrl: lcUrl,
|
|
118
|
+
termsUrl: listing.terms,
|
|
119
|
+
disputeResolutionDeclared: listing.disputeResolution !== undefined,
|
|
120
|
+
...(listing.acceptanceRequired !== undefined
|
|
121
|
+
? { acceptanceRequired: listing.acceptanceRequired }
|
|
122
|
+
: {}),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const declared = listing.atrHash;
|
|
126
|
+
if (declared === undefined)
|
|
127
|
+
return halt({
|
|
128
|
+
verdict: "unverifiable",
|
|
129
|
+
wouldHalt: true,
|
|
130
|
+
atrHashMatch: false,
|
|
131
|
+
...common,
|
|
132
|
+
detail: LEVEL_1_DETAIL,
|
|
133
|
+
});
|
|
134
|
+
// `parseLegalContextJson` has ALREADY rejected any atrHash outside /^0x[0-9a-fA-F]{64}$/ — the
|
|
135
|
+
// discovery schema is stricter than the TypeScript type it produces, which types `atrHash` as a bare
|
|
136
|
+
// `string`. This narrowing is a compile-time necessity whose throw arm no parsed document reaches,
|
|
137
|
+
// and it is written as a fail-loud throw rather than a cast precisely so that a future loosening of
|
|
138
|
+
// that schema surfaces here instead of silently handing a malformed value to DSC-2.
|
|
139
|
+
if (!isAtrHash(declared))
|
|
140
|
+
throw new Error(
|
|
141
|
+
`the document declares an atrHash that is not a 32-byte 0x fingerprint: ${declared}`,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const terms = await ports.fetcher.fetch(listing.terms);
|
|
145
|
+
const integrity = await checkListingIntegrity(
|
|
146
|
+
listing,
|
|
147
|
+
terms.bytes,
|
|
148
|
+
declared,
|
|
149
|
+
);
|
|
150
|
+
// `atrHashMatch` tracks DSC-2's own verdict. A listing refused for its FORMAT never reached the
|
|
151
|
+
// comparison, and reports `false` for the same reason the tool halts on it: nothing was proved.
|
|
152
|
+
const out = {
|
|
153
|
+
verdict:
|
|
154
|
+
integrity.status === "ok"
|
|
155
|
+
? ("verified" as const)
|
|
156
|
+
: integrity.status === "mismatch"
|
|
157
|
+
? ("mismatch" as const)
|
|
158
|
+
: ("unverifiable" as const),
|
|
159
|
+
wouldHalt: !integrity.ok,
|
|
160
|
+
atrHashMatch: integrity.ok,
|
|
161
|
+
...common,
|
|
162
|
+
declaredAtrHash: integrity.advertisedAtrHash,
|
|
163
|
+
computedAtrHash: integrity.servedAtrHash,
|
|
164
|
+
termsBytes: terms.bytes.byteLength,
|
|
165
|
+
detail: integrity.detail,
|
|
166
|
+
};
|
|
167
|
+
if (!integrity.ok) return halt(out);
|
|
168
|
+
return {
|
|
169
|
+
content: [
|
|
170
|
+
{
|
|
171
|
+
type: "text" as const,
|
|
172
|
+
text: `OK to proceed — LCP verification passed.\n\n${JSON.stringify(out, null, 2)}`,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
structuredContent: out,
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A halting verdict is a tool EXECUTION error, not a protocol error: MCP routes those to the model so it
|
|
182
|
+
* can act on them, which is exactly what "do not pay" needs to reach. */
|
|
183
|
+
function halt(out: Record<string, unknown>): CallToolResult {
|
|
184
|
+
return {
|
|
185
|
+
content: [
|
|
186
|
+
{
|
|
187
|
+
type: "text" as const,
|
|
188
|
+
text: `HALT — do NOT pay. LCP verification did not pass.\n\n${JSON.stringify(out, null, 2)}`,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
structuredContent: out,
|
|
192
|
+
isError: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A package manifest's `version`, or a named failure.
|
|
5
|
+
*
|
|
6
|
+
* Takes the manifest URL rather than closing over one so that every refusal arm is REACHABLE from a test.
|
|
7
|
+
* A manifest with no `version`, a non-string one, an empty one, or JSON that is not an object at all each
|
|
8
|
+
* has to produce an error naming the file — never a server that declares `undefined` as its version to
|
|
9
|
+
* every connected client.
|
|
10
|
+
*/
|
|
11
|
+
export function readManifestVersion(manifestUrl: URL): string {
|
|
12
|
+
const manifest: unknown = JSON.parse(readFileSync(manifestUrl, "utf8"));
|
|
13
|
+
if (
|
|
14
|
+
typeof manifest !== "object" ||
|
|
15
|
+
manifest === null ||
|
|
16
|
+
!("version" in manifest) ||
|
|
17
|
+
typeof manifest.version !== "string" ||
|
|
18
|
+
manifest.version.length === 0
|
|
19
|
+
)
|
|
20
|
+
throw new Error(
|
|
21
|
+
`${manifestUrl.href} states no non-empty string "version" — the server cannot declare an identity it cannot read`,
|
|
22
|
+
);
|
|
23
|
+
return manifest.version;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* This package's own version, READ from its manifest — never restated in source.
|
|
28
|
+
*
|
|
29
|
+
* A FUNCTION, and a `const` only for the name beside it, because the two facts have different owners. The
|
|
30
|
+
* server name is ours to declare and changing it is a deliberate act. The version is the MANIFEST's to
|
|
31
|
+
* declare, and `changeset version` rewrites it without touching a line of TypeScript. A hardcoded constant
|
|
32
|
+
* would keep announcing the previous version over the wire to every MCP client, and nothing in a release
|
|
33
|
+
* would catch it: the manifest and the constant would both be internally consistent while disagreeing with
|
|
34
|
+
* each other. Reading the manifest makes that drift impossible rather than merely detectable.
|
|
35
|
+
*
|
|
36
|
+
* `../package.json` resolves to the package root from `src/` and from `dist/` alike, and npm includes the
|
|
37
|
+
* manifest in every tarball regardless of `files`, so the read is as valid installed as it is in the
|
|
38
|
+
* workspace.
|
|
39
|
+
*/
|
|
40
|
+
export function serverVersion(): string {
|
|
41
|
+
return readManifestVersion(new URL("../package.json", import.meta.url));
|
|
42
|
+
}
|