@ivalt/agent-auth 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/.env.example +32 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/ivalt-mcp.js +17 -0
- package/package.json +43 -0
- package/src/config.js +72 -0
- package/src/http.js +67 -0
- package/src/ivalt-client.js +145 -0
- package/src/server.js +13 -0
- package/src/tools.js +197 -0
package/.env.example
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# iVALT MCP server configuration. Copy to .env (gitignored) or pass these as
|
|
2
|
+
# real environment variables from your MCP client / container.
|
|
3
|
+
#
|
|
4
|
+
# With NO key set at all, the server routes through iVALT's public ondemandid.com
|
|
5
|
+
# proxy and needs no credentials (good for demos).
|
|
6
|
+
|
|
7
|
+
# --- iVALT credential (optional) ---
|
|
8
|
+
# Set to call api.ivalt.com directly with your license key / connection id.
|
|
9
|
+
# IVALT_API_KEY=your-x-api-key-here
|
|
10
|
+
# IVALT_CONNECTION_ID=your-connection-id # alias for IVALT_API_KEY
|
|
11
|
+
|
|
12
|
+
# Force the upstream regardless of key presence: "direct" or "proxy".
|
|
13
|
+
# IVALT_UPSTREAM=proxy
|
|
14
|
+
|
|
15
|
+
# --- Approver defaults ---
|
|
16
|
+
# Default approver phone (E.164), e.g. +12025550123. No default — set this, or
|
|
17
|
+
# pass approver_mobile on each tool call. Callers can override per request.
|
|
18
|
+
# IVALT_DEFAULT_MOBILE=+12025550123
|
|
19
|
+
# Label shown in the approval request.
|
|
20
|
+
IVALT_REQUEST_FROM=iVALT MCP
|
|
21
|
+
# Default verification factors (comma list of: biometric,location,time,device).
|
|
22
|
+
# IVALT_DEFAULT_FACTORS=biometric,location,time
|
|
23
|
+
|
|
24
|
+
# --- HTTP transport (src/http.js / Docker) ---
|
|
25
|
+
# Port the HTTP server listens on.
|
|
26
|
+
PORT=8080
|
|
27
|
+
# Bearer token required on POST /mcp. Leave unset only for local dev.
|
|
28
|
+
# MCP_AUTH_TOKEN=change-me
|
|
29
|
+
|
|
30
|
+
# --- Tuning (optional) ---
|
|
31
|
+
# IVALT_POLL_INTERVAL_MS=2000
|
|
32
|
+
# IVALT_AUTH_WINDOW_MS=180000
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iVALT
|
|
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,174 @@
|
|
|
1
|
+
# @ivalt/agent-auth — iVALT human-approval MCP server
|
|
2
|
+
|
|
3
|
+
Escalate a sensitive agent step to a real human. This MCP server sends an iVALT
|
|
4
|
+
push to the approver's phone, the human approves with biometrics (FaceID /
|
|
5
|
+
fingerprint, plus optional location / time / device checks), and the agent
|
|
6
|
+
receives a **PKI-signed attestation**.
|
|
7
|
+
|
|
8
|
+
It ships two transports from one codebase:
|
|
9
|
+
|
|
10
|
+
- **stdio** — launched locally via `npx` (for Cursor, Claude Desktop, LangGraph).
|
|
11
|
+
- **HTTP** (Streamable-HTTP) — a hostable endpoint (Docker image) for the
|
|
12
|
+
multi-tenant / remote use case.
|
|
13
|
+
|
|
14
|
+
The agent never sees the iVALT key — it lives only in this server's env. The
|
|
15
|
+
client decides *when* to call and owns governance; iVALT owns the verify action.
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
|
|
19
|
+
| Tool | Behavior |
|
|
20
|
+
|------|----------|
|
|
21
|
+
| `request_approval` | Blocking. Sends the request and waits up to `timeout_s` (default 90, max 120) for approval. Returns the attestation. |
|
|
22
|
+
| `request_approval_async` | Returns a `request_id` immediately. For long-running cloud agents. |
|
|
23
|
+
| `check_status` | Polls a `request_id` -> `pending` / `approved` (with attestation) / `denied` / `expired`. |
|
|
24
|
+
|
|
25
|
+
Shared inputs: `action` (required), `reason`, `approver_mobile` (E.164, overrides
|
|
26
|
+
the default — this is how one endpoint serves many tenants), `factors`
|
|
27
|
+
(`biometric` | `location` | `time` | `device`).
|
|
28
|
+
|
|
29
|
+
Approved responses return a normalized attestation:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"status": "approved",
|
|
34
|
+
"attestation": {
|
|
35
|
+
"approved": true,
|
|
36
|
+
"request_id": "a518d10b-...",
|
|
37
|
+
"approver": { "mobile": "+1...", "name": "Jane Doe", "email": "jane@example.com" },
|
|
38
|
+
"approver_mobile": "+1...",
|
|
39
|
+
"request_from": "iVALT MCP: <action>",
|
|
40
|
+
"factors_verified": { "biometric": true, "location": true, "time": false, "device": true },
|
|
41
|
+
"location": { "latitude": "32.84...", "longitude": "-79.86...", "address": "..." },
|
|
42
|
+
"signed_at": "2026-06-26T...Z",
|
|
43
|
+
"pki": { "public_key": "MIICIjANBgkq...", "signature": null, "key_ref": "a518d10b-..." },
|
|
44
|
+
"raw": { }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Notes on the live shape: iVALT returns the device's registered public key in `raw.imei` (mapped to `pki.public_key`), the iVALT request UUID in `raw.request_id`, and geo data (`latitude`/`longitude`/`address`) when the device shares it. This endpoint does not return a separate detached `signature`, so `pki.signature` is typically `null` while `pki.public_key` carries the PKI material. The complete upstream payload is always preserved under `raw`.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
All config is via environment variables (see [`.env.example`](.env.example)).
|
|
54
|
+
With **no key set**, the server uses iVALT's public `ondemandid.com` proxy (no
|
|
55
|
+
credentials needed). Set `IVALT_API_KEY` to call `api.ivalt.com` directly.
|
|
56
|
+
|
|
57
|
+
| Var | Default | Purpose |
|
|
58
|
+
|-----|---------|---------|
|
|
59
|
+
| `IVALT_API_KEY` / `IVALT_CONNECTION_ID` | _(none)_ | iVALT credential; presence switches upstream to `direct`. |
|
|
60
|
+
| `IVALT_UPSTREAM` | auto | Force `direct` or `proxy`. |
|
|
61
|
+
| `IVALT_DEFAULT_MOBILE` | _(none)_ | Default approver phone (E.164). If unset, callers must pass `approver_mobile` per request. |
|
|
62
|
+
| `IVALT_REQUEST_FROM` | `iVALT MCP` | Label on the approval request. |
|
|
63
|
+
| `IVALT_DEFAULT_FACTORS` | _(none)_ | Comma list of default factors. |
|
|
64
|
+
| `PORT` | `8080` | HTTP transport port. |
|
|
65
|
+
| `MCP_AUTH_TOKEN` | _(none)_ | Bearer token required on `POST /mcp`. |
|
|
66
|
+
|
|
67
|
+
## Use it from a client
|
|
68
|
+
|
|
69
|
+
### Cursor (`.cursor/mcp.json`)
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"ivalt": {
|
|
75
|
+
"command": "npx",
|
|
76
|
+
"args": ["-y", "@ivalt/agent-auth"],
|
|
77
|
+
"env": {
|
|
78
|
+
"IVALT_API_KEY": "your-key",
|
|
79
|
+
"IVALT_DEFAULT_MOBILE": "+1..."
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Pre-publish, point at the local path instead:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"ivalt": {
|
|
92
|
+
"command": "node",
|
|
93
|
+
"args": ["C:/dev/iv/projects/mcp/bin/ivalt-mcp.js"],
|
|
94
|
+
"env": { "IVALT_DEFAULT_MOBILE": "+1..." }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Claude Desktop
|
|
101
|
+
|
|
102
|
+
Same shape under `mcpServers` in `claude_desktop_config.json`.
|
|
103
|
+
|
|
104
|
+
### Python / LangGraph (`langchain-mcp-adapters`)
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
108
|
+
|
|
109
|
+
client = MultiServerMCPClient({
|
|
110
|
+
"ivalt": {
|
|
111
|
+
"command": "npx",
|
|
112
|
+
"args": ["-y", "@ivalt/agent-auth"],
|
|
113
|
+
"env": {"IVALT_API_KEY": "your-key", "IVALT_DEFAULT_MOBILE": "+1..."},
|
|
114
|
+
"transport": "stdio",
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
tools = await client.get_tools() # request_approval, request_approval_async, check_status
|
|
118
|
+
# bind `tools` into your graph; use request_approval as a human-in-the-loop gate.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
To connect to a hosted HTTP server instead (no Node needed on the client):
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
client = MultiServerMCPClient({
|
|
125
|
+
"ivalt": {
|
|
126
|
+
"url": "https://your-host/mcp",
|
|
127
|
+
"transport": "streamable_http",
|
|
128
|
+
"headers": {"Authorization": "Bearer <MCP_AUTH_TOKEN>"},
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Run locally
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npm install
|
|
137
|
+
|
|
138
|
+
# stdio (what Cursor/Claude/LangGraph spawn)
|
|
139
|
+
npm run start:stdio
|
|
140
|
+
|
|
141
|
+
# HTTP transport
|
|
142
|
+
MCP_AUTH_TOKEN=secret PORT=8080 npm run start:http
|
|
143
|
+
curl http://localhost:8080/health
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Quick wiring check over both transports:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
node test/smoke.js stdio
|
|
150
|
+
node test/smoke.js http http://localhost:8080/mcp secret
|
|
151
|
+
# add SMOKE_FIRE=1 to also fire a real push to IVALT_DEFAULT_MOBILE
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Run with Docker (HTTP transport)
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
docker build -t ivalt-mcp .
|
|
158
|
+
docker run -p 8080:8080 \
|
|
159
|
+
-e MCP_AUTH_TOKEN=secret \
|
|
160
|
+
-e IVALT_API_KEY=your-key \
|
|
161
|
+
-e IVALT_DEFAULT_MOBILE=+1... \
|
|
162
|
+
ivalt-mcp
|
|
163
|
+
curl http://localhost:8080/health
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The multi-tenant router connects to `POST /mcp` with the bearer token and passes
|
|
167
|
+
a per-tenant `approver_mobile` in each tool call.
|
|
168
|
+
|
|
169
|
+
## Notes
|
|
170
|
+
|
|
171
|
+
- `request_id` encodes the approver mobile + issue time; iVALT's result endpoint
|
|
172
|
+
keys on the mobile, so avoid concurrent in-flight requests for the same phone.
|
|
173
|
+
- Approval window is ~60s; blocking `request_approval` polls every 2s.
|
|
174
|
+
- HTTP runs in stateless mode (`GET`/`DELETE /mcp` return 405).
|
package/bin/ivalt-mcp.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { buildServer } from '../src/server.js';
|
|
4
|
+
import { describeConfig } from '../src/config.js';
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
const server = buildServer();
|
|
8
|
+
const transport = new StdioServerTransport();
|
|
9
|
+
await server.connect(transport);
|
|
10
|
+
// stderr only — stdout is the MCP protocol channel and must stay clean.
|
|
11
|
+
console.error('[ivalt-mcp] stdio server ready', JSON.stringify(describeConfig()));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
main().catch((err) => {
|
|
15
|
+
console.error('[ivalt-mcp] fatal:', err);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ivalt/agent-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "iVALT human-approval MCP server: escalate sensitive agent steps to biometric, PKI-signed human attestation. stdio + HTTP transports.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"mcp",
|
|
12
|
+
"model-context-protocol",
|
|
13
|
+
"ivalt",
|
|
14
|
+
"human-in-the-loop",
|
|
15
|
+
"authentication",
|
|
16
|
+
"biometric",
|
|
17
|
+
"agent"
|
|
18
|
+
],
|
|
19
|
+
"bin": {
|
|
20
|
+
"ivalt-mcp": "bin/ivalt-mcp.js"
|
|
21
|
+
},
|
|
22
|
+
"main": "src/server.js",
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"src",
|
|
26
|
+
".env.example",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"start": "node src/http.js",
|
|
35
|
+
"start:stdio": "node bin/ivalt-mcp.js",
|
|
36
|
+
"start:http": "node src/http.js"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
40
|
+
"express": "^4.21.2",
|
|
41
|
+
"zod": "^3.24.1"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
/* Tiny, dependency-free .env loader. Real env vars (passed by the MCP client
|
|
8
|
+
or the container) always win over a local .env file. */
|
|
9
|
+
function loadDotEnv() {
|
|
10
|
+
const candidates = [
|
|
11
|
+
path.join(process.cwd(), '.env'),
|
|
12
|
+
path.join(__dirname, '..', '.env'),
|
|
13
|
+
];
|
|
14
|
+
for (const file of candidates) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
17
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
18
|
+
if (!line || line.trim().startsWith('#')) continue;
|
|
19
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
|
|
20
|
+
if (m && process.env[m[1]] === undefined) {
|
|
21
|
+
process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
/* no .env at this path — fine */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
loadDotEnv();
|
|
31
|
+
|
|
32
|
+
function parseFactors(value) {
|
|
33
|
+
if (!value) return [];
|
|
34
|
+
return value
|
|
35
|
+
.split(',')
|
|
36
|
+
.map((s) => s.trim().toLowerCase())
|
|
37
|
+
.filter((s) => ['biometric', 'location', 'time', 'device'].includes(s));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const apiKey = process.env.IVALT_API_KEY || process.env.IVALT_CONNECTION_ID || '';
|
|
41
|
+
|
|
42
|
+
/* upstream: 'direct' (api.ivalt.com, needs x-api-key) or 'proxy' (ondemandid.com,
|
|
43
|
+
no key needed). Defaults based on whether a key is present, overridable. */
|
|
44
|
+
let upstream = (process.env.IVALT_UPSTREAM || '').toLowerCase();
|
|
45
|
+
if (upstream !== 'direct' && upstream !== 'proxy') {
|
|
46
|
+
upstream = apiKey ? 'direct' : 'proxy';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const config = {
|
|
50
|
+
apiKey,
|
|
51
|
+
upstream,
|
|
52
|
+
defaultMobile: process.env.IVALT_DEFAULT_MOBILE || '',
|
|
53
|
+
requestFrom: process.env.IVALT_REQUEST_FROM || 'iVALT MCP',
|
|
54
|
+
defaultFactors: parseFactors(process.env.IVALT_DEFAULT_FACTORS),
|
|
55
|
+
port: Number(process.env.PORT || 8080),
|
|
56
|
+
authToken: process.env.MCP_AUTH_TOKEN || '',
|
|
57
|
+
pollIntervalMs: Number(process.env.IVALT_POLL_INTERVAL_MS || 2000),
|
|
58
|
+
authWindowMs: Number(process.env.IVALT_AUTH_WINDOW_MS || 180000),
|
|
59
|
+
maxTimeoutS: 120,
|
|
60
|
+
defaultTimeoutS: 90,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function describeConfig() {
|
|
64
|
+
return {
|
|
65
|
+
upstream: config.upstream,
|
|
66
|
+
upstream_host: config.upstream === 'direct' ? 'api.ivalt.com' : 'www.ondemandid.com',
|
|
67
|
+
key_set: Boolean(config.apiKey),
|
|
68
|
+
default_mobile: config.defaultMobile,
|
|
69
|
+
request_from: config.requestFrom,
|
|
70
|
+
default_factors: config.defaultFactors,
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
import { buildServer } from './server.js';
|
|
4
|
+
import { config, describeConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
const app = express();
|
|
7
|
+
app.use(express.json({ limit: '256kb' }));
|
|
8
|
+
|
|
9
|
+
/* Unauthenticated health check for container orchestration. */
|
|
10
|
+
app.get('/health', (_req, res) => {
|
|
11
|
+
res.json({ ok: true, transport: 'http', ...describeConfig() });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/* Bearer-token gate. When MCP_AUTH_TOKEN is set, every /mcp call must present it. */
|
|
15
|
+
function requireAuth(req, res, next) {
|
|
16
|
+
if (!config.authToken) return next(); // open mode (dev only) — warned at startup
|
|
17
|
+
const header = req.headers['authorization'] || '';
|
|
18
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
|
19
|
+
if (token && token === config.authToken) return next();
|
|
20
|
+
return res.status(401).json({
|
|
21
|
+
jsonrpc: '2.0',
|
|
22
|
+
error: { code: -32001, message: 'Unauthorized: missing or invalid bearer token.' },
|
|
23
|
+
id: null,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Stateless Streamable-HTTP: a fresh server + transport per request. Per-call
|
|
28
|
+
approver_mobile overrides ride in the tool arguments, so one endpoint serves
|
|
29
|
+
many tenants. */
|
|
30
|
+
app.post('/mcp', requireAuth, async (req, res) => {
|
|
31
|
+
const server = buildServer();
|
|
32
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
33
|
+
res.on('close', () => {
|
|
34
|
+
transport.close();
|
|
35
|
+
server.close();
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
await server.connect(transport);
|
|
39
|
+
await transport.handleRequest(req, res, req.body);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error('[ivalt-mcp] http request error:', err);
|
|
42
|
+
if (!res.headersSent) {
|
|
43
|
+
res.status(500).json({
|
|
44
|
+
jsonrpc: '2.0',
|
|
45
|
+
error: { code: -32603, message: 'Internal server error.' },
|
|
46
|
+
id: null,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/* Stateless mode does not support server-initiated streams or sessions. */
|
|
53
|
+
const methodNotAllowed = (_req, res) =>
|
|
54
|
+
res.status(405).json({
|
|
55
|
+
jsonrpc: '2.0',
|
|
56
|
+
error: { code: -32000, message: 'Method not allowed (stateless server).' },
|
|
57
|
+
id: null,
|
|
58
|
+
});
|
|
59
|
+
app.get('/mcp', methodNotAllowed);
|
|
60
|
+
app.delete('/mcp', methodNotAllowed);
|
|
61
|
+
|
|
62
|
+
app.listen(config.port, () => {
|
|
63
|
+
console.error(`[ivalt-mcp] HTTP server on :${config.port}`, JSON.stringify(describeConfig()));
|
|
64
|
+
if (!config.authToken) {
|
|
65
|
+
console.error('[ivalt-mcp] WARNING: MCP_AUTH_TOKEN is not set — /mcp is open. Set it before exposing this server.');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { config } from './config.js';
|
|
2
|
+
|
|
3
|
+
/* Upstream endpoints. Direct hits api.ivalt.com with an x-api-key header;
|
|
4
|
+
proxy hits iVALT's public ondemandid.com demo proxy (no key needed). */
|
|
5
|
+
const ENDPOINTS = {
|
|
6
|
+
direct: {
|
|
7
|
+
request: 'https://api.ivalt.com/biometric-auth-request',
|
|
8
|
+
result: 'https://api.ivalt.com/biometric-geo-fence-auth-results',
|
|
9
|
+
},
|
|
10
|
+
proxy: {
|
|
11
|
+
request: 'https://www.ondemandid.com/api/biometric-auth-request',
|
|
12
|
+
result: 'https://www.ondemandid.com/api/biometric-auth-result',
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function endpoints() {
|
|
17
|
+
return ENDPOINTS[config.upstream] || ENDPOINTS.proxy;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function headers() {
|
|
21
|
+
const h = { 'Content-Type': 'application/json' };
|
|
22
|
+
if (config.upstream === 'direct' && config.apiKey) h['x-api-key'] = config.apiKey;
|
|
23
|
+
return h;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function postJson(url, body, timeoutMs = 20000) {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(url, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: headers(),
|
|
33
|
+
body: JSON.stringify(body),
|
|
34
|
+
signal: controller.signal,
|
|
35
|
+
});
|
|
36
|
+
let json = null;
|
|
37
|
+
try {
|
|
38
|
+
json = await res.json();
|
|
39
|
+
} catch {
|
|
40
|
+
json = null;
|
|
41
|
+
}
|
|
42
|
+
return { status: res.status, ok: res.ok, json };
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractDetail(json, fallback) {
|
|
49
|
+
return (
|
|
50
|
+
json?.error?.detail ||
|
|
51
|
+
json?.error?.message ||
|
|
52
|
+
json?.message ||
|
|
53
|
+
json?.detail ||
|
|
54
|
+
fallback
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Fire a biometric approval request (push to the approver's phone).
|
|
60
|
+
* @returns {Promise<{ok: boolean, status: number, detail?: string, raw: any}>}
|
|
61
|
+
*/
|
|
62
|
+
export async function requestApproval({ mobile, requestFrom }) {
|
|
63
|
+
try {
|
|
64
|
+
const { status, ok, json } = await postJson(endpoints().request, {
|
|
65
|
+
mobile,
|
|
66
|
+
requestFrom: requestFrom || config.requestFrom,
|
|
67
|
+
});
|
|
68
|
+
if (!ok) {
|
|
69
|
+
return { ok: false, status, detail: extractDetail(json, 'iVALT request failed.'), raw: json };
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, status, raw: json };
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return { ok: false, status: 0, detail: `Upstream unreachable: ${err.message}`, raw: null };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Poll the result endpoint once.
|
|
79
|
+
* @returns {Promise<{state: 'approved'|'pending'|'denied'|'error', detail?: string, raw: any, status: number}>}
|
|
80
|
+
*/
|
|
81
|
+
export async function pollResult({ mobile }) {
|
|
82
|
+
let res;
|
|
83
|
+
try {
|
|
84
|
+
res = await postJson(endpoints().result, { mobile });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Transient network error — caller should keep polling.
|
|
87
|
+
return { state: 'pending', detail: `transient: ${err.message}`, raw: null, status: 0 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { status, ok, json } = res;
|
|
91
|
+
|
|
92
|
+
if (ok) {
|
|
93
|
+
const details = json?.data?.details || json?.details || json || {};
|
|
94
|
+
return { state: 'approved', raw: details, status };
|
|
95
|
+
}
|
|
96
|
+
// 422 = still waiting; 404 = not yet registered — both mean keep polling.
|
|
97
|
+
if (status === 422 || status === 404) {
|
|
98
|
+
return { state: 'pending', raw: json, status };
|
|
99
|
+
}
|
|
100
|
+
// Anything else is a hard failure (denied / geofence / timezone / bad request).
|
|
101
|
+
return { state: 'denied', detail: extractDetail(json, 'Authentication failed.'), raw: json, status };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Normalize an approved iVALT response into a stable attestation object so
|
|
106
|
+
* callers never parse raw upstream shapes.
|
|
107
|
+
*/
|
|
108
|
+
export function normalizeAttestation(rawDetails, { mobile, requestFrom, factors }) {
|
|
109
|
+
const d = rawDetails || {};
|
|
110
|
+
const requested = Array.isArray(factors) ? factors : [];
|
|
111
|
+
|
|
112
|
+
// iVALT returns geo data when the device shares it.
|
|
113
|
+
const hasLocation = d.latitude != null || d.longitude != null || d.address != null;
|
|
114
|
+
const location = hasLocation
|
|
115
|
+
? { latitude: d.latitude ?? null, longitude: d.longitude ?? null, address: d.address ?? null }
|
|
116
|
+
: null;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
approved: true,
|
|
120
|
+
request_id: d.request_id || d.requestId || null,
|
|
121
|
+
approver: {
|
|
122
|
+
mobile,
|
|
123
|
+
name: d.name || null,
|
|
124
|
+
email: d.email || null,
|
|
125
|
+
},
|
|
126
|
+
approver_mobile: mobile,
|
|
127
|
+
request_from: requestFrom || config.requestFrom,
|
|
128
|
+
factors_verified: {
|
|
129
|
+
// iVALT is biometric by definition; location is verified when geo is returned.
|
|
130
|
+
biometric: true,
|
|
131
|
+
location: requested.includes('location') || hasLocation,
|
|
132
|
+
time: requested.includes('time'),
|
|
133
|
+
device: requested.includes('device') || Boolean(d.imei),
|
|
134
|
+
},
|
|
135
|
+
location,
|
|
136
|
+
signed_at: new Date().toISOString(),
|
|
137
|
+
pki: {
|
|
138
|
+
// iVALT returns the device's registered public key in the `imei` field.
|
|
139
|
+
public_key: d.imei || d.publicKey || d.public_key || null,
|
|
140
|
+
signature: d.signature || d.attestation || d.token || null,
|
|
141
|
+
key_ref: d.request_id || d.requestId || d.keyId || d.key_ref || null,
|
|
142
|
+
},
|
|
143
|
+
raw: d,
|
|
144
|
+
};
|
|
145
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { registerTools } from './tools.js';
|
|
3
|
+
|
|
4
|
+
/* Build a fresh MCP server instance with the iVALT tools registered.
|
|
5
|
+
A new instance is created per stdio process and per stateless HTTP request. */
|
|
6
|
+
export function buildServer() {
|
|
7
|
+
const server = new McpServer({
|
|
8
|
+
name: 'ivalt-approval',
|
|
9
|
+
version: '0.1.0',
|
|
10
|
+
});
|
|
11
|
+
registerTools(server);
|
|
12
|
+
return server;
|
|
13
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { config } from './config.js';
|
|
3
|
+
import { requestApproval, pollResult, normalizeAttestation } from './ivalt-client.js';
|
|
4
|
+
|
|
5
|
+
const FACTORS = ['biometric', 'location', 'time', 'device'];
|
|
6
|
+
|
|
7
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
|
|
9
|
+
/* request_id is an opaque, self-describing handle. iVALT's result endpoint keys
|
|
10
|
+
off the approver mobile, so we encode mobile + context + issue time. */
|
|
11
|
+
function encodeRequestId({ mobile, requestFrom, factors, issuedAt }) {
|
|
12
|
+
const json = JSON.stringify({ m: mobile, r: requestFrom, f: factors, t: issuedAt });
|
|
13
|
+
return Buffer.from(json, 'utf8').toString('base64url');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function decodeRequestId(requestId) {
|
|
17
|
+
try {
|
|
18
|
+
const json = Buffer.from(String(requestId), 'base64url').toString('utf8');
|
|
19
|
+
const o = JSON.parse(json);
|
|
20
|
+
return { mobile: o.m, requestFrom: o.r, factors: o.f || [], issuedAt: o.t };
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveApprover(input) {
|
|
27
|
+
return (input && String(input).trim()) || config.defaultMobile;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const NO_APPROVER_DETAIL =
|
|
31
|
+
'No approver phone configured. Pass approver_mobile (E.164, e.g. +12025550123) or set IVALT_DEFAULT_MOBILE on the server.';
|
|
32
|
+
|
|
33
|
+
function resolveFactors(input) {
|
|
34
|
+
if (Array.isArray(input) && input.length) return input.filter((f) => FACTORS.includes(f));
|
|
35
|
+
return config.defaultFactors;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Wrap a JS object as an MCP tool result (text content with pretty JSON). */
|
|
39
|
+
function result(payload, { isError = false } = {}) {
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
42
|
+
isError,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const sharedInput = {
|
|
47
|
+
action: z.string().min(1).describe('Human-readable description of the sensitive action requiring approval.'),
|
|
48
|
+
reason: z.string().optional().describe('Optional extra context shown alongside the action.'),
|
|
49
|
+
approver_mobile: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe('Approver phone in E.164 (e.g. +12025550123). Defaults to the server-configured approver. Override per call for multi-tenant routing.'),
|
|
53
|
+
factors: z
|
|
54
|
+
.array(z.enum(FACTORS))
|
|
55
|
+
.optional()
|
|
56
|
+
.describe('Verification factors to require. Defaults to the server-configured factors.'),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function registerTools(server) {
|
|
60
|
+
/* ---- request_approval: blocking, waits for the phone approval ---- */
|
|
61
|
+
server.registerTool(
|
|
62
|
+
'request_approval',
|
|
63
|
+
{
|
|
64
|
+
title: 'Request human approval (blocking)',
|
|
65
|
+
description:
|
|
66
|
+
'Escalate a sensitive step to a human: sends an iVALT push to the approver phone, waits for biometric approval, and returns a PKI-signed attestation. Blocks up to timeout_s seconds.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
...sharedInput,
|
|
69
|
+
timeout_s: z
|
|
70
|
+
.number()
|
|
71
|
+
.int()
|
|
72
|
+
.positive()
|
|
73
|
+
.max(config.maxTimeoutS)
|
|
74
|
+
.optional()
|
|
75
|
+
.describe(`How long to wait for approval, seconds (default ${config.defaultTimeoutS}, max ${config.maxTimeoutS}).`),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
async ({ action, reason, approver_mobile, factors, timeout_s }) => {
|
|
79
|
+
const mobile = resolveApprover(approver_mobile);
|
|
80
|
+
if (!mobile) {
|
|
81
|
+
return result({ status: 'error', detail: NO_APPROVER_DETAIL }, { isError: true });
|
|
82
|
+
}
|
|
83
|
+
const usedFactors = resolveFactors(factors);
|
|
84
|
+
const requestFrom = reason ? `${config.requestFrom}: ${action} (${reason})` : `${config.requestFrom}: ${action}`;
|
|
85
|
+
|
|
86
|
+
const sent = await requestApproval({ mobile, requestFrom });
|
|
87
|
+
if (!sent.ok) {
|
|
88
|
+
return result({ status: 'error', detail: sent.detail, approver_mobile: mobile }, { isError: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const timeoutMs = Math.min((timeout_s || config.defaultTimeoutS) * 1000, config.maxTimeoutS * 1000);
|
|
92
|
+
const expiresAt = Date.now() + timeoutMs;
|
|
93
|
+
|
|
94
|
+
while (Date.now() < expiresAt) {
|
|
95
|
+
await sleep(config.pollIntervalMs);
|
|
96
|
+
const r = await pollResult({ mobile });
|
|
97
|
+
if (r.state === 'approved') {
|
|
98
|
+
return result({
|
|
99
|
+
status: 'approved',
|
|
100
|
+
attestation: normalizeAttestation(r.raw, { mobile, requestFrom, factors: usedFactors }),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (r.state === 'denied') {
|
|
104
|
+
return result({ status: 'denied', detail: r.detail, approver_mobile: mobile }, { isError: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result(
|
|
109
|
+
{ status: 'expired', detail: 'No approval received within the timeout window.', approver_mobile: mobile },
|
|
110
|
+
{ isError: true }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/* ---- request_approval_async: returns a request_id immediately ---- */
|
|
116
|
+
server.registerTool(
|
|
117
|
+
'request_approval_async',
|
|
118
|
+
{
|
|
119
|
+
title: 'Request human approval (async)',
|
|
120
|
+
description:
|
|
121
|
+
'Fire an iVALT approval request and return immediately with a request_id. Use check_status to poll for the result. Best for long-running cloud agents.',
|
|
122
|
+
inputSchema: { ...sharedInput },
|
|
123
|
+
},
|
|
124
|
+
async ({ action, reason, approver_mobile, factors }) => {
|
|
125
|
+
const mobile = resolveApprover(approver_mobile);
|
|
126
|
+
if (!mobile) {
|
|
127
|
+
return result({ status: 'error', detail: NO_APPROVER_DETAIL }, { isError: true });
|
|
128
|
+
}
|
|
129
|
+
const usedFactors = resolveFactors(factors);
|
|
130
|
+
const requestFrom = reason ? `${config.requestFrom}: ${action} (${reason})` : `${config.requestFrom}: ${action}`;
|
|
131
|
+
|
|
132
|
+
const sent = await requestApproval({ mobile, requestFrom });
|
|
133
|
+
if (!sent.ok) {
|
|
134
|
+
return result({ status: 'error', detail: sent.detail, approver_mobile: mobile }, { isError: true });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const issuedAt = Date.now();
|
|
138
|
+
const request_id = encodeRequestId({ mobile, requestFrom, factors: usedFactors, issuedAt });
|
|
139
|
+
return result({
|
|
140
|
+
status: 'pending',
|
|
141
|
+
request_id,
|
|
142
|
+
approver_mobile: mobile,
|
|
143
|
+
expires_at: new Date(issuedAt + config.authWindowMs).toISOString(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
/* ---- check_status: poll a previously issued request ---- */
|
|
149
|
+
server.registerTool(
|
|
150
|
+
'check_status',
|
|
151
|
+
{
|
|
152
|
+
title: 'Check approval status',
|
|
153
|
+
description:
|
|
154
|
+
'Check the status of a request_id from request_approval_async. Returns pending, approved (with attestation), denied, or expired.',
|
|
155
|
+
inputSchema: {
|
|
156
|
+
request_id: z.string().min(1).describe('The request_id returned by request_approval_async.'),
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
async ({ request_id }) => {
|
|
160
|
+
const decoded = decodeRequestId(request_id);
|
|
161
|
+
if (!decoded || !decoded.mobile) {
|
|
162
|
+
return result({ status: 'error', detail: 'Invalid request_id.' }, { isError: true });
|
|
163
|
+
}
|
|
164
|
+
const { mobile, requestFrom, factors, issuedAt } = decoded;
|
|
165
|
+
const expired = Date.now() > issuedAt + config.authWindowMs;
|
|
166
|
+
|
|
167
|
+
const r = await pollResult({ mobile });
|
|
168
|
+
if (r.state === 'approved') {
|
|
169
|
+
return result({
|
|
170
|
+
status: 'approved',
|
|
171
|
+
request_id,
|
|
172
|
+
attestation: normalizeAttestation(r.raw, { mobile, requestFrom, factors }),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (r.state === 'denied') {
|
|
176
|
+
return result({ status: 'denied', request_id, detail: r.detail }, { isError: true });
|
|
177
|
+
}
|
|
178
|
+
if (expired) {
|
|
179
|
+
return result(
|
|
180
|
+
{
|
|
181
|
+
status: 'expired',
|
|
182
|
+
request_id,
|
|
183
|
+
detail: `No approval received within the ${Math.round(config.authWindowMs / 1000)}s window.`,
|
|
184
|
+
},
|
|
185
|
+
{ isError: true }
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return result({
|
|
189
|
+
status: 'pending',
|
|
190
|
+
request_id,
|
|
191
|
+
expires_at: new Date(issuedAt + config.authWindowMs).toISOString(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return server;
|
|
197
|
+
}
|