@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,45 @@
|
|
|
1
|
+
/** LCP §2.1: the one location a service publishes its legal context at. */
|
|
2
|
+
export const WELL_KNOWN_PATH = "/.well-known/legal-context.json";
|
|
3
|
+
/**
|
|
4
|
+
* The canonical discovery URL for a service, from either a bare origin or the well-known URL itself.
|
|
5
|
+
*
|
|
6
|
+
* IDEMPOTENT over both spellings on purpose: an agent that already holds the full
|
|
7
|
+
* `https://seller.example/.well-known/legal-context.json` and one that holds only `https://seller.example`
|
|
8
|
+
* are the same caller a moment apart, and appending the path twice would 404 against a conformant seller.
|
|
9
|
+
*
|
|
10
|
+
* PARSED, NEVER CONCATENATED, and the refusals are why. Appending to the raw string put the well-known path
|
|
11
|
+
* INSIDE whatever the input already carried: `https://x.example?level=3` became
|
|
12
|
+
* `https://x.example?level=3/.well-known/legal-context.json`, which fails on the wire as a broken discovery
|
|
13
|
+
* document at the service root instead of as the input error it is. So the input is parsed, and three
|
|
14
|
+
* shapes are refused BY NAME rather than silently rewritten:
|
|
15
|
+
*
|
|
16
|
+
* - **A query or a fragment.** LCP §2.1 defines the well-known URI with no parameters, and a query a
|
|
17
|
+
* conformant seller ignores is a request for behaviour the standard does not define — appending one as
|
|
18
|
+
* a private hint only works against a server that already agreed to read it. The level a document
|
|
19
|
+
* satisfies is read off the document.
|
|
20
|
+
* - **Credentials in the authority.** `URL`'s `origin` drops them, so parsing `https://u:p@h.example`
|
|
21
|
+
* would hand back a URL addressed differently from the one the caller supplied — the one thing
|
|
22
|
+
* concatenation got right. Silently changing who is being talked to is worse than refusing.
|
|
23
|
+
* - **No network origin at all.** `mailto:`, `data:`, `file:`, `urn:` — each accepted by the tools'
|
|
24
|
+
* `z.url()`, and none of them has a well-known location.
|
|
25
|
+
*
|
|
26
|
+
* SCHEME IS NOT CHECKED HERE. `agent-guard`'s fetcher is HTTPS-only and refuses `http:` on the way out; a
|
|
27
|
+
* second copy of that rule in this function would be duplicated truth with nothing keeping the two in step.
|
|
28
|
+
*
|
|
29
|
+
* A THROW is the right shape inside a tool handler: the MCP SDK surfaces a handler throw as an
|
|
30
|
+
* `isError: true` `tools/call` result, so the model reads the refusal rather than a transport failure.
|
|
31
|
+
*/
|
|
32
|
+
export function legalContextUrl(serviceUrl) {
|
|
33
|
+
const url = new URL(serviceUrl);
|
|
34
|
+
if (url.origin === "null")
|
|
35
|
+
throw new Error(`not a service URL — \`${serviceUrl}\` has no network origin, so it has no well-known location`);
|
|
36
|
+
if (url.search !== "" || url.hash !== "")
|
|
37
|
+
throw new Error(`not a service URL — \`${serviceUrl}\` carries a query or fragment, and LCP §2.1 defines the well-known URI with neither`);
|
|
38
|
+
if (url.username !== "" || url.password !== "")
|
|
39
|
+
throw new Error(`not a service URL — \`${serviceUrl}\` carries credentials in its authority, which the well-known location does not preserve`);
|
|
40
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
41
|
+
return path.endsWith(WELL_KNOWN_PATH)
|
|
42
|
+
? `${url.origin}${path}`
|
|
43
|
+
: `${url.origin}${path}${WELL_KNOWN_PATH}`;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=well-known.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"well-known.js","sourceRoot":"","sources":["../src/well-known.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,MAAM,CAAC,MAAM,eAAe,GAAG,iCAAiC,CAAC;AAEjE;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,UAAU,eAAe,CAAC,UAAkB;IAChD,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,CAAC;IAChC,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM;QACvB,MAAM,IAAI,KAAK,CACb,yBAAyB,UAAU,4DAA4D,CAChG,CAAC;IACJ,IAAI,GAAG,CAAC,MAAM,KAAK,EAAE,IAAI,GAAG,CAAC,IAAI,KAAK,EAAE;QACtC,MAAM,IAAI,KAAK,CACb,yBAAyB,UAAU,sFAAsF,CAC1H,CAAC;IACJ,IAAI,GAAG,CAAC,QAAQ,KAAK,EAAE,IAAI,GAAG,CAAC,QAAQ,KAAK,EAAE;QAC5C,MAAM,IAAI,KAAK,CACb,yBAAyB,UAAU,0FAA0F,CAC9H,CAAC;IACJ,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC9C,OAAO,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC;QACnC,CAAC,CAAC,GAAG,GAAG,CAAC,MAAM,GAAG,IAAI,EAAE;QACxB,CAAC,CAAC,GAAG,GAAG,CAAC,MAAM,GAAG,IAAI,GAAG,eAAe,EAAE,CAAC;AAC/C,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@integraledger/lcp-mcp-server",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"lcp-mcp": "./dist/bin.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src",
|
|
17
|
+
"CHANGELOG.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"NOTICE"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"registry": "https://registry.npmjs.org",
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/IntegraLedger/integra-agent-guard.git",
|
|
28
|
+
"directory": "packages/lcp-mcp-server"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@integraledger/lcp-authority": "0.10.1",
|
|
32
|
+
"@integraledger/lcp-binding-core": "0.10.1",
|
|
33
|
+
"@integraledger/lcp-discovery": "0.10.1",
|
|
34
|
+
"@integraledger/lcp-evidence": "0.10.1",
|
|
35
|
+
"@integraledger/lcp-kernel": "0.10.1",
|
|
36
|
+
"@integraledger/lcp-placement-ack": "0.10.1",
|
|
37
|
+
"@integraledger/lcp-placement-ap2": "0.10.1",
|
|
38
|
+
"@integraledger/lcp-placements": "0.10.1",
|
|
39
|
+
"@integraledger/lcp-verify": "0.10.1",
|
|
40
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
41
|
+
"zod": "4.4.3",
|
|
42
|
+
"@integraledger/agent-guard": "0.9.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@modelcontextprotocol/client": "2.0.0",
|
|
46
|
+
"@types/node": "24.13.3",
|
|
47
|
+
"vitest": "4.1.10"
|
|
48
|
+
},
|
|
49
|
+
"license": "Apache-2.0",
|
|
50
|
+
"description": "Read-only Model Context Protocol server exposing Legal Context Protocol tools to an AI agent — verify before pay, compute an atrHash, extract and place references across the nine commerce protocols that have one. Works against any seller.",
|
|
51
|
+
"keywords": [
|
|
52
|
+
"lcp",
|
|
53
|
+
"legal-context-protocol",
|
|
54
|
+
"mcp",
|
|
55
|
+
"model-context-protocol",
|
|
56
|
+
"agentic-commerce",
|
|
57
|
+
"ai-agent",
|
|
58
|
+
"verify-before-pay",
|
|
59
|
+
"tools"
|
|
60
|
+
],
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=24"
|
|
63
|
+
},
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/IntegraLedger/integra-agent-guard/issues"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://github.com/IntegraLedger/integra-agent-guard/tree/main/packages/lcp-mcp-server#readme",
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "tsc -p tsconfig.build.json",
|
|
70
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
71
|
+
"test": "vitest run"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ToolAnnotations } from "@modelcontextprotocol/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Does the tool reach outside this process?
|
|
5
|
+
*
|
|
6
|
+
* The ONLY axis on which this server's tools differ. Everything here reads; nothing writes, publishes,
|
|
7
|
+
* files, or settles — see the mandate boundary in `server.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export type ToolReach = "network" | "closed";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The annotations every tool on this server carries, per the LIVE MCP `ToolAnnotations` definition
|
|
13
|
+
* (`schema/2026-07-28/schema.ts`, read 2026-07-30; the specification check behind that date is recorded in
|
|
14
|
+
* this package's README).
|
|
15
|
+
*
|
|
16
|
+
* THE DEFAULTS ARE WHY THESE ARE STATED RATHER THAN OMITTED. MCP documents `destructiveHint` as
|
|
17
|
+
* **`Default: true`** and `openWorldHint` as **`Default: true`**, with `readOnlyHint` and `idempotentHint`
|
|
18
|
+
* defaulting to `false`. An unannotated tool therefore reads to a client as *possibly destructive, possibly
|
|
19
|
+
* non-idempotent, open to an arbitrary external world* — the most alarming reading available. Every value
|
|
20
|
+
* below is the honest one for a surface that computes, fetches and reads, so stating them is not decoration:
|
|
21
|
+
* omission would actively misdescribe the tools.
|
|
22
|
+
*
|
|
23
|
+
* THIS IS ALSO WHERE LCP's APPENDIX C IS AN ILLUSTRATION AND THE HOST PROTOCOL BINDS. LCP v1.38 §C.9
|
|
24
|
+
* says tool annotations "such as `destructiveHint` and `openWorldHint` signal that LCP-aware tools perform
|
|
25
|
+
* legally significant actions". MCP's own definition says something narrower and different:
|
|
26
|
+
* `destructiveHint` means "the tool may perform destructive updates to **its environment**", and it is
|
|
27
|
+
* "meaningful only when `readOnlyHint == false`". There is no MCP annotation that means "legally
|
|
28
|
+
* significant", and repurposing one to imply it would be an assertion MCP's clients cannot read. So the
|
|
29
|
+
* annotations here say what MCP defines them to say — `destructiveHint: false`, because none of these tools
|
|
30
|
+
* updates anything — and the legal significance is carried where a client can actually read it: in each
|
|
31
|
+
* tool's `description`.
|
|
32
|
+
*
|
|
33
|
+
* `destructiveHint: false` is stated even though `readOnlyHint: true` makes it formally not-meaningful. The
|
|
34
|
+
* spec says the property is meaningful only when `readOnlyHint === false`; it does not require a client to
|
|
35
|
+
* consult `readOnlyHint` first, and a client that reads `annotations.destructiveHint` directly would
|
|
36
|
+
* otherwise inherit the `true` default. Stating it closes that read at no cost.
|
|
37
|
+
*
|
|
38
|
+
* A last honesty note that belongs in the code rather than only in a doc: MCP requires clients to
|
|
39
|
+
* "consider tool annotations to be untrusted unless they come from trusted servers". These are hints a
|
|
40
|
+
* server asserts about itself. They are not a security control and nothing here treats them as one.
|
|
41
|
+
*/
|
|
42
|
+
export function readOnlyToolAnnotations(
|
|
43
|
+
title: string,
|
|
44
|
+
reach: ToolReach,
|
|
45
|
+
): ToolAnnotations {
|
|
46
|
+
return {
|
|
47
|
+
title,
|
|
48
|
+
readOnlyHint: true,
|
|
49
|
+
destructiveHint: false,
|
|
50
|
+
idempotentHint: true,
|
|
51
|
+
openWorldHint: reach === "network",
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/bin.ts
ADDED
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isKnownProtocolId,
|
|
3
|
+
KNOWN_PROTOCOL_IDS,
|
|
4
|
+
type PlacementManifest,
|
|
5
|
+
type ReferencePlacementAdapter,
|
|
6
|
+
type Refusal,
|
|
7
|
+
} from "@integraledger/lcp-binding-core";
|
|
8
|
+
import {
|
|
9
|
+
placementFor,
|
|
10
|
+
supportedProtocols,
|
|
11
|
+
} from "@integraledger/lcp-placements";
|
|
12
|
+
import type { CallToolResult } from "@modelcontextprotocol/server";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import type { LcpMcpPorts } from "./ports.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The one place this server turns a wire token into a placement adapter — `@integraledger/lcp-placements`,
|
|
18
|
+
* the placement registry, and nothing else. No local map, no `switch`, no import of an individual
|
|
19
|
+
* `placement-*` package: a protocol added to the registry is served here the moment it is published, and
|
|
20
|
+
* a protocol the registry does not carry is reported as an absence rather than guessed at.
|
|
21
|
+
*
|
|
22
|
+
* TWO DISTINCT FAILURES, and collapsing them would lose the fix. `"acp2"` is not a protocol — a typo, and
|
|
23
|
+
* the answer is the closed set. `"mcp"` IS a protocol id and has no field placement at all: LCP §C.9 and
|
|
24
|
+
* §10 make MCP a delivery mechanism, which is what THIS PACKAGE is, so there is no document field for a
|
|
25
|
+
* reference to ride in. Answering "unknown protocol" there would invite someone to go build
|
|
26
|
+
* `placement-mcp`, which should not exist.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveAdapter(
|
|
29
|
+
protocol: string,
|
|
30
|
+
ports: LcpMcpPorts,
|
|
31
|
+
): ReferencePlacementAdapter {
|
|
32
|
+
if (!isKnownProtocolId(protocol))
|
|
33
|
+
throw new Error(
|
|
34
|
+
`"${protocol}" is not an LCP protocol id — known ids: ${KNOWN_PROTOCOL_IDS.join(", ")}`,
|
|
35
|
+
);
|
|
36
|
+
const adapter = placementFor(protocol, ports.deployment);
|
|
37
|
+
if (adapter === undefined)
|
|
38
|
+
throw new Error(
|
|
39
|
+
`no reference placement is registered for "${protocol}" — this build can place into: ${supportedProtocols().join(", ")}`,
|
|
40
|
+
);
|
|
41
|
+
return adapter;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The manifest facts a caller needs to understand where the reference went, without shipping the whole
|
|
46
|
+
* manifest (its `specRef` prose and alias table are documentation, not a tool result).
|
|
47
|
+
*
|
|
48
|
+
* DECLARED ONCE, and both placement tools use it, so the two cannot describe the same block differently.
|
|
49
|
+
*
|
|
50
|
+
* `container` reports the KIND only. The container is a discriminated union whose tagged-array arm carries
|
|
51
|
+
* the array path, tag field, tag value and value field — everything a reader needs is already in `field`,
|
|
52
|
+
* which the manifest keeps as the human-readable locator precisely so a stranger does not have to
|
|
53
|
+
* reassemble one from the walker's parts.
|
|
54
|
+
*/
|
|
55
|
+
export const PLACEMENT_SUMMARY_SCHEMA: z.ZodObject<{
|
|
56
|
+
protocol: z.ZodString;
|
|
57
|
+
tier: z.ZodString;
|
|
58
|
+
pattern: z.ZodString;
|
|
59
|
+
container: z.ZodString;
|
|
60
|
+
encoding: z.ZodString;
|
|
61
|
+
field: z.ZodString;
|
|
62
|
+
}> = z
|
|
63
|
+
.object({
|
|
64
|
+
protocol: z.string().describe("The LCP protocol id."),
|
|
65
|
+
tier: z
|
|
66
|
+
.string()
|
|
67
|
+
.describe(
|
|
68
|
+
"§8.3 wire compatibility: A works against stock today, B needs upstream change.",
|
|
69
|
+
),
|
|
70
|
+
pattern: z.string().describe("The §8.3 binding pattern this carrier is."),
|
|
71
|
+
container: z
|
|
72
|
+
.string()
|
|
73
|
+
.describe(
|
|
74
|
+
"How the field is reached: object-path, tagged-array or header-map.",
|
|
75
|
+
),
|
|
76
|
+
encoding: z.string().describe("How the reference sits in the field."),
|
|
77
|
+
field: z
|
|
78
|
+
.string()
|
|
79
|
+
.describe("The host-protocol field the reference occupies."),
|
|
80
|
+
})
|
|
81
|
+
.describe(
|
|
82
|
+
"The placement manifest's own account of where the reference sits.",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
export function manifestSummary(manifest: PlacementManifest): {
|
|
86
|
+
protocol: string;
|
|
87
|
+
tier: string;
|
|
88
|
+
pattern: string;
|
|
89
|
+
container: string;
|
|
90
|
+
encoding: string;
|
|
91
|
+
field: string;
|
|
92
|
+
} {
|
|
93
|
+
return {
|
|
94
|
+
protocol: manifest.protocol,
|
|
95
|
+
tier: manifest.tier,
|
|
96
|
+
pattern: manifest.pattern,
|
|
97
|
+
container: manifest.container.kind,
|
|
98
|
+
encoding: manifest.encoding,
|
|
99
|
+
field: manifest.field,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A placement refusal, as MCP sees it.
|
|
105
|
+
*
|
|
106
|
+
* Refusals are VALUES in this codebase, never exceptions — and they stay values here: the halt class and
|
|
107
|
+
* the stable code travel in `structuredContent` where a caller can branch on them, while `isError: true` is
|
|
108
|
+
* what makes MCP hand the whole thing to the model instead of swallowing it.
|
|
109
|
+
*/
|
|
110
|
+
export function refusalResult(refusal: Refusal): CallToolResult {
|
|
111
|
+
const out = {
|
|
112
|
+
refused: true,
|
|
113
|
+
haltClass: refusal.haltClass,
|
|
114
|
+
code: refusal.code,
|
|
115
|
+
...(refusal.detail !== undefined ? { detail: refusal.detail } : {}),
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
content: [{ type: "text" as const, text: JSON.stringify(out, null, 2) }],
|
|
119
|
+
structuredContent: out,
|
|
120
|
+
isError: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
readOnlyToolAnnotations,
|
|
3
|
+
type ToolReach,
|
|
4
|
+
} from "./annotations.js";
|
|
5
|
+
export {
|
|
6
|
+
manifestSummary,
|
|
7
|
+
refusalResult,
|
|
8
|
+
resolveAdapter,
|
|
9
|
+
} from "./dispatch.js";
|
|
10
|
+
export { isoNow, nodePorts, REVERSE_DOMAIN_ENV } from "./node-ports.js";
|
|
11
|
+
export type { LcpMcpPorts } from "./ports.js";
|
|
12
|
+
export { createLcpMcpServer, LCP_TOOL_NAMES, SERVER_NAME } from "./server.js";
|
|
13
|
+
export { serveLcpStdio } from "./stdio.js";
|
|
14
|
+
export { serverVersion } from "./version.js";
|
|
15
|
+
export { legalContextUrl, WELL_KNOWN_PATH } from "./well-known.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { makeCachingFetcher, nodeDnsLookup } from "@integraledger/agent-guard";
|
|
2
|
+
import type { LcpMcpPorts } from "./ports.js";
|
|
3
|
+
|
|
4
|
+
/** The environment variable a deployment states its own reverse-domain namespace in. */
|
|
5
|
+
export const REVERSE_DOMAIN_ENV = "LCP_MCP_REVERSE_DOMAIN";
|
|
6
|
+
|
|
7
|
+
/** The wall clock as an ISO-8601 instant — the `now` port the terms fetcher requires. */
|
|
8
|
+
export function isoNow(): string {
|
|
9
|
+
return new Date().toISOString();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Node wiring: the real, guarded terms fetcher plus whatever the environment states.
|
|
14
|
+
*
|
|
15
|
+
* The fetcher is `agent-guard`'s — HTTPS-only, `redirect: "error"`, every resolved address checked public
|
|
16
|
+
* unicast on every network fetch, body capped while streaming, LCP §2.6 cache discipline. This server
|
|
17
|
+
* fetches URLs a COUNTERPARTY chose, so that is not hardening, it is the minimum: bare `fetch` here would
|
|
18
|
+
* turn `lcp_verify_before_pay` into an SSRF primitive an agent can be talked into aiming anywhere.
|
|
19
|
+
*
|
|
20
|
+
* `LCP_MCP_REVERSE_DOMAIN` is absent by default and stays absent. It is only needed by placements whose
|
|
21
|
+
* carrier is namespaced under the deployment's own domain (Mastercard VI's custom Layer-2 constraint type),
|
|
22
|
+
* and LCP §8 canonizes no per-protocol integration profile — so a default here would write Integra's domain
|
|
23
|
+
* into someone else's signed document in every deployment that forgot to set it. Unset, that one placement
|
|
24
|
+
* throws and names what is missing; every other tool is unaffected.
|
|
25
|
+
*/
|
|
26
|
+
export function nodePorts(
|
|
27
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
28
|
+
): LcpMcpPorts {
|
|
29
|
+
const reverseDomain = env[REVERSE_DOMAIN_ENV];
|
|
30
|
+
return {
|
|
31
|
+
fetcher: makeCachingFetcher({
|
|
32
|
+
httpFetch: fetch,
|
|
33
|
+
now: isoNow,
|
|
34
|
+
lookup: nodeDnsLookup,
|
|
35
|
+
}),
|
|
36
|
+
...(reverseDomain !== undefined ? { deployment: { reverseDomain } } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/ports.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TermsFetcher } from "@integraledger/agent-guard";
|
|
2
|
+
import type { PlacementDeployment } from "@integraledger/lcp-placements";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a deployment supplies before this server can serve anything.
|
|
6
|
+
*
|
|
7
|
+
* NO DEFAULTS, and both entries are the reason. The fetcher is the buyer gate's own HTTPS-only,
|
|
8
|
+
* SSRF-guarded, size-capped, LCP §2.6-cached implementation (`agent-guard`'s `makeCachingFetcher`) — the
|
|
9
|
+
* URLs this server fetches are chosen by a *counterparty*, so a server that quietly defaulted to bare
|
|
10
|
+
* `fetch` would silently drop the guard that makes those fetches safe. `nodePorts` wires the real one for a
|
|
11
|
+
* Node deployment; a Workers deployment supplies its own `lookup`.
|
|
12
|
+
*
|
|
13
|
+
* `deployment` is genuinely optional rather than defaulted: eight of the nine registered placements are
|
|
14
|
+
* singletons needing nothing from it, and only Mastercard VI's namespaced factory requires a reverse domain.
|
|
15
|
+
* Omitting it makes `lcp_place_reference` throw for that ONE protocol, naming what is missing — which is the
|
|
16
|
+
* registry's own rule, not a second copy of it here.
|
|
17
|
+
*/
|
|
18
|
+
export interface LcpMcpPorts {
|
|
19
|
+
/** Fetches terms and discovery documents. See `@integraledger/agent-guard`'s `makeCachingFetcher`. */
|
|
20
|
+
readonly fetcher: TermsFetcher;
|
|
21
|
+
/** The deployment's own reverse-domain namespace, for namespaced placement registrations. */
|
|
22
|
+
readonly deployment?: PlacementDeployment;
|
|
23
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { LcpMcpPorts } from "./ports.js";
|
|
3
|
+
import { registerComputeAtrHash } from "./tools/compute-atrhash.js";
|
|
4
|
+
import { registerExtractReference } from "./tools/extract-reference.js";
|
|
5
|
+
import { registerGenerateLegalContext } from "./tools/generate-legal-context.js";
|
|
6
|
+
import { registerPlaceReference } from "./tools/place-reference.js";
|
|
7
|
+
import { registerScaffoldIntegration } from "./tools/scaffold-integration.js";
|
|
8
|
+
import { registerVerifyBeforePay } from "./tools/verify-before-pay.js";
|
|
9
|
+
import { serverVersion } from "./version.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The MCP server name, and the reason it is not the one the specification illustrates.
|
|
13
|
+
*
|
|
14
|
+
* LCP v1.38 §C.9 shows a different tool vocabulary (`get_legal_context`, `verify_terms`, …) and says in the
|
|
15
|
+
* same breath that "the standard does not canonize a particular tool registry" and that the MCP stewards
|
|
16
|
+
* are invited to publish canonical names. An appendix example is not an interoperability fact: names an
|
|
17
|
+
* agent host has already learned are, because a host that has learned a name breaks when it changes.
|
|
18
|
+
* Renaming these to match an illustration the specification itself declines to canonize would break working
|
|
19
|
+
* integrations and make nothing canonical in exchange.
|
|
20
|
+
*/
|
|
21
|
+
export const SERVER_NAME = "lcp-mcp";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every tool this server exposes, in registration order.
|
|
25
|
+
*
|
|
26
|
+
* Exported so the boundary can be ASSERTED rather than described. LCP §C.9's illustrative table also lists
|
|
27
|
+
* `accept_terms`, `create_agreement`, `get_agreement`, `initiate_dispute` and `get_dispute_status`; none of
|
|
28
|
+
* them is here and none is coming. Recording an acceptance, holding the agreement record, or running the
|
|
29
|
+
* dispute are the roles of a custodian and a forum — we are neither. Integra is the record engine; the
|
|
30
|
+
* custodian of the record and the forum for the dispute are the deployment's and the counterparties' own.
|
|
31
|
+
* A server that offered `initiate_dispute` would be asserting an operating role the mandate forbids, and it
|
|
32
|
+
* is far easier to not add a seventh tool than to remove it after an agent has learned to call it.
|
|
33
|
+
*/
|
|
34
|
+
export const LCP_TOOL_NAMES = [
|
|
35
|
+
"lcp_compute_atrhash",
|
|
36
|
+
"lcp_generate_legal_context",
|
|
37
|
+
"lcp_verify_before_pay",
|
|
38
|
+
"lcp_scaffold_integration",
|
|
39
|
+
"lcp_place_reference",
|
|
40
|
+
"lcp_extract_reference",
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the LCP MCP server.
|
|
45
|
+
*
|
|
46
|
+
* A FACTORY, not a singleton, because that is what the transports want: `serveStdio` and
|
|
47
|
+
* `createMcpHandler` both take a factory and may build one instance per connection or per era. Handing
|
|
48
|
+
* them a shared instance would make one client's state another's.
|
|
49
|
+
*
|
|
50
|
+
* The server ends up declaring `tools` and nothing else, and it declares it by REGISTERING tools rather
|
|
51
|
+
* than by asserting a capability in the constructor. MCP requires a server that supports tools to declare
|
|
52
|
+
* the capability and does not permit declaring one that is not served; the SDK derives the declaration from
|
|
53
|
+
* the registrations, so a constructor-side `{ capabilities: { tools: {} } }` is a second statement of the
|
|
54
|
+
* same fact that cannot be wrong today and could be wrong tomorrow. Measured: removing it changes nothing a
|
|
55
|
+
* client sees, which is exactly why it does not belong.
|
|
56
|
+
*
|
|
57
|
+
* There are no resources and no prompts. §C.9 illustrates both — `lcp://agreement/{id}`,
|
|
58
|
+
* `dispute_evidence_assembly` — and every one of its examples is an agreement or dispute surface, which is
|
|
59
|
+
* the boundary above. When there is a resource to serve that is not one of those, it is declared then.
|
|
60
|
+
*/
|
|
61
|
+
export function createLcpMcpServer(ports: LcpMcpPorts): McpServer {
|
|
62
|
+
const server = new McpServer({ name: SERVER_NAME, version: serverVersion() });
|
|
63
|
+
registerComputeAtrHash(server, ports);
|
|
64
|
+
registerGenerateLegalContext(server, ports);
|
|
65
|
+
registerVerifyBeforePay(server, ports);
|
|
66
|
+
registerScaffoldIntegration(server);
|
|
67
|
+
registerPlaceReference(server, ports);
|
|
68
|
+
registerExtractReference(server, ports);
|
|
69
|
+
return server;
|
|
70
|
+
}
|
package/src/stdio.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ServeStdioOptions,
|
|
3
|
+
type StdioServerHandle,
|
|
4
|
+
serveStdio,
|
|
5
|
+
} from "@modelcontextprotocol/server/stdio";
|
|
6
|
+
import type { LcpMcpPorts } from "./ports.js";
|
|
7
|
+
import { createLcpMcpServer } from "./server.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Serve this server over stdio — the transport every desktop agent host speaks.
|
|
11
|
+
*
|
|
12
|
+
* `serveStdio` rather than a hand-wired `StdioServerTransport`: the MCP v2 entry owns the era decision for
|
|
13
|
+
* the connection, pinning ONE instance from the factory for its lifetime, so a 2025-era client and a
|
|
14
|
+
* 2026-07-28 client are both served correctly from the same registration. Hand-wiring a single transport
|
|
15
|
+
* would serve only one of them, and which one would depend on the SDK version rather than on the client.
|
|
16
|
+
*
|
|
17
|
+
* `options` is passed straight through — a test drives a linked in-memory transport through it, and a
|
|
18
|
+
* deployment that binds stdio to a socket per MCP's custom-transport guidance uses the same door.
|
|
19
|
+
*/
|
|
20
|
+
export function serveLcpStdio(
|
|
21
|
+
ports: LcpMcpPorts,
|
|
22
|
+
options?: ServeStdioOptions,
|
|
23
|
+
): StdioServerHandle {
|
|
24
|
+
return serveStdio(() => createLcpMcpServer(ports), options);
|
|
25
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { encodeLegalContextString } from "@integraledger/lcp-binding-core";
|
|
2
|
+
import { hashAtr } from "@integraledger/lcp-kernel";
|
|
3
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { readOnlyToolAnnotations } from "../annotations.js";
|
|
6
|
+
import type { LcpMcpPorts } from "../ports.js";
|
|
7
|
+
|
|
8
|
+
const inputSchema = z.object({
|
|
9
|
+
terms: z
|
|
10
|
+
.string()
|
|
11
|
+
.optional()
|
|
12
|
+
.describe(
|
|
13
|
+
"Raw terms text to hash, as UTF-8 bytes. Provide this OR termsUrl, never both.",
|
|
14
|
+
),
|
|
15
|
+
termsUrl: z
|
|
16
|
+
.url()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe(
|
|
19
|
+
"HTTPS URL of the terms document to fetch and hash. Provide this OR terms, never both.",
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const outputSchema = z.object({
|
|
24
|
+
atrHash: z.string().describe("0x-prefixed SHA-256 of the terms bytes."),
|
|
25
|
+
bytes: z.number().int().describe("How many bytes were hashed."),
|
|
26
|
+
reference: z
|
|
27
|
+
.string()
|
|
28
|
+
.describe("The LCP §8.1 carrier string, `lcp:sha256:0x…`."),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `lcp_compute_atrhash` — the ATR fingerprint of a terms document, from inline text or a fetchable URL.
|
|
33
|
+
*
|
|
34
|
+
* Grounded on `kernel.hashAtr` (the one implementation of the ATR fingerprint) and
|
|
35
|
+
* `binding-core.encodeLegalContextString` (the one implementation of the §8.1 carrier), rather than on a
|
|
36
|
+
* local `createHash("sha256")` and a template literal. The carrier string it returns is the exact input
|
|
37
|
+
* `lcp_place_reference` takes, so compute → place composes without the agent reformatting anything.
|
|
38
|
+
*
|
|
39
|
+
* EXACTLY ONE of `terms`/`termsUrl`. Neither is a caller who has not said what to hash; both is a caller
|
|
40
|
+
* whose two inputs may disagree, and picking one would silently hash something the caller did not mean.
|
|
41
|
+
*/
|
|
42
|
+
export function registerComputeAtrHash(
|
|
43
|
+
server: McpServer,
|
|
44
|
+
ports: LcpMcpPorts,
|
|
45
|
+
): void {
|
|
46
|
+
server.registerTool(
|
|
47
|
+
"lcp_compute_atrhash",
|
|
48
|
+
{
|
|
49
|
+
description:
|
|
50
|
+
"Compute the LCP ATR hash — the SHA-256 over the exact terms bytes — from inline `terms` text " +
|
|
51
|
+
"or a fetchable `termsUrl`. Returns `{ atrHash, bytes, reference }`, where `reference` is the " +
|
|
52
|
+
"canonical `lcp:sha256:0x…` carrier string. A `termsUrl` is fetched over HTTPS only, is not " +
|
|
53
|
+
"followed through redirects, and is size-capped.",
|
|
54
|
+
inputSchema,
|
|
55
|
+
outputSchema,
|
|
56
|
+
annotations: readOnlyToolAnnotations("Compute LCP ATR hash", "network"),
|
|
57
|
+
},
|
|
58
|
+
async (args) => {
|
|
59
|
+
if (args.terms !== undefined && args.termsUrl !== undefined)
|
|
60
|
+
throw new Error(
|
|
61
|
+
"provide exactly one of `terms` or `termsUrl` — both were supplied, and they may disagree",
|
|
62
|
+
);
|
|
63
|
+
let bytes: Uint8Array;
|
|
64
|
+
if (args.termsUrl !== undefined)
|
|
65
|
+
bytes = (await ports.fetcher.fetch(args.termsUrl)).bytes;
|
|
66
|
+
else if (args.terms !== undefined)
|
|
67
|
+
bytes = new TextEncoder().encode(args.terms);
|
|
68
|
+
else
|
|
69
|
+
throw new Error(
|
|
70
|
+
"provide exactly one of `terms` or `termsUrl` — neither was supplied",
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const atrHash = await hashAtr(bytes);
|
|
74
|
+
const out = {
|
|
75
|
+
atrHash,
|
|
76
|
+
bytes: bytes.byteLength,
|
|
77
|
+
reference: encodeLegalContextString({ type: "sha256", value: atrHash }),
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
content: [
|
|
81
|
+
{ type: "text" as const, text: JSON.stringify(out, null, 2) },
|
|
82
|
+
],
|
|
83
|
+
structuredContent: out,
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { encodeLegalContextString } 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
|
+
const inputSchema = z.object({
|
|
15
|
+
protocol: z
|
|
16
|
+
.string()
|
|
17
|
+
.describe(
|
|
18
|
+
"The commerce protocol this document belongs to. See the description for the set this build reads.",
|
|
19
|
+
),
|
|
20
|
+
document: z
|
|
21
|
+
.record(z.string(), z.unknown())
|
|
22
|
+
.describe("The host protocol's own document, as JSON."),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const outputSchema = z.object({
|
|
26
|
+
reference: z
|
|
27
|
+
.string()
|
|
28
|
+
.describe("The LCP §8.1 carrier string recovered from the document."),
|
|
29
|
+
type: z.string().describe("The §8.2 carrier type, e.g. `sha256`."),
|
|
30
|
+
value: z.string().describe("The carrier value, e.g. the 0x ATR hash."),
|
|
31
|
+
placement: PLACEMENT_SUMMARY_SCHEMA,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `lcp_extract_reference` — recover an LCP reference from a commerce protocol's own document.
|
|
36
|
+
*
|
|
37
|
+
* The read half of `lcp_place_reference`, through the same placement registry, so a document one produces the
|
|
38
|
+
* other reads. It is what lets an agent on the receiving side of a counterparty's ACP session, x402
|
|
39
|
+
* challenge or A2A task ask "which terms does this document say govern it?" without knowing where that
|
|
40
|
+
* protocol keeps them.
|
|
41
|
+
*
|
|
42
|
+
* A DOCUMENT WITH NO REFERENCE REFUSES; it never returns a placeholder. That refusal is the answer — the
|
|
43
|
+
* counterparty placed nothing — and an empty string or a null would read as a reference to nothing.
|
|
44
|
+
*
|
|
45
|
+
* The adapter reads the manifest's declared field AND its declared aliases, so a counterparty using a
|
|
46
|
+
* spelling the manifest records as accepted is read rather than refused. Which spellings those are is the
|
|
47
|
+
* manifest's data, not this tool's opinion.
|
|
48
|
+
*/
|
|
49
|
+
export function registerExtractReference(
|
|
50
|
+
server: McpServer,
|
|
51
|
+
ports: LcpMcpPorts,
|
|
52
|
+
): void {
|
|
53
|
+
server.registerTool(
|
|
54
|
+
"lcp_extract_reference",
|
|
55
|
+
{
|
|
56
|
+
description:
|
|
57
|
+
"Recover the LCP legal-context reference from a commerce protocol's own document, reading the " +
|
|
58
|
+
"field that protocol's placement manifest declares. Supported protocols in this build: " +
|
|
59
|
+
`${supportedProtocols().join(", ")}. Returns the \`lcp:{type}:{value}\` carrier string and where ` +
|
|
60
|
+
"it was found. A document carrying no reference is refused, not answered with an empty value.",
|
|
61
|
+
inputSchema,
|
|
62
|
+
outputSchema,
|
|
63
|
+
annotations: readOnlyToolAnnotations(
|
|
64
|
+
"Extract an LCP reference",
|
|
65
|
+
"closed",
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
(args) => {
|
|
69
|
+
const adapter = resolveAdapter(args.protocol, ports);
|
|
70
|
+
const outcome = adapter.extract(args.document);
|
|
71
|
+
if (!("ok" in outcome)) return refusalResult(outcome);
|
|
72
|
+
const out = {
|
|
73
|
+
reference: encodeLegalContextString(outcome.value),
|
|
74
|
+
type: outcome.value.type,
|
|
75
|
+
value: outcome.value.value,
|
|
76
|
+
placement: manifestSummary(adapter.manifest),
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{ type: "text" as const, text: JSON.stringify(out, null, 2) },
|
|
81
|
+
],
|
|
82
|
+
structuredContent: out,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
}
|