@tiertwo/mcp 0.1.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/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/api.d.ts +126 -0
- package/dist/api.js +227 -0
- package/dist/approval.d.ts +52 -0
- package/dist/approval.js +89 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +60 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +140 -0
- package/dist/keystore.d.ts +33 -0
- package/dist/keystore.js +89 -0
- package/dist/pairing.d.ts +17 -0
- package/dist/pairing.js +63 -0
- package/dist/tools.d.ts +23 -0
- package/dist/tools.js +81 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tier Two
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @tiertwo/mcp
|
|
2
|
+
|
|
3
|
+
The **Tier Two** MCP server. It gives an AI agent the ability to create accounts on
|
|
4
|
+
merchant sites *as a specific, verified human* — after that human has paired the agent's
|
|
5
|
+
device to their [Tier Two](https://trytiertwo.com) account and approves each signup.
|
|
6
|
+
|
|
7
|
+
It runs as a stdio MCP server you launch with `npx`, exposes three tools, and mints
|
|
8
|
+
short-lived signed signup tokens that merchants verify with
|
|
9
|
+
[`@tiertwo/sdk`](https://www.npmjs.com/package/@tiertwo/sdk).
|
|
10
|
+
|
|
11
|
+
## Add it to your agent
|
|
12
|
+
|
|
13
|
+
Add the server to your host's MCP config. For Claude (Desktop / Code), in `mcpServers`:
|
|
14
|
+
|
|
15
|
+
```jsonc
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"tiertwo": {
|
|
19
|
+
"command": "npx",
|
|
20
|
+
"args": ["-y", "@tiertwo/mcp@0.1.0"],
|
|
21
|
+
"env": {
|
|
22
|
+
"VH_API_URL": "https://trytiertwo.com"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **`VH_API_URL`** points the server at the Tier Two backend. It defaults to
|
|
30
|
+
`https://trytiertwo.com`; set `http://localhost:3000` to run against a local backend.
|
|
31
|
+
- **Pin the version** (`@tiertwo/mcp@0.1.0`), don't float `latest` — see
|
|
32
|
+
[Supply-chain hygiene](#supply-chain-hygiene) below.
|
|
33
|
+
|
|
34
|
+
## First run: pairing
|
|
35
|
+
|
|
36
|
+
To pair right away, run:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npx -y @tiertwo/mcp pair
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It generates the device keypair, prints the pairing code + a pre-filled dashboard
|
|
43
|
+
link, and waits until you confirm in the browser.
|
|
44
|
+
|
|
45
|
+
Otherwise pairing happens lazily: the first time the agent uses a tool, the server
|
|
46
|
+
generates a device keypair (the
|
|
47
|
+
private key is stored in your **OS keychain**; only a non-secret device id lands in
|
|
48
|
+
`$XDG_CONFIG_HOME/tiertwo/config.json`) and pairs it to your account:
|
|
49
|
+
|
|
50
|
+
1. It surfaces a **pairing code**, a pre-filled **`/dashboard/pair` URL**, and a
|
|
51
|
+
6-digit **verification number** — inline through the host (MCP elicitation) where
|
|
52
|
+
supported, otherwise on stderr.
|
|
53
|
+
2. You open the dashboard (signed in to your verified Tier Two account), confirm the
|
|
54
|
+
**device name** and that the **verification number matches**, and approve.
|
|
55
|
+
3. The server polls until confirmed, then persists the device id. Pairing happens once
|
|
56
|
+
per machine + backend.
|
|
57
|
+
|
|
58
|
+
## Tools
|
|
59
|
+
|
|
60
|
+
| Tool | What it does |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `get_identity` | Returns the paired human's verified status and claims (`verified`, `country`, `age_over_18`). The agent uses this to reason about eligibility before a signup. |
|
|
63
|
+
| `request_human_approval` | Asks the human to approve a `(domain, action)` through the host. Never auto-approves; the approval is one-time and short-lived. |
|
|
64
|
+
| `sign_signup_request` | Mints a short-lived verified-human signup token for `(domain, action)`. **Independently requires a fresh approval** — if none is held it triggers the prompt first, so signing can never skip human approval. Returns `{ token }` for the merchant to verify. |
|
|
65
|
+
|
|
66
|
+
The approval gate is **host-mediated and un-skippable**: approval is collected through
|
|
67
|
+
the host (elicitation, with an out-of-band browser fallback), and the backend's
|
|
68
|
+
`/api/sign` independently requires a fresh approval. A model that calls
|
|
69
|
+
`sign_signup_request` directly still triggers the human prompt.
|
|
70
|
+
|
|
71
|
+
## Supply-chain hygiene
|
|
72
|
+
|
|
73
|
+
`npx @tiertwo/mcp` runs whatever is `latest` each time, which means a compromised
|
|
74
|
+
release would run on your machine automatically. Two mitigations:
|
|
75
|
+
|
|
76
|
+
- **Pin a version** in your config (`@tiertwo/mcp@0.1.0`) rather than floating `latest`,
|
|
77
|
+
and bump deliberately.
|
|
78
|
+
- Releases are published from CI with **npm provenance** (a signed, verifiable link from
|
|
79
|
+
the published tarball back to the exact GitHub commit + workflow), so a release that
|
|
80
|
+
didn't come from the real pipeline is detectable on the
|
|
81
|
+
[npm package page](https://www.npmjs.com/package/@tiertwo/mcp).
|
|
82
|
+
|
|
83
|
+
See [`docs/ARCHITECTURE.md`](https://github.com/Jordan-M/verified-human/blob/master/docs/ARCHITECTURE.md)
|
|
84
|
+
§11 (MCP) and §18.1 (un-skippable approval) for the design.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { type KeyObject } from "node:crypto";
|
|
2
|
+
import { type DeviceAuthEnvelope, type DeviceClientMeta, type DevicePurpose, type ScopedAction } from "@tiertwo/shared";
|
|
3
|
+
/** A fresh transport envelope: purpose + random 16B nonce (base64url) + current ts. */
|
|
4
|
+
export declare function freshEnvelope(purpose: DevicePurpose): DeviceAuthEnvelope;
|
|
5
|
+
/** A fresh envelope + a ScopedAction payload for the action-scoped endpoints. */
|
|
6
|
+
export declare function freshScopedAction(purpose: DevicePurpose, domain: string, action: string, resource?: string): DeviceAuthEnvelope & ScopedAction;
|
|
7
|
+
/** base64url Ed25519 signature over canonicalJSON(body) — the X-VH-Signature value. */
|
|
8
|
+
export declare function signBody(privateKey: KeyObject, body: unknown): string;
|
|
9
|
+
export type PairInit = {
|
|
10
|
+
publicKey: string;
|
|
11
|
+
name: string;
|
|
12
|
+
/** Client-claimed directory metadata (Stage 4.9) — informational only. */
|
|
13
|
+
client?: DeviceClientMeta;
|
|
14
|
+
};
|
|
15
|
+
export type PairInitResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
code: string;
|
|
18
|
+
verificationNumber: string;
|
|
19
|
+
url: string;
|
|
20
|
+
expiresAt: string;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
|
25
|
+
export type PairStatus = {
|
|
26
|
+
status: "pending";
|
|
27
|
+
} | {
|
|
28
|
+
status: "confirmed";
|
|
29
|
+
deviceId: string;
|
|
30
|
+
} | {
|
|
31
|
+
status: "expired";
|
|
32
|
+
} | {
|
|
33
|
+
status: "not_found";
|
|
34
|
+
} | {
|
|
35
|
+
status: "error";
|
|
36
|
+
};
|
|
37
|
+
export type IdentityResult = {
|
|
38
|
+
ok: true;
|
|
39
|
+
verified: boolean;
|
|
40
|
+
country: string | null;
|
|
41
|
+
age_over_18: boolean;
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
reason: string;
|
|
45
|
+
};
|
|
46
|
+
export type ApprovalResult = {
|
|
47
|
+
ok: true;
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
reason: string;
|
|
51
|
+
};
|
|
52
|
+
export type OOBRequestResult = {
|
|
53
|
+
ok: true;
|
|
54
|
+
requestId: string;
|
|
55
|
+
code: string;
|
|
56
|
+
url: string;
|
|
57
|
+
expiresAt: string;
|
|
58
|
+
} | {
|
|
59
|
+
ok: false;
|
|
60
|
+
reason: string;
|
|
61
|
+
};
|
|
62
|
+
export type ApprovalRequestStatus = {
|
|
63
|
+
status: "pending";
|
|
64
|
+
} | {
|
|
65
|
+
status: "confirmed";
|
|
66
|
+
} | {
|
|
67
|
+
status: "expired";
|
|
68
|
+
} | {
|
|
69
|
+
status: "not_found";
|
|
70
|
+
} | {
|
|
71
|
+
status: "error";
|
|
72
|
+
};
|
|
73
|
+
/** Outcome of opening a standing-access request (Stage 2b). */
|
|
74
|
+
export type AccessRequestResult = {
|
|
75
|
+
ok: true;
|
|
76
|
+
requestId: string;
|
|
77
|
+
status: string;
|
|
78
|
+
} | {
|
|
79
|
+
ok: false;
|
|
80
|
+
reason: string;
|
|
81
|
+
};
|
|
82
|
+
/** Token mint outcome. `reason` mirrors /api/sign's documented failures. */
|
|
83
|
+
export type SignResult = {
|
|
84
|
+
ok: true;
|
|
85
|
+
token: string;
|
|
86
|
+
} | {
|
|
87
|
+
ok: false;
|
|
88
|
+
reason: "not_verified" | "access_required" | "unauthorized" | "bad_request" | "server_error" | "network";
|
|
89
|
+
};
|
|
90
|
+
export type ApiClientOptions = {
|
|
91
|
+
apiUrl: string;
|
|
92
|
+
privateKey: KeyObject;
|
|
93
|
+
deviceId?: string;
|
|
94
|
+
fetchImpl?: typeof fetch;
|
|
95
|
+
/** Client-claimed directory metadata (Stage 4.9), attached as the UNSIGNED
|
|
96
|
+
* X-VH-Client header on signed calls (informational only — see shared). */
|
|
97
|
+
clientMeta?: DeviceClientMeta;
|
|
98
|
+
};
|
|
99
|
+
export declare class ApiClient {
|
|
100
|
+
readonly apiUrl: string;
|
|
101
|
+
private readonly privateKey;
|
|
102
|
+
private readonly deviceId?;
|
|
103
|
+
private readonly fetchImpl;
|
|
104
|
+
private readonly clientMeta?;
|
|
105
|
+
constructor(opts: ApiClientOptions);
|
|
106
|
+
/** A copy bound to a paired deviceId, for signed calls after pairing. */
|
|
107
|
+
withDevice(deviceId: string): ApiClient;
|
|
108
|
+
private url;
|
|
109
|
+
/** POST a device-signed body. Returns the raw status + parsed JSON (or null). */
|
|
110
|
+
private postSigned;
|
|
111
|
+
pairInit(input: PairInit): Promise<PairInitResult>;
|
|
112
|
+
pairStatus(code: string): Promise<PairStatus>;
|
|
113
|
+
getIdentity(): Promise<IdentityResult>;
|
|
114
|
+
createApproval(domain: string, action: string, resource?: string): Promise<ApprovalResult>;
|
|
115
|
+
requestApprovalOOB(domain: string, action: string, resource?: string): Promise<OOBRequestResult>;
|
|
116
|
+
pollApprovalRequest(code: string): Promise<ApprovalRequestStatus>;
|
|
117
|
+
sign(domain: string, action: string, resource?: string): Promise<SignResult>;
|
|
118
|
+
/**
|
|
119
|
+
* Open a standing-access request (Stage 2b): ask an org admin to grant this agent
|
|
120
|
+
* lasting authorization for (domain, action, optional resource), with an optional
|
|
121
|
+
* reason. Enterprise-only — a consumer/no-org device gets `no_org`.
|
|
122
|
+
*/
|
|
123
|
+
requestAccess(domain: string, action: string, resource?: string, reason?: string): Promise<AccessRequestResult>;
|
|
124
|
+
/** Map a raw response to a stable reason string (network, http_NNN, or backend reason). */
|
|
125
|
+
private reasonOf;
|
|
126
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Signed HTTP client to the Tier Two backend. This is the device role: it builds a
|
|
2
|
+
// device-signed body (a transport ENVELOPE — purpose/nonce/ts — plus, for scoped
|
|
3
|
+
// endpoints, a ScopedAction payload), signs canonicalJSON(body) with the device's
|
|
4
|
+
// Ed25519 key, and sends X-VH-Device + X-VH-Signature exactly as lib/deviceAuth.ts
|
|
5
|
+
// verifies them (see apps/web/scripts/sign-e2e.ts — the proven reference). Every
|
|
6
|
+
// signed call carries a fresh one-time nonce + current ts and a `purpose` that binds
|
|
7
|
+
// it to one endpoint. All methods return discriminated unions and never throw on
|
|
8
|
+
// HTTP/network failure (fail-closed).
|
|
9
|
+
import { randomBytes, sign } from "node:crypto";
|
|
10
|
+
import { canonicalJSON, CLIENT_META_HEADER, DEVICE_HEADER, SIGNATURE_HEADER, } from "@tiertwo/shared";
|
|
11
|
+
/** A fresh transport envelope: purpose + random 16B nonce (base64url) + current ts. */
|
|
12
|
+
export function freshEnvelope(purpose) {
|
|
13
|
+
return {
|
|
14
|
+
purpose,
|
|
15
|
+
nonce: randomBytes(16).toString("base64url"),
|
|
16
|
+
ts: Date.now(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** A fresh envelope + a ScopedAction payload for the action-scoped endpoints. */
|
|
20
|
+
export function freshScopedAction(purpose, domain, action, resource) {
|
|
21
|
+
return {
|
|
22
|
+
...freshEnvelope(purpose),
|
|
23
|
+
domain,
|
|
24
|
+
action,
|
|
25
|
+
...(resource !== undefined ? { resource } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** base64url Ed25519 signature over canonicalJSON(body) — the X-VH-Signature value. */
|
|
29
|
+
export function signBody(privateKey, body) {
|
|
30
|
+
return sign(null, Buffer.from(canonicalJSON(body), "utf8"), privateKey).toString("base64url");
|
|
31
|
+
}
|
|
32
|
+
export class ApiClient {
|
|
33
|
+
apiUrl;
|
|
34
|
+
privateKey;
|
|
35
|
+
deviceId;
|
|
36
|
+
fetchImpl;
|
|
37
|
+
clientMeta;
|
|
38
|
+
constructor(opts) {
|
|
39
|
+
this.apiUrl = opts.apiUrl.replace(/\/$/, "");
|
|
40
|
+
this.privateKey = opts.privateKey;
|
|
41
|
+
this.deviceId = opts.deviceId;
|
|
42
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
43
|
+
this.clientMeta = opts.clientMeta;
|
|
44
|
+
}
|
|
45
|
+
/** A copy bound to a paired deviceId, for signed calls after pairing. */
|
|
46
|
+
withDevice(deviceId) {
|
|
47
|
+
return new ApiClient({
|
|
48
|
+
apiUrl: this.apiUrl,
|
|
49
|
+
privateKey: this.privateKey,
|
|
50
|
+
deviceId,
|
|
51
|
+
fetchImpl: this.fetchImpl,
|
|
52
|
+
clientMeta: this.clientMeta,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
url(path) {
|
|
56
|
+
return `${this.apiUrl}${path}`;
|
|
57
|
+
}
|
|
58
|
+
/** POST a device-signed body. Returns the raw status + parsed JSON (or null). */
|
|
59
|
+
async postSigned(path,
|
|
60
|
+
// `reason` rides only on the access-request body (Stage 2b); it never touches the
|
|
61
|
+
// shared ScopedAction type. deviceAuth verifies the signature over the full body
|
|
62
|
+
// and asAccessRequest permits it, so the signed reason survives.
|
|
63
|
+
body) {
|
|
64
|
+
if (!this.deviceId)
|
|
65
|
+
throw new Error("postSigned requires a paired deviceId");
|
|
66
|
+
try {
|
|
67
|
+
const res = await this.fetchImpl(this.url(path), {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
"content-type": "application/json",
|
|
71
|
+
[DEVICE_HEADER]: this.deviceId,
|
|
72
|
+
[SIGNATURE_HEADER]: signBody(this.privateKey, body),
|
|
73
|
+
// Unsigned, informational (Stage 4.9) — the signed body stays
|
|
74
|
+
// byte-identical whether or not metadata rides along.
|
|
75
|
+
...(this.clientMeta
|
|
76
|
+
? { [CLIENT_META_HEADER]: JSON.stringify(this.clientMeta) }
|
|
77
|
+
: {}),
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
});
|
|
81
|
+
const json = (await res.json().catch(() => null));
|
|
82
|
+
return { status: res.status, json };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return { status: 0 }; // network/transport failure
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// --- Pairing (public, unsigned — no deviceId yet) -----------------------------
|
|
89
|
+
async pairInit(input) {
|
|
90
|
+
try {
|
|
91
|
+
const res = await this.fetchImpl(this.url("/api/devices/pair/init"), {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "content-type": "application/json" },
|
|
94
|
+
body: JSON.stringify(input),
|
|
95
|
+
});
|
|
96
|
+
const json = (await res.json().catch(() => null));
|
|
97
|
+
if (res.status === 201 && json) {
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
code: String(json.code),
|
|
101
|
+
verificationNumber: String(json.verificationNumber),
|
|
102
|
+
url: String(json.url),
|
|
103
|
+
expiresAt: String(json.expiresAt),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { ok: false, reason: String(json?.reason ?? `http_${res.status}`) };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { ok: false, reason: "network" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async pairStatus(code) {
|
|
113
|
+
try {
|
|
114
|
+
const res = await this.fetchImpl(this.url(`/api/devices/pair/status?code=${encodeURIComponent(code)}`));
|
|
115
|
+
const json = (await res.json().catch(() => null));
|
|
116
|
+
if (json?.status === "confirmed" && json.deviceId) {
|
|
117
|
+
return { status: "confirmed", deviceId: json.deviceId };
|
|
118
|
+
}
|
|
119
|
+
if (json?.status === "pending" || json?.status === "expired" || json?.status === "not_found") {
|
|
120
|
+
return { status: json.status };
|
|
121
|
+
}
|
|
122
|
+
return { status: "error" };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return { status: "error" };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// --- Signed calls (require a paired deviceId) ---------------------------------
|
|
129
|
+
async getIdentity() {
|
|
130
|
+
// /api/identity takes an envelope-only signed body (purpose:"identity") — no
|
|
131
|
+
// domain/action, since the read carries no application payload.
|
|
132
|
+
const res = await this.postSigned("/api/identity", freshEnvelope("identity"));
|
|
133
|
+
if (res.status === 200 && "json" in res && res.json) {
|
|
134
|
+
const j = res.json;
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
verified: Boolean(j.verified),
|
|
138
|
+
country: j.country ?? null,
|
|
139
|
+
age_over_18: Boolean(j.age_over_18),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { ok: false, reason: this.reasonOf(res) };
|
|
143
|
+
}
|
|
144
|
+
async createApproval(domain, action, resource) {
|
|
145
|
+
const res = await this.postSigned("/api/approvals", freshScopedAction("approve", domain, action, resource));
|
|
146
|
+
if (res.status === 201)
|
|
147
|
+
return { ok: true };
|
|
148
|
+
return { ok: false, reason: this.reasonOf(res) };
|
|
149
|
+
}
|
|
150
|
+
async requestApprovalOOB(domain, action, resource) {
|
|
151
|
+
const res = await this.postSigned("/api/approvals/request", freshScopedAction("approve_request", domain, action, resource));
|
|
152
|
+
if (res.status === 201 && "json" in res && res.json) {
|
|
153
|
+
const j = res.json;
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
requestId: String(j.requestId),
|
|
157
|
+
code: String(j.code),
|
|
158
|
+
url: String(j.url),
|
|
159
|
+
expiresAt: String(j.expiresAt),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return { ok: false, reason: this.reasonOf(res) };
|
|
163
|
+
}
|
|
164
|
+
async pollApprovalRequest(code) {
|
|
165
|
+
try {
|
|
166
|
+
const res = await this.fetchImpl(this.url(`/api/approvals/request/status?code=${encodeURIComponent(code)}`));
|
|
167
|
+
const json = (await res.json().catch(() => null));
|
|
168
|
+
if (json?.status === "pending" ||
|
|
169
|
+
json?.status === "confirmed" ||
|
|
170
|
+
json?.status === "expired" ||
|
|
171
|
+
json?.status === "not_found") {
|
|
172
|
+
return { status: json.status };
|
|
173
|
+
}
|
|
174
|
+
return { status: "error" };
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return { status: "error" };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async sign(domain, action, resource) {
|
|
181
|
+
const res = await this.postSigned("/api/sign", freshScopedAction("sign", domain, action, resource));
|
|
182
|
+
if (res.status === 200 && "json" in res && res.json && typeof res.json.token === "string") {
|
|
183
|
+
return { ok: true, token: res.json.token };
|
|
184
|
+
}
|
|
185
|
+
const reason = this.reasonOf(res);
|
|
186
|
+
if (reason === "not_verified" ||
|
|
187
|
+
reason === "access_required" ||
|
|
188
|
+
reason === "unauthorized" ||
|
|
189
|
+
reason === "bad_request" ||
|
|
190
|
+
reason === "server_error" ||
|
|
191
|
+
reason === "network") {
|
|
192
|
+
return { ok: false, reason };
|
|
193
|
+
}
|
|
194
|
+
return { ok: false, reason: "server_error" };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Open a standing-access request (Stage 2b): ask an org admin to grant this agent
|
|
198
|
+
* lasting authorization for (domain, action, optional resource), with an optional
|
|
199
|
+
* reason. Enterprise-only — a consumer/no-org device gets `no_org`.
|
|
200
|
+
*/
|
|
201
|
+
async requestAccess(domain, action, resource, reason) {
|
|
202
|
+
const body = {
|
|
203
|
+
...freshScopedAction("access_request", domain, action, resource),
|
|
204
|
+
...(reason !== undefined ? { reason } : {}),
|
|
205
|
+
};
|
|
206
|
+
const res = await this.postSigned("/api/access/request", body);
|
|
207
|
+
if (res.status === 201 && "json" in res && res.json) {
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
requestId: String(res.json.requestId),
|
|
211
|
+
status: String(res.json.status),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return { ok: false, reason: this.reasonOf(res) };
|
|
215
|
+
}
|
|
216
|
+
/** Map a raw response to a stable reason string (network, http_NNN, or backend reason). */
|
|
217
|
+
reasonOf(res) {
|
|
218
|
+
if (res.status === 0)
|
|
219
|
+
return "network";
|
|
220
|
+
if (res.status === 401)
|
|
221
|
+
return "unauthorized";
|
|
222
|
+
if (res.status === 500)
|
|
223
|
+
return "server_error";
|
|
224
|
+
const reason = res.json?.reason;
|
|
225
|
+
return typeof reason === "string" ? reason : `http_${res.status}`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ApiClient } from "./api.js";
|
|
2
|
+
/** A scoped capability the human is asked to authorize. `resource` is optional. */
|
|
3
|
+
export type ScopedInput = {
|
|
4
|
+
domain: string;
|
|
5
|
+
action: string;
|
|
6
|
+
resource?: string;
|
|
7
|
+
};
|
|
8
|
+
/** The slice of the MCP host we use — kept small so it's trivial to mock. */
|
|
9
|
+
export type ElicitFormRequest = {
|
|
10
|
+
message: string;
|
|
11
|
+
requestedSchema: {
|
|
12
|
+
type: "object";
|
|
13
|
+
properties: Record<string, unknown>;
|
|
14
|
+
required?: string[];
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export type ElicitOutcome = {
|
|
18
|
+
action: string;
|
|
19
|
+
content?: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
export type ApprovalHost = {
|
|
22
|
+
getClientCapabilities(): {
|
|
23
|
+
elicitation?: unknown;
|
|
24
|
+
} | undefined;
|
|
25
|
+
elicitInput(params: ElicitFormRequest): Promise<ElicitOutcome>;
|
|
26
|
+
};
|
|
27
|
+
export type Notify = (message: string) => void;
|
|
28
|
+
export type ApprovalOutcome = {
|
|
29
|
+
approved: true;
|
|
30
|
+
channel: "elicitation" | "oob";
|
|
31
|
+
} | {
|
|
32
|
+
approved: false;
|
|
33
|
+
reason: string;
|
|
34
|
+
oobUrl?: string;
|
|
35
|
+
};
|
|
36
|
+
export type EnsureApprovalDeps = {
|
|
37
|
+
notify?: Notify;
|
|
38
|
+
pollIntervalMs?: number;
|
|
39
|
+
pollTimeoutMs?: number;
|
|
40
|
+
sleep?: (ms: number) => Promise<void>;
|
|
41
|
+
now?: () => number;
|
|
42
|
+
};
|
|
43
|
+
/** Does the connected host support MCP elicitation? */
|
|
44
|
+
export declare function detectElicitation(host: ApprovalHost): boolean;
|
|
45
|
+
/** Ask the host for a yes/no. Returns true only on an explicit accept+approve. */
|
|
46
|
+
export declare function elicitApproval(host: ApprovalHost, input: ScopedInput): Promise<boolean>;
|
|
47
|
+
/**
|
|
48
|
+
* Obtain a fresh, backend-held approval for (domain, action) through the host.
|
|
49
|
+
* Both request_human_approval and sign_signup_request call this independently, so
|
|
50
|
+
* neither can be skipped — calling sign directly still triggers the prompt.
|
|
51
|
+
*/
|
|
52
|
+
export declare function ensureApproval(host: ApprovalHost, api: ApiClient, input: ScopedInput, deps?: EnsureApprovalDeps): Promise<ApprovalOutcome>;
|
package/dist/approval.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const ACTION_PHRASE = {
|
|
2
|
+
create_account: "create an account",
|
|
3
|
+
};
|
|
4
|
+
function describeAction(action) {
|
|
5
|
+
return ACTION_PHRASE[action] ?? action;
|
|
6
|
+
}
|
|
7
|
+
/** "deploy" → "deploy", "deploy (svc-x)" when a resource scopes it. */
|
|
8
|
+
function describeScope(input) {
|
|
9
|
+
const verb = describeAction(input.action);
|
|
10
|
+
return input.resource ? `${verb} (${input.resource})` : verb;
|
|
11
|
+
}
|
|
12
|
+
/** Does the connected host support MCP elicitation? */
|
|
13
|
+
export function detectElicitation(host) {
|
|
14
|
+
try {
|
|
15
|
+
return Boolean(host.getClientCapabilities()?.elicitation);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Ask the host for a yes/no. Returns true only on an explicit accept+approve. */
|
|
22
|
+
export async function elicitApproval(host, input) {
|
|
23
|
+
const scope = describeScope(input);
|
|
24
|
+
const result = await host.elicitInput({
|
|
25
|
+
message: `Allow your agent to ${scope} on ${input.domain}? This authorizes one short-lived Tier Two token for ${input.domain}.`,
|
|
26
|
+
requestedSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
approve: {
|
|
30
|
+
type: "boolean",
|
|
31
|
+
title: "Approve",
|
|
32
|
+
description: `Approve ${scope} on ${input.domain}`,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ["approve"],
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
// accept + approve===true is the only yes; decline/cancel (or approve:false) is no.
|
|
39
|
+
return result.action === "accept" && result.content?.approve === true;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Obtain a fresh, backend-held approval for (domain, action) through the host.
|
|
43
|
+
* Both request_human_approval and sign_signup_request call this independently, so
|
|
44
|
+
* neither can be skipped — calling sign directly still triggers the prompt.
|
|
45
|
+
*/
|
|
46
|
+
export async function ensureApproval(host, api, input, deps = {}) {
|
|
47
|
+
if (detectElicitation(host)) {
|
|
48
|
+
let accepted;
|
|
49
|
+
try {
|
|
50
|
+
accepted = await elicitApproval(host, input);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// A host that mis-declares elicitation: fall through to OOB rather than crash.
|
|
54
|
+
return ensureViaOOB(api, input, { ...deps, forced: true });
|
|
55
|
+
}
|
|
56
|
+
if (!accepted)
|
|
57
|
+
return { approved: false, reason: "declined" };
|
|
58
|
+
const created = await api.createApproval(input.domain, input.action, input.resource);
|
|
59
|
+
if (!created.ok)
|
|
60
|
+
return { approved: false, reason: created.reason };
|
|
61
|
+
return { approved: true, channel: "elicitation" };
|
|
62
|
+
}
|
|
63
|
+
return ensureViaOOB(api, input, deps);
|
|
64
|
+
}
|
|
65
|
+
async function ensureViaOOB(api, input, deps) {
|
|
66
|
+
const notify = deps.notify ?? (() => { });
|
|
67
|
+
const intervalMs = deps.pollIntervalMs ?? 2000;
|
|
68
|
+
const timeoutMs = deps.pollTimeoutMs ?? 120_000;
|
|
69
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
70
|
+
const now = deps.now ?? Date.now;
|
|
71
|
+
const req = await api.requestApprovalOOB(input.domain, input.action, input.resource);
|
|
72
|
+
if (!req.ok)
|
|
73
|
+
return { approved: false, reason: req.reason };
|
|
74
|
+
notify(`Approve this request in your browser, then your agent will continue:\n ${req.url}`);
|
|
75
|
+
const deadline = now() + timeoutMs;
|
|
76
|
+
// Poll the NON-consuming status endpoint until the human confirms or it expires.
|
|
77
|
+
for (;;) {
|
|
78
|
+
const status = await api.pollApprovalRequest(req.code);
|
|
79
|
+
if (status.status === "confirmed")
|
|
80
|
+
return { approved: true, channel: "oob" };
|
|
81
|
+
if (status.status === "expired" || status.status === "not_found") {
|
|
82
|
+
return { approved: false, reason: "expired", oobUrl: req.url };
|
|
83
|
+
}
|
|
84
|
+
if (now() >= deadline) {
|
|
85
|
+
return { approved: false, reason: "timeout", oobUrl: req.url };
|
|
86
|
+
}
|
|
87
|
+
await sleep(intervalMs);
|
|
88
|
+
}
|
|
89
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DeviceClientMeta } from "@tiertwo/shared";
|
|
2
|
+
import { ApiClient } from "./api.js";
|
|
3
|
+
import { type PairDeps } from "./pairing.js";
|
|
4
|
+
import { type DeviceKey, type KeystoreDeps } from "./keystore.js";
|
|
5
|
+
export type PairCommandDeps = {
|
|
6
|
+
out?: (line: string) => void;
|
|
7
|
+
keystore?: KeystoreDeps;
|
|
8
|
+
/** Test seam: build the ApiClient (the real one signs with the device key). */
|
|
9
|
+
makeApi?: (apiUrl: string, key: DeviceKey, client: DeviceClientMeta) => ApiClient;
|
|
10
|
+
/** Poll timing overrides, passed through to pairDevice. */
|
|
11
|
+
pairDeps?: Partial<PairDeps>;
|
|
12
|
+
};
|
|
13
|
+
/** Run the `pair` subcommand. Returns the process exit code. */
|
|
14
|
+
export declare function runPairCommand(apiUrl: string, deps?: PairCommandDeps): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// `npx -y @tiertwo/mcp pair` — start the device-pairing ceremony directly from a
|
|
2
|
+
// terminal, without waiting for an agent host to invoke a tool. Unlike the MCP
|
|
3
|
+
// server path, stdout here is a real terminal (not the JSON-RPC channel), so
|
|
4
|
+
// instructions print straight to it. Reuses the exact same pairDevice ceremony;
|
|
5
|
+
// the no-capabilities host below just means the elicitation branch never fires.
|
|
6
|
+
import { hostname, release } from "node:os";
|
|
7
|
+
import { ApiClient } from "./api.js";
|
|
8
|
+
import { pairDevice } from "./pairing.js";
|
|
9
|
+
import { defaultDeps, getDeviceRecord, loadOrCreateKeypair, saveDeviceRecord, } from "./keystore.js";
|
|
10
|
+
const NO_HOST = {
|
|
11
|
+
getClientCapabilities: () => undefined,
|
|
12
|
+
elicitInput: () => Promise.reject(new Error("no MCP host in CLI mode")),
|
|
13
|
+
};
|
|
14
|
+
/** Run the `pair` subcommand. Returns the process exit code. */
|
|
15
|
+
export async function runPairCommand(apiUrl, deps = {}) {
|
|
16
|
+
const out = deps.out ?? ((line) => process.stdout.write(`${line}\n`));
|
|
17
|
+
const keystore = deps.keystore ?? defaultDeps();
|
|
18
|
+
const key = loadOrCreateKeypair(apiUrl, keystore);
|
|
19
|
+
const client = {
|
|
20
|
+
product: "tiertwo-mcp",
|
|
21
|
+
runtime: "local",
|
|
22
|
+
platform: `${process.platform} ${release()}`,
|
|
23
|
+
};
|
|
24
|
+
const api = deps.makeApi
|
|
25
|
+
? deps.makeApi(apiUrl, key, client)
|
|
26
|
+
: new ApiClient({ apiUrl, privateKey: key.privateKey, clientMeta: client });
|
|
27
|
+
// A local record only proves we paired once — the device may since have been
|
|
28
|
+
// revoked on the dashboard. Probe with a device-signed call: `unauthorized` is
|
|
29
|
+
// the backend's uniform answer for a revoked/unknown device, so on that we
|
|
30
|
+
// fall through and pair fresh (same keypair, new Device row).
|
|
31
|
+
const existing = getDeviceRecord(apiUrl, keystore);
|
|
32
|
+
if (existing) {
|
|
33
|
+
const probe = await api.withDevice(existing.deviceId).getIdentity();
|
|
34
|
+
if (probe.ok) {
|
|
35
|
+
out(`This machine is already paired (device ${existing.deviceId}, since ${existing.pairedAt}).`);
|
|
36
|
+
out(`Manage or revoke it from the dashboard: ${apiUrl}/dashboard`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
if (probe.reason !== "unauthorized") {
|
|
40
|
+
out(`Couldn't verify the existing pairing (${probe.reason}) — is ${apiUrl} reachable?`);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
out(`The previous pairing (device ${existing.deviceId}) is no longer valid — it was probably revoked. Starting a fresh pairing…`);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const { deviceId } = await pairDevice(NO_HOST, api, key.publicKeyB64url, {
|
|
47
|
+
hostname: hostname(),
|
|
48
|
+
client,
|
|
49
|
+
notify: out,
|
|
50
|
+
...deps.pairDeps,
|
|
51
|
+
});
|
|
52
|
+
saveDeviceRecord(apiUrl, { deviceId, pairedAt: new Date().toISOString() }, keystore);
|
|
53
|
+
out(`Paired as device ${deviceId}. Your agent can now use Tier Two tools from this machine.`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
out(`Pairing failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @tiertwo/mcp — the `npx @tiertwo/mcp` stdio MCP server (ARCHITECTURE §11). Gives an
|
|
3
|
+
// AI agent three tools — get_identity, request_human_approval, sign_request — after
|
|
4
|
+
// pairing the device to a verified human's account on first run (§8,
|
|
5
|
+
// poll-until-confirmed). The signing flow is un-skippable: approval is collected
|
|
6
|
+
// through the host and /api/sign independently requires a fresh one (§18.1).
|
|
7
|
+
//
|
|
8
|
+
// stdout IS the JSON-RPC channel — every human-facing line goes to stderr (and, best
|
|
9
|
+
// effort, an MCP logging notification). NEVER console.log here. (The one exception is
|
|
10
|
+
// `tiertwo-mcp pair`, which never starts the server — see cli.ts.)
|
|
11
|
+
import { hostname, release } from "node:os";
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { isValidAction, isValidResource, WELL_KNOWN_ACTIONS, } from "@tiertwo/shared";
|
|
16
|
+
import { ApiClient } from "./api.js";
|
|
17
|
+
import { runPairCommand } from "./cli.js";
|
|
18
|
+
import { pairDevice } from "./pairing.js";
|
|
19
|
+
import { getDeviceRecord, loadOrCreateKeypair, saveDeviceRecord, } from "./keystore.js";
|
|
20
|
+
import { getIdentityTool, requestAccessTool, requestHumanApprovalTool, signRequestTool, } from "./tools.js";
|
|
21
|
+
// Common actions surfaced as a hint (not an allow-list — any valid action works).
|
|
22
|
+
const ACTION_HINT = `Common actions: ${WELL_KNOWN_ACTIONS.join(", ")} (any valid action string is accepted).`;
|
|
23
|
+
const VH_API_URL = (process.env.VH_API_URL ?? "https://trytiertwo.com").replace(/\/$/, "");
|
|
24
|
+
function toResult(o) {
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text", text: JSON.stringify(o.data) }],
|
|
27
|
+
isError: o.isError ?? false,
|
|
28
|
+
structuredContent: o.data,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async function main() {
|
|
32
|
+
// `npx -y @tiertwo/mcp pair` — pair this machine from a terminal right now,
|
|
33
|
+
// instead of waiting for the first tool call from an agent host.
|
|
34
|
+
if (process.argv[2] === "pair") {
|
|
35
|
+
process.exitCode = await runPairCommand(VH_API_URL);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const server = new McpServer({ name: "tiertwo-mcp", version: "0.1.0" }, { capabilities: { logging: {}, tools: {} } });
|
|
39
|
+
const notify = (message) => {
|
|
40
|
+
process.stderr.write(`[tiertwo-mcp] ${message}\n`);
|
|
41
|
+
void server.server.sendLoggingMessage({ level: "info", data: message }).catch(() => { });
|
|
42
|
+
};
|
|
43
|
+
// Adapter: the small slice of the connected host that approval.ts needs.
|
|
44
|
+
const host = {
|
|
45
|
+
getClientCapabilities: () => server.server.getClientCapabilities(),
|
|
46
|
+
elicitInput: (params) => server.server.elicitInput(params),
|
|
47
|
+
};
|
|
48
|
+
const key = loadOrCreateKeypair(VH_API_URL);
|
|
49
|
+
let pairedClient = null;
|
|
50
|
+
/** Client-claimed directory metadata (Stage 4.9): the connected MCP HOST
|
|
51
|
+
* (claude-code, cursor…) from the initialize handshake, falling back to
|
|
52
|
+
* ourselves; a stdio child process runs on the user's machine by
|
|
53
|
+
* construction → runtime "local". Informational only. */
|
|
54
|
+
function clientMeta() {
|
|
55
|
+
const hostInfo = server.server.getClientVersion();
|
|
56
|
+
return {
|
|
57
|
+
product: hostInfo?.name ?? "tiertwo-mcp",
|
|
58
|
+
...(hostInfo?.version ? { productVersion: hostInfo.version } : {}),
|
|
59
|
+
runtime: "local",
|
|
60
|
+
platform: `${process.platform} ${release()}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// Resolve a device-bound client, pairing first (poll-until-confirmed) if needed.
|
|
64
|
+
// ensurePaired runs inside tool handlers — i.e. after initialize — so the host's
|
|
65
|
+
// clientInfo is available for the metadata at both pairing and refresh time.
|
|
66
|
+
async function ensurePaired() {
|
|
67
|
+
if (pairedClient)
|
|
68
|
+
return pairedClient;
|
|
69
|
+
const meta = clientMeta();
|
|
70
|
+
const base = new ApiClient({
|
|
71
|
+
apiUrl: VH_API_URL,
|
|
72
|
+
privateKey: key.privateKey,
|
|
73
|
+
clientMeta: meta,
|
|
74
|
+
});
|
|
75
|
+
const existing = getDeviceRecord(VH_API_URL);
|
|
76
|
+
if (existing) {
|
|
77
|
+
pairedClient = base.withDevice(existing.deviceId);
|
|
78
|
+
return pairedClient;
|
|
79
|
+
}
|
|
80
|
+
const { deviceId } = await pairDevice(host, base, key.publicKeyB64url, {
|
|
81
|
+
hostname: hostname(),
|
|
82
|
+
client: meta,
|
|
83
|
+
notify,
|
|
84
|
+
});
|
|
85
|
+
saveDeviceRecord(VH_API_URL, { deviceId, pairedAt: new Date().toISOString() });
|
|
86
|
+
pairedClient = base.withDevice(deviceId);
|
|
87
|
+
return pairedClient;
|
|
88
|
+
}
|
|
89
|
+
// Every tool ensures pairing first and converts thrown errors into tool errors.
|
|
90
|
+
async function guarded(run) {
|
|
91
|
+
try {
|
|
92
|
+
const api = await ensurePaired();
|
|
93
|
+
return toResult(await run(api));
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return toResult({
|
|
97
|
+
data: { error: e instanceof Error ? e.message : "internal_error" },
|
|
98
|
+
isError: true,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
server.registerTool("get_identity", {
|
|
103
|
+
title: "Get verified-human identity",
|
|
104
|
+
description: "Return the paired human's verified status and identity claims (verified, country, age_over_18). Use this to reason about eligibility before requesting a signup.",
|
|
105
|
+
}, () => guarded((api) => getIdentityTool(api)));
|
|
106
|
+
server.registerTool("request_human_approval", {
|
|
107
|
+
title: "Request human approval",
|
|
108
|
+
description: `Ask the human to approve a scoped capability (domain, action, optional resource) through the host. Returns whether they approved. Never auto-approves; the approval is one-time and short-lived. ${ACTION_HINT}`,
|
|
109
|
+
inputSchema: {
|
|
110
|
+
domain: z.string().min(1),
|
|
111
|
+
action: z.string().refine(isValidAction, "invalid action"),
|
|
112
|
+
resource: z.string().refine(isValidResource, "invalid resource").optional(),
|
|
113
|
+
},
|
|
114
|
+
}, ({ domain, action, resource }) => guarded((api) => requestHumanApprovalTool(host, api, { domain, action, resource }, { notify })));
|
|
115
|
+
server.registerTool("sign_request", {
|
|
116
|
+
title: "Sign a verified-human action request",
|
|
117
|
+
description: `Mint a short-lived token proving a verified human authorized a scoped capability (domain, action, optional resource). If an org admin has granted standing access for it, this mints with no prompt; otherwise it requires a fresh human approval first, so signing can never skip authorization. If it returns access_required, call request_access to ask an admin for a standing grant. Returns { token } for the merchant/enforcer to verify. Consumer signup is action:"create_account". ${ACTION_HINT}`,
|
|
118
|
+
inputSchema: {
|
|
119
|
+
domain: z.string().min(1),
|
|
120
|
+
action: z.string().refine(isValidAction, "invalid action"),
|
|
121
|
+
resource: z.string().refine(isValidResource, "invalid resource").optional(),
|
|
122
|
+
},
|
|
123
|
+
}, ({ domain, action, resource }) => guarded((api) => signRequestTool(host, api, { domain, action, resource }, { notify })));
|
|
124
|
+
server.registerTool("request_access", {
|
|
125
|
+
title: "Request standing access for an action",
|
|
126
|
+
description: `Ask an org admin to grant this agent STANDING authorization for a scoped capability (domain, action, optional resource), with an optional reason. Once granted, sign_request mints tokens for it with no per-action prompt until the grant expires or is revoked. Use this when sign_request returns access_required. Enterprise-only: a consumer/no-org device gets no_org. ${ACTION_HINT}`,
|
|
127
|
+
inputSchema: {
|
|
128
|
+
domain: z.string().min(1),
|
|
129
|
+
action: z.string().refine(isValidAction, "invalid action"),
|
|
130
|
+
resource: z.string().refine(isValidResource, "invalid resource").optional(),
|
|
131
|
+
reason: z.string().max(500).optional(),
|
|
132
|
+
},
|
|
133
|
+
}, ({ domain, action, resource, reason }) => guarded((api) => requestAccessTool(api, { domain, action, resource, reason })));
|
|
134
|
+
await server.connect(new StdioServerTransport());
|
|
135
|
+
notify(`tiertwo-mcp ready (API ${VH_API_URL})`);
|
|
136
|
+
}
|
|
137
|
+
main().catch((e) => {
|
|
138
|
+
process.stderr.write(`[tiertwo-mcp] fatal: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}\n`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type KeyObject } from "node:crypto";
|
|
2
|
+
/** A keychain entry — the minimal surface keystore needs (real or mocked in tests). */
|
|
3
|
+
export type KeyringEntry = {
|
|
4
|
+
getPassword(): string | null;
|
|
5
|
+
setPassword(password: string): void;
|
|
6
|
+
};
|
|
7
|
+
export type KeyringFactory = (service: string, account: string) => KeyringEntry;
|
|
8
|
+
export type KeystoreDeps = {
|
|
9
|
+
keyring: KeyringFactory;
|
|
10
|
+
configDir: string;
|
|
11
|
+
};
|
|
12
|
+
export type DeviceKey = {
|
|
13
|
+
privateKey: KeyObject;
|
|
14
|
+
/** JWK `x`: base64url raw Ed25519 public key, as stored in Device.publicKey. */
|
|
15
|
+
publicKeyB64url: string;
|
|
16
|
+
};
|
|
17
|
+
export type DeviceRecord = {
|
|
18
|
+
deviceId: string;
|
|
19
|
+
pairedAt: string;
|
|
20
|
+
};
|
|
21
|
+
/** Real OS keychain + real config dir. Tests pass their own deps. */
|
|
22
|
+
export declare function defaultDeps(): KeystoreDeps;
|
|
23
|
+
/** Derive the JWK `x` (base64url raw 32B) the backend verifies against. */
|
|
24
|
+
export declare function publicKeyB64url(privateKey: KeyObject): string;
|
|
25
|
+
/**
|
|
26
|
+
* Load the device private key from the keychain, generating + persisting a fresh
|
|
27
|
+
* Ed25519 keypair on first run. The public key is always derived, never stored.
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadOrCreateKeypair(apiUrl: string, deps?: KeystoreDeps): DeviceKey;
|
|
30
|
+
/** The persisted device record for this API host, or null if not yet paired. */
|
|
31
|
+
export declare function getDeviceRecord(apiUrl: string, deps?: KeystoreDeps): DeviceRecord | null;
|
|
32
|
+
/** Persist (merge) the device record for this API host after pairing. */
|
|
33
|
+
export declare function saveDeviceRecord(apiUrl: string, record: DeviceRecord, deps?: KeystoreDeps): void;
|
package/dist/keystore.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Local key + device state for the MCP. The Ed25519 PRIVATE KEY lives in the OS
|
|
2
|
+
// keychain (macOS Keychain / Windows Credential Manager / Linux Secret Service via
|
|
3
|
+
// @napi-rs/keyring) as a PKCS8 PEM — the one secret. The non-secret pointers
|
|
4
|
+
// (deviceId, pairedAt) live in a JSON config file under $XDG_CONFIG_HOME/tiertwo.
|
|
5
|
+
//
|
|
6
|
+
// Both keychain account and config record are namespaced by the API host, so a prod
|
|
7
|
+
// install and a local-dev VH_API_URL on the same machine never collide. The public
|
|
8
|
+
// key is always DERIVED from the private key as the JWK `x` (base64url raw 32B) —
|
|
9
|
+
// exactly what the backend stores in Device.publicKey and verifies in
|
|
10
|
+
// lib/deviceAuth.ts — so it can never drift from what was paired.
|
|
11
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, } from "node:crypto";
|
|
12
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { Entry } from "@napi-rs/keyring";
|
|
16
|
+
const KEYRING_SERVICE = "tiertwo-mcp";
|
|
17
|
+
const KEY_ACCOUNT_BASE = "device-private-key";
|
|
18
|
+
function defaultConfigDir() {
|
|
19
|
+
const base = process.env.XDG_CONFIG_HOME ||
|
|
20
|
+
(process.platform === "win32" ? process.env.APPDATA : undefined) ||
|
|
21
|
+
join(homedir(), ".config");
|
|
22
|
+
return join(base, "tiertwo");
|
|
23
|
+
}
|
|
24
|
+
/** Real OS keychain + real config dir. Tests pass their own deps. */
|
|
25
|
+
export function defaultDeps() {
|
|
26
|
+
return {
|
|
27
|
+
keyring: (service, account) => new Entry(service, account),
|
|
28
|
+
configDir: defaultConfigDir(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Stable per-API-host namespace so prod + local-dev installs don't collide. */
|
|
32
|
+
function apiHost(apiUrl) {
|
|
33
|
+
try {
|
|
34
|
+
return new URL(apiUrl).host || "default";
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return "default";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Derive the JWK `x` (base64url raw 32B) the backend verifies against. */
|
|
41
|
+
export function publicKeyB64url(privateKey) {
|
|
42
|
+
const jwk = createPublicKey(privateKey).export({ format: "jwk" });
|
|
43
|
+
return jwk.x;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Load the device private key from the keychain, generating + persisting a fresh
|
|
47
|
+
* Ed25519 keypair on first run. The public key is always derived, never stored.
|
|
48
|
+
*/
|
|
49
|
+
export function loadOrCreateKeypair(apiUrl, deps = defaultDeps()) {
|
|
50
|
+
const entry = deps.keyring(KEYRING_SERVICE, `${KEY_ACCOUNT_BASE}:${apiHost(apiUrl)}`);
|
|
51
|
+
let pem = null;
|
|
52
|
+
try {
|
|
53
|
+
pem = entry.getPassword();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
pem = null; // locked/unavailable keychain → treat as no key (will regenerate)
|
|
57
|
+
}
|
|
58
|
+
if (pem) {
|
|
59
|
+
const privateKey = createPrivateKey(pem);
|
|
60
|
+
return { privateKey, publicKeyB64url: publicKeyB64url(privateKey) };
|
|
61
|
+
}
|
|
62
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
63
|
+
entry.setPassword(privateKey.export({ type: "pkcs8", format: "pem" }));
|
|
64
|
+
return { privateKey, publicKeyB64url: publicKeyB64url(privateKey) };
|
|
65
|
+
}
|
|
66
|
+
function configPath(deps) {
|
|
67
|
+
return join(deps.configDir, "config.json");
|
|
68
|
+
}
|
|
69
|
+
function readConfigFile(deps) {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(readFileSync(configPath(deps), "utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return {}; // missing or corrupt → empty config
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The persisted device record for this API host, or null if not yet paired. */
|
|
78
|
+
export function getDeviceRecord(apiUrl, deps = defaultDeps()) {
|
|
79
|
+
return readConfigFile(deps).byHost?.[apiHost(apiUrl)] ?? null;
|
|
80
|
+
}
|
|
81
|
+
/** Persist (merge) the device record for this API host after pairing. */
|
|
82
|
+
export function saveDeviceRecord(apiUrl, record, deps = defaultDeps()) {
|
|
83
|
+
const cfg = readConfigFile(deps);
|
|
84
|
+
const byHost = { ...(cfg.byHost ?? {}), [apiHost(apiUrl)]: record };
|
|
85
|
+
mkdirSync(deps.configDir, { recursive: true });
|
|
86
|
+
writeFileSync(configPath(deps), JSON.stringify({ ...cfg, byHost }, null, 2), {
|
|
87
|
+
mode: 0o600,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { DeviceClientMeta } from "@tiertwo/shared";
|
|
2
|
+
import type { ApiClient } from "./api.js";
|
|
3
|
+
import { type ApprovalHost, type Notify } from "./approval.js";
|
|
4
|
+
export type PairDeps = {
|
|
5
|
+
hostname: string;
|
|
6
|
+
/** Client-claimed directory metadata (Stage 4.9), sent in the pair/init body. */
|
|
7
|
+
client?: DeviceClientMeta;
|
|
8
|
+
notify?: Notify;
|
|
9
|
+
pollIntervalMs?: number;
|
|
10
|
+
pollTimeoutMs?: number;
|
|
11
|
+
sleep?: (ms: number) => Promise<void>;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
};
|
|
14
|
+
export type PairResult = {
|
|
15
|
+
deviceId: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function pairDevice(host: ApprovalHost, base: ApiClient, publicKeyB64url: string, deps: PairDeps): Promise<PairResult>;
|
package/dist/pairing.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { detectElicitation } from "./approval.js";
|
|
2
|
+
function instructionsFor(pairUrl, code, verificationNumber) {
|
|
3
|
+
return (`This device isn't paired with Tier Two yet. To authorize it:\n` +
|
|
4
|
+
` 1. Open ${pairUrl}\n` +
|
|
5
|
+
` 2. Check the verification number shows ${verificationNumber}\n` +
|
|
6
|
+
` 3. Click "Confirm & pair" (your code ${code} is pre-filled).`);
|
|
7
|
+
}
|
|
8
|
+
export async function pairDevice(host, base, publicKeyB64url, deps) {
|
|
9
|
+
const notify = deps.notify ?? (() => { });
|
|
10
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
11
|
+
const now = deps.now ?? Date.now;
|
|
12
|
+
const intervalMs = deps.pollIntervalMs ?? 2000;
|
|
13
|
+
const timeoutMs = deps.pollTimeoutMs ?? 10 * 60_000;
|
|
14
|
+
const init = await base.pairInit({
|
|
15
|
+
publicKey: publicKeyB64url,
|
|
16
|
+
name: deps.hostname,
|
|
17
|
+
...(deps.client ? { client: deps.client } : {}),
|
|
18
|
+
});
|
|
19
|
+
if (!init.ok)
|
|
20
|
+
throw new Error(`pairing init failed: ${init.reason}`);
|
|
21
|
+
const pairUrl = `${init.url}?code=${encodeURIComponent(init.code)}`;
|
|
22
|
+
const instructions = instructionsFor(pairUrl, init.code, init.verificationNumber);
|
|
23
|
+
// Always emit to stderr + logging (fallback for hosts without elicitation).
|
|
24
|
+
notify(instructions);
|
|
25
|
+
// Inline dialog so the human actually sees the code (not just in the server log).
|
|
26
|
+
if (detectElicitation(host)) {
|
|
27
|
+
const res = await host
|
|
28
|
+
.elicitInput({
|
|
29
|
+
message: instructions,
|
|
30
|
+
requestedSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
paired: {
|
|
34
|
+
type: "boolean",
|
|
35
|
+
title: "I've paired this device",
|
|
36
|
+
description: "Confirm on the dashboard, then check this to continue.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
required: ["paired"],
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
.catch(() => ({ action: "cancel" }));
|
|
43
|
+
if (res.action !== "accept") {
|
|
44
|
+
throw new Error("pairing was cancelled — ask again to retry");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// The dashboard confirm binds the device; poll until it does (the click above is
|
|
48
|
+
// only a hint — we never trust it, we verify via status).
|
|
49
|
+
const deadline = now() + timeoutMs;
|
|
50
|
+
for (;;) {
|
|
51
|
+
const status = await base.pairStatus(init.code);
|
|
52
|
+
if (status.status === "confirmed") {
|
|
53
|
+
notify("Device paired. Continuing…");
|
|
54
|
+
return { deviceId: status.deviceId };
|
|
55
|
+
}
|
|
56
|
+
if (status.status === "expired" || status.status === "not_found") {
|
|
57
|
+
throw new Error("pairing code expired before confirmation — ask again to retry");
|
|
58
|
+
}
|
|
59
|
+
if (now() >= deadline)
|
|
60
|
+
throw new Error("pairing timed out — ask again to retry");
|
|
61
|
+
await sleep(intervalMs);
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ApiClient } from "./api.js";
|
|
2
|
+
import { type ApprovalHost, type EnsureApprovalDeps, type ScopedInput } from "./approval.js";
|
|
3
|
+
export type ToolOutcome = {
|
|
4
|
+
data: Record<string, unknown>;
|
|
5
|
+
isError?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare function getIdentityTool(api: ApiClient): Promise<ToolOutcome>;
|
|
8
|
+
export declare function requestHumanApprovalTool(host: ApprovalHost, api: ApiClient, input: ScopedInput, deps?: EnsureApprovalDeps): Promise<ToolOutcome>;
|
|
9
|
+
export declare function signRequestTool(host: ApprovalHost, api: ApiClient, input: ScopedInput, deps?: EnsureApprovalDeps): Promise<ToolOutcome>;
|
|
10
|
+
export type AccessRequestInput = {
|
|
11
|
+
domain: string;
|
|
12
|
+
action: string;
|
|
13
|
+
resource?: string;
|
|
14
|
+
reason?: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Open a standing-access request (Stage 2b): ask an org admin to grant this agent
|
|
18
|
+
* lasting authorization for a scoped capability. Unlike request_human_approval (a
|
|
19
|
+
* one-time yes for a single mint), a granted request lets sign_request mint with no
|
|
20
|
+
* prompt until it expires or is revoked. No host elicitation — the human decision
|
|
21
|
+
* happens later in the org's dashboard.
|
|
22
|
+
*/
|
|
23
|
+
export declare function requestAccessTool(api: ApiClient, input: AccessRequestInput): Promise<ToolOutcome>;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ensureApproval, } from "./approval.js";
|
|
2
|
+
// Surfaced when no standing grant or approval authorizes the action, steering the
|
|
3
|
+
// agent to the standing-access path instead of retrying blindly.
|
|
4
|
+
const ACCESS_REQUIRED_HINT = "No standing grant or approval authorizes this action. Run request_access(domain, action, resource) and ask an org admin to grant it.";
|
|
5
|
+
/** "deploy (svc-x)" / "create_account" — for the standing-grant notify line. */
|
|
6
|
+
function scopeLabel(input) {
|
|
7
|
+
return input.resource ? `${input.action} (${input.resource})` : input.action;
|
|
8
|
+
}
|
|
9
|
+
export async function getIdentityTool(api) {
|
|
10
|
+
const res = await api.getIdentity();
|
|
11
|
+
if (!res.ok)
|
|
12
|
+
return { data: { error: res.reason }, isError: true };
|
|
13
|
+
return {
|
|
14
|
+
data: { verified: res.verified, country: res.country, age_over_18: res.age_over_18 },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export async function requestHumanApprovalTool(host, api, input, deps = {}) {
|
|
18
|
+
const outcome = await ensureApproval(host, api, input, deps);
|
|
19
|
+
if (!outcome.approved) {
|
|
20
|
+
return {
|
|
21
|
+
data: { approved: false, reason: outcome.reason, ...(outcome.oobUrl ? { url: outcome.oobUrl } : {}) },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return { data: { approved: true, channel: outcome.channel } };
|
|
25
|
+
}
|
|
26
|
+
export async function signRequestTool(host, api, input, deps = {}) {
|
|
27
|
+
// 1. Sign-first: a standing grant authorizes the mint with no prompt. This is the
|
|
28
|
+
// headline of Stage 2b — a covered action just works. The call consumes nothing
|
|
29
|
+
// when it fails closed, so it's a safe probe.
|
|
30
|
+
const first = await api.sign(input.domain, input.action, input.resource);
|
|
31
|
+
if (first.ok) {
|
|
32
|
+
// A 200 means the backend already held authorization — a standing grant, or a
|
|
33
|
+
// still-valid prior approval — so no new prompt was needed. The token alone can't
|
|
34
|
+
// say which (the audit row records grantId), so keep the notice neutral.
|
|
35
|
+
deps.notify?.(`Signed ${scopeLabel(input)} on ${input.domain} — already authorized, no new approval prompt needed.`);
|
|
36
|
+
return { data: { token: first.token } };
|
|
37
|
+
}
|
|
38
|
+
// A non-authorization failure (not_verified, unauthorized, …) won't be fixed by
|
|
39
|
+
// asking for approval — surface it as-is.
|
|
40
|
+
if (first.reason !== "access_required") {
|
|
41
|
+
return { data: { error: first.reason }, isError: true };
|
|
42
|
+
}
|
|
43
|
+
// 2. No standing grant → obtain a fresh, one-time human approval through the host,
|
|
44
|
+
// then sign once more (§18.1: signing can never skip the prompt).
|
|
45
|
+
const outcome = await ensureApproval(host, api, input, deps);
|
|
46
|
+
if (!outcome.approved) {
|
|
47
|
+
return {
|
|
48
|
+
data: {
|
|
49
|
+
error: "approval_required",
|
|
50
|
+
reason: outcome.reason,
|
|
51
|
+
hint: ACCESS_REQUIRED_HINT,
|
|
52
|
+
...(outcome.oobUrl ? { url: outcome.oobUrl } : {}),
|
|
53
|
+
},
|
|
54
|
+
isError: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const signed = await api.sign(input.domain, input.action, input.resource);
|
|
58
|
+
if (!signed.ok) {
|
|
59
|
+
return {
|
|
60
|
+
data: {
|
|
61
|
+
error: signed.reason,
|
|
62
|
+
...(signed.reason === "access_required" ? { hint: ACCESS_REQUIRED_HINT } : {}),
|
|
63
|
+
},
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { data: { token: signed.token } };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Open a standing-access request (Stage 2b): ask an org admin to grant this agent
|
|
71
|
+
* lasting authorization for a scoped capability. Unlike request_human_approval (a
|
|
72
|
+
* one-time yes for a single mint), a granted request lets sign_request mint with no
|
|
73
|
+
* prompt until it expires or is revoked. No host elicitation — the human decision
|
|
74
|
+
* happens later in the org's dashboard.
|
|
75
|
+
*/
|
|
76
|
+
export async function requestAccessTool(api, input) {
|
|
77
|
+
const res = await api.requestAccess(input.domain, input.action, input.resource, input.reason);
|
|
78
|
+
if (!res.ok)
|
|
79
|
+
return { data: { error: res.reason }, isError: true };
|
|
80
|
+
return { data: { requestId: res.requestId, status: res.status } };
|
|
81
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tiertwo/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server (npx @tiertwo/mcp) that lets an AI agent create accounts as a verified human — first-run device pairing, host-mediated approval, and short-lived signed signup tokens.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Tier Two",
|
|
8
|
+
"homepage": "https://github.com/Jordan-M/verified-human/tree/master/packages/mcp#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Jordan-M/verified-human.git",
|
|
12
|
+
"directory": "packages/mcp"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Jordan-M/verified-human/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"tiertwo",
|
|
19
|
+
"verified-human",
|
|
20
|
+
"mcp",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"claude",
|
|
23
|
+
"ai-agent"
|
|
24
|
+
],
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"bin": {
|
|
28
|
+
"tiertwo-mcp": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"provenance": true
|
|
36
|
+
},
|
|
37
|
+
"exports": {
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"import": "./dist/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./keystore": {
|
|
43
|
+
"types": "./dist/keystore.d.ts",
|
|
44
|
+
"import": "./dist/keystore.js"
|
|
45
|
+
},
|
|
46
|
+
"./pairing": {
|
|
47
|
+
"types": "./dist/pairing.d.ts",
|
|
48
|
+
"import": "./dist/pairing.js"
|
|
49
|
+
},
|
|
50
|
+
"./api": {
|
|
51
|
+
"types": "./dist/api.d.ts",
|
|
52
|
+
"import": "./dist/api.js"
|
|
53
|
+
},
|
|
54
|
+
"./approval": {
|
|
55
|
+
"types": "./dist/approval.d.ts",
|
|
56
|
+
"import": "./dist/approval.js"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
61
|
+
"@napi-rs/keyring": "1.3.0",
|
|
62
|
+
"zod": "^4.4.3",
|
|
63
|
+
"@tiertwo/shared": "0.1.0"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@types/node": "^20",
|
|
67
|
+
"tsx": "^4.22.4",
|
|
68
|
+
"typescript": "^5",
|
|
69
|
+
"vitest": "^3.2.0"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "tsc -p tsconfig.json",
|
|
73
|
+
"lint": "echo no-op",
|
|
74
|
+
"test": "vitest run",
|
|
75
|
+
"test:watch": "vitest"
|
|
76
|
+
}
|
|
77
|
+
}
|