lauther-mcp 0.1.1 → 0.3.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/README.md +44 -1
- package/package.json +3 -3
- package/src/index.js +233 -4
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ token**. Then add the server to your MCP config:
|
|
|
30
30
|
|
|
31
31
|
Nothing to install — `npx` fetches it. Node 18+.
|
|
32
32
|
|
|
33
|
-
## The
|
|
33
|
+
## The tools
|
|
34
34
|
|
|
35
35
|
### `ask_approval`
|
|
36
36
|
|
|
@@ -62,6 +62,49 @@ It reports what actually happened, including the case worth knowing about: if
|
|
|
62
62
|
no device is registered on your account, you're told that rather than given a
|
|
63
63
|
cheerful success for a notification nobody received.
|
|
64
64
|
|
|
65
|
+
### `get_location`
|
|
66
|
+
|
|
67
|
+
Asks you to share where you are, once. You get a prompt on your phone with
|
|
68
|
+
**Share location** and **Decline**; the agent receives one position, as it was
|
|
69
|
+
when you tapped.
|
|
70
|
+
|
|
71
|
+
| Result | What the agent is told |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `SHARED` | latitude, longitude, accuracy in metres, and when it was read |
|
|
74
|
+
| `DECLINED` | do not retry and do not find the location another way |
|
|
75
|
+
| `NO ANSWER` | silence is not consent; nothing was shared |
|
|
76
|
+
| `EXPIRED` | the request lapsed on your phone; nothing was shared |
|
|
77
|
+
|
|
78
|
+
Options: `question` (what you see on the prompt), `details`,
|
|
79
|
+
`require_biometric`, `timeout_seconds` (default 120).
|
|
80
|
+
|
|
81
|
+
### `request_photo`
|
|
82
|
+
|
|
83
|
+
Asks you for one picture, taken on the spot or picked from your library. The
|
|
84
|
+
agent gets the link **and the image itself**, so it can look at it. The link
|
|
85
|
+
works for 7 days, after which the photo is deleted. If the image cannot be
|
|
86
|
+
fetched the agent still gets the text and the link.
|
|
87
|
+
|
|
88
|
+
Same options and the same four outcomes as `get_location`.
|
|
89
|
+
|
|
90
|
+
### `ask_text`
|
|
91
|
+
|
|
92
|
+
Asks you a short question and waits for you to type the answer on your phone:
|
|
93
|
+
a verification code, a choice, a name, a fact only you know. The prompt shows
|
|
94
|
+
the question and a field, with **Reply** and **Decline**. Answers run to 500
|
|
95
|
+
characters, so it is for short things.
|
|
96
|
+
|
|
97
|
+
| Result | What the agent is told |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `ANSWERED` | exactly what you typed, in quotes |
|
|
100
|
+
| `DECLINED` | do not retry and do not get the answer another way |
|
|
101
|
+
| `NO ANSWER` | silence is not consent; nothing was sent |
|
|
102
|
+
| `EXPIRED` | the request lapsed on your phone; nothing was sent |
|
|
103
|
+
|
|
104
|
+
Options: `question` (default "Answer a question?"), `details`,
|
|
105
|
+
`placeholder` (a hint in the field, such as "6-digit code"),
|
|
106
|
+
`require_biometric`, `timeout_seconds` (default 120).
|
|
107
|
+
|
|
65
108
|
## Telling your agent when to use it
|
|
66
109
|
|
|
67
110
|
Most models won't reach for an approval tool unless asked. Something like this
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lauther-mcp",
|
|
3
3
|
"mcpName": "io.github.csanadymiklos/lauther",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"description": "MCP server that lets an AI agent ask its owner for approval on their phone before doing something irreversible.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"mcp",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"author": "Dipling Kft.",
|
|
21
21
|
"type": "module",
|
|
22
22
|
"bin": {
|
|
23
|
-
"lauther-mcp": "
|
|
23
|
+
"lauther-mcp": "src/index.js"
|
|
24
24
|
},
|
|
25
25
|
"main": "./src/index.js",
|
|
26
26
|
"files": [
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
|
-
"lauther-client": "^0.
|
|
38
|
+
"lauther-client": "^0.4.0",
|
|
39
39
|
"zod": "^3.25.0"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
package/src/index.js
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* lauther-mcp — an MCP server that puts a human in the loop, on their phone.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Five tools, each one thing an agent needs a person for:
|
|
6
6
|
*
|
|
7
|
-
* ask_approval
|
|
8
|
-
*
|
|
7
|
+
* ask_approval the agent is about to do something irreversible and
|
|
8
|
+
* asks first. Blocks until a person answers.
|
|
9
9
|
* send_notification "the migration finished", "the build broke".
|
|
10
|
+
* get_location "where are you?" - one position, shared on a tap.
|
|
11
|
+
* request_photo "show me" - one picture, taken or picked on the phone.
|
|
12
|
+
* ask_text "what is the code?" - a short answer, typed on the phone.
|
|
10
13
|
*
|
|
11
14
|
* The interesting one is ask_approval. Lauther's /v1/approve long-polls for at
|
|
12
15
|
* most 25 seconds, which suits a shell one-liner and not an agent whose owner
|
|
@@ -19,6 +22,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
19
22
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
20
23
|
import { z } from "zod";
|
|
21
24
|
import { Lauther, LautherError } from "lauther-client";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
|
|
27
|
+
const PKG_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
22
28
|
|
|
23
29
|
const TOKEN = process.env.LAUTHER_TOKEN;
|
|
24
30
|
const BASE_URL = process.env.LAUTHER_BASE_URL; // tests and self-hosted relays
|
|
@@ -35,6 +41,12 @@ if (!TOKEN) {
|
|
|
35
41
|
|
|
36
42
|
const lauther = new Lauther(TOKEN, BASE_URL ? { baseUrl: BASE_URL } : {});
|
|
37
43
|
|
|
44
|
+
/** The server keeps shared photos for 7 days (SPEC_REQUESTS 1.5). */
|
|
45
|
+
const PHOTO_RETENTION = "7 days";
|
|
46
|
+
|
|
47
|
+
/** A shared photo is shown to the agent inline; the API caps uploads at 2 MB. */
|
|
48
|
+
const MAX_INLINE_PHOTO_BYTES = 4 * 1024 * 1024;
|
|
49
|
+
|
|
38
50
|
const text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
39
51
|
const failure = (s) => ({ content: [{ type: "text", text: s }], isError: true });
|
|
40
52
|
|
|
@@ -50,8 +62,54 @@ function explain(err) {
|
|
|
50
62
|
return `Unexpected error: ${err?.message ?? String(err)}`;
|
|
51
63
|
}
|
|
52
64
|
|
|
65
|
+
/** `2026-10-13T09:00:00.000Z` becomes `2026-10-13T09:00:00Z`; anything odd passes through. */
|
|
66
|
+
function isoSeconds(at) {
|
|
67
|
+
const d = new Date(at);
|
|
68
|
+
return Number.isNaN(d.getTime()) ? String(at) : d.toISOString().replace(/\.\d+Z$/, "Z");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** " Confirmed by biometric." or nothing. */
|
|
72
|
+
const confirmation = (out) => (out.confirmedBy ? ` Confirmed by ${out.confirmedBy}.` : "");
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The non-yes outcomes of a request, worded for an agent. Returns null when
|
|
76
|
+
* the person did approve, so the caller can describe what was shared.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} what "location", "photo" or "answer", for the sentence
|
|
79
|
+
*/
|
|
80
|
+
function notShared(out, what) {
|
|
81
|
+
if (out.response === "approve") return null;
|
|
82
|
+
if (out.response === "deny") {
|
|
83
|
+
return text(`DECLINED after ${out.waitedSeconds}s. Do not retry, and do not try ` +
|
|
84
|
+
`to get the ${what} another way - report the refusal and stop.`);
|
|
85
|
+
}
|
|
86
|
+
if (out.expired) {
|
|
87
|
+
return text(`EXPIRED after ${out.waitedSeconds}s - the request lapsed on their ` +
|
|
88
|
+
`phone before anyone answered, and no ${what} was shared. Expiry is not ` +
|
|
89
|
+
"consent: say it expired and let the human restart it when they are back.");
|
|
90
|
+
}
|
|
91
|
+
return text(`NO ANSWER after ${out.waitedSeconds}s - nobody responded, so no ` +
|
|
92
|
+
`${what} was shared. Silence is not consent: say the request went unanswered ` +
|
|
93
|
+
"and let the human restart it when they are back.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Fetch a shared photo for inline display. Null on any failure: the text still works. */
|
|
97
|
+
async function fetchPhoto(url) {
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
100
|
+
if (!res.ok) return null;
|
|
101
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
102
|
+
if (bytes.length === 0 || bytes.length > MAX_INLINE_PHOTO_BYTES) return null;
|
|
103
|
+
return bytes.toString("base64");
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
53
109
|
export function buildServer() {
|
|
54
|
-
|
|
110
|
+
// The version comes from package.json so it cannot drift from what npm
|
|
111
|
+
// published (it did once: 0.1.1 on npm reported itself as 0.1.0).
|
|
112
|
+
const server = new McpServer({ name: "lauther", version: PKG_VERSION });
|
|
55
113
|
|
|
56
114
|
server.registerTool(
|
|
57
115
|
"ask_approval",
|
|
@@ -147,6 +205,177 @@ export function buildServer() {
|
|
|
147
205
|
},
|
|
148
206
|
);
|
|
149
207
|
|
|
208
|
+
server.registerTool(
|
|
209
|
+
"get_location",
|
|
210
|
+
{
|
|
211
|
+
title: "Ask a human to share their location",
|
|
212
|
+
description:
|
|
213
|
+
"Ask the human who owns this phone to share where they are right now. " +
|
|
214
|
+
"They get a prompt and choose Share or Decline; if they share, one " +
|
|
215
|
+
"position is sent, once, as it was at that moment. Use it when you " +
|
|
216
|
+
"need their location to do the task (find the nearest branch, set a " +
|
|
217
|
+
"timezone, log a site visit), not to track them. Returns SHARED with " +
|
|
218
|
+
"latitude, longitude and accuracy, or DECLINED, NO ANSWER or EXPIRED. " +
|
|
219
|
+
"On anything but SHARED you have no location: do not guess one.",
|
|
220
|
+
inputSchema: {
|
|
221
|
+
question: z.string().min(1).max(250).optional()
|
|
222
|
+
.describe("What they see on the prompt. Say why you need it: " +
|
|
223
|
+
"'Share your location so I can find the nearest pharmacy?'. " +
|
|
224
|
+
"Defaults to 'Share your location?'."),
|
|
225
|
+
details: z.string().max(2000).optional()
|
|
226
|
+
.describe("Anything else that helps them decide."),
|
|
227
|
+
require_biometric: z.boolean().optional()
|
|
228
|
+
.describe("Demand a fingerprint or face before the location is sent."),
|
|
229
|
+
timeout_seconds: z.number().int().min(5).max(900).optional()
|
|
230
|
+
.describe("How long to wait for an answer. Defaults to 120."),
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
async ({ question = "Share your location?", details, require_biometric,
|
|
234
|
+
timeout_seconds = 120 }) => {
|
|
235
|
+
try {
|
|
236
|
+
const out = await lauther.requestLocation({
|
|
237
|
+
title: question,
|
|
238
|
+
message: details,
|
|
239
|
+
requireBiometric: require_biometric,
|
|
240
|
+
timeoutSeconds: timeout_seconds,
|
|
241
|
+
});
|
|
242
|
+
const notYes = notShared(out, "location");
|
|
243
|
+
if (notYes) return notYes;
|
|
244
|
+
|
|
245
|
+
const loc = out.result;
|
|
246
|
+
if (!loc || typeof loc.lat !== "number" || typeof loc.lng !== "number") {
|
|
247
|
+
return failure("They approved, but Lauther returned no usable position. " +
|
|
248
|
+
"You have no location: do not guess one.");
|
|
249
|
+
}
|
|
250
|
+
const accuracy = typeof loc.accuracy === "number"
|
|
251
|
+
? ` (±${Math.round(loc.accuracy)} m)` : "";
|
|
252
|
+
const at = loc.at ? ` at ${isoSeconds(loc.at)}` : "";
|
|
253
|
+
return text(`SHARED after ${out.waitedSeconds}s: ` +
|
|
254
|
+
`${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)}${accuracy}${at}.` +
|
|
255
|
+
`${confirmation(out)} That is where they were at that moment, shared once ` +
|
|
256
|
+
"for what you asked about - use it for that and nothing else.");
|
|
257
|
+
} catch (err) {
|
|
258
|
+
return failure(explain(err) + " No location was obtained, so do not guess one.");
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
server.registerTool(
|
|
264
|
+
"request_photo",
|
|
265
|
+
{
|
|
266
|
+
title: "Ask a human for a photo",
|
|
267
|
+
description:
|
|
268
|
+
"Ask the human who owns this phone for one picture. They get a prompt " +
|
|
269
|
+
"and choose to take a photo (or pick one) or Decline. Use it when you " +
|
|
270
|
+
"need to see something only they can show you: a label, a screen, a " +
|
|
271
|
+
"whiteboard, a damaged part. Returns SHARED with a link plus the image " +
|
|
272
|
+
"itself so you can look at it directly, or DECLINED, NO ANSWER or " +
|
|
273
|
+
"EXPIRED. The link works for 7 days and the photo is then deleted.",
|
|
274
|
+
inputSchema: {
|
|
275
|
+
question: z.string().min(1).max(250).optional()
|
|
276
|
+
.describe("What they see on the prompt. Say what you need to see: " +
|
|
277
|
+
"'Take a photo of the error on the printer display?'. " +
|
|
278
|
+
"Defaults to 'Take a photo?'."),
|
|
279
|
+
details: z.string().max(2000).optional()
|
|
280
|
+
.describe("Anything else that helps them decide or frame the shot."),
|
|
281
|
+
require_biometric: z.boolean().optional()
|
|
282
|
+
.describe("Demand a fingerprint or face before the photo is sent."),
|
|
283
|
+
timeout_seconds: z.number().int().min(5).max(900).optional()
|
|
284
|
+
.describe("How long to wait for an answer. Defaults to 120."),
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
async ({ question = "Take a photo?", details, require_biometric,
|
|
288
|
+
timeout_seconds = 120 }) => {
|
|
289
|
+
try {
|
|
290
|
+
const out = await lauther.requestPhoto({
|
|
291
|
+
title: question,
|
|
292
|
+
message: details,
|
|
293
|
+
requireBiometric: require_biometric,
|
|
294
|
+
timeoutSeconds: timeout_seconds,
|
|
295
|
+
});
|
|
296
|
+
const notYes = notShared(out, "photo");
|
|
297
|
+
if (notYes) return notYes;
|
|
298
|
+
|
|
299
|
+
const photo = out.result;
|
|
300
|
+
if (!photo || typeof photo.url !== "string") {
|
|
301
|
+
return failure("They approved, but Lauther returned no photo. You have " +
|
|
302
|
+
"nothing to look at: do not guess what it showed.");
|
|
303
|
+
}
|
|
304
|
+
const size = photo.width && photo.height ? ` ${photo.width}x${photo.height}` : "";
|
|
305
|
+
const line = `SHARED after ${out.waitedSeconds}s: photo${size} at ${photo.url} ` +
|
|
306
|
+
`- available for ${PHOTO_RETENTION}.${confirmation(out)}`;
|
|
307
|
+
|
|
308
|
+
const data = await fetchPhoto(photo.url);
|
|
309
|
+
if (!data) {
|
|
310
|
+
return text(`${line} The image could not be fetched to show you inline, ` +
|
|
311
|
+
"but the link above still works.");
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
content: [
|
|
315
|
+
{ type: "text", text: `${line} The picture is attached.` },
|
|
316
|
+
{ type: "image", data, mimeType: "image/jpeg" },
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
} catch (err) {
|
|
320
|
+
return failure(explain(err) + " No photo was obtained, so do not guess what it showed.");
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
server.registerTool(
|
|
326
|
+
"ask_text",
|
|
327
|
+
{
|
|
328
|
+
title: "Ask a human a short question",
|
|
329
|
+
description:
|
|
330
|
+
"Ask the human who owns this phone a short question and wait for them " +
|
|
331
|
+
"to type the answer on it. Use it for something only they know: a " +
|
|
332
|
+
"verification code, a choice between options, a name, a short fact. " +
|
|
333
|
+
"Never for long text (answers are capped at 500 characters), and not " +
|
|
334
|
+
"for permission to act: use ask_approval for that. Returns ANSWERED " +
|
|
335
|
+
"with exactly what they typed, or DECLINED, NO ANSWER or EXPIRED. On " +
|
|
336
|
+
"anything but ANSWERED you have no answer: do not guess one.",
|
|
337
|
+
inputSchema: {
|
|
338
|
+
question: z.string().min(1).max(250).optional()
|
|
339
|
+
.describe("The question, as they read it on a lock screen. Be " +
|
|
340
|
+
"specific about the shape of the answer: 'What is the 6-digit " +
|
|
341
|
+
"code from your authenticator?'. Defaults to 'Answer a question?'."),
|
|
342
|
+
details: z.string().max(2000).optional()
|
|
343
|
+
.describe("Anything else that helps them answer."),
|
|
344
|
+
placeholder: z.string().max(80).optional()
|
|
345
|
+
.describe("A hint shown in the answer field, e.g. '6-digit code'."),
|
|
346
|
+
require_biometric: z.boolean().optional()
|
|
347
|
+
.describe("Demand a fingerprint or face before the answer is sent."),
|
|
348
|
+
timeout_seconds: z.number().int().min(5).max(900).optional()
|
|
349
|
+
.describe("How long to wait for an answer. Defaults to 120."),
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
async ({ question = "Answer a question?", details, placeholder, require_biometric,
|
|
353
|
+
timeout_seconds = 120 }) => {
|
|
354
|
+
try {
|
|
355
|
+
const out = await lauther.requestText({
|
|
356
|
+
title: question,
|
|
357
|
+
message: details,
|
|
358
|
+
placeholder,
|
|
359
|
+
requireBiometric: require_biometric,
|
|
360
|
+
timeoutSeconds: timeout_seconds,
|
|
361
|
+
});
|
|
362
|
+
const notYes = notShared(out, "answer");
|
|
363
|
+
if (notYes) return notYes;
|
|
364
|
+
|
|
365
|
+
const answer = out.result;
|
|
366
|
+
if (!answer || typeof answer.text !== "string") {
|
|
367
|
+
return failure("They replied, but Lauther returned no usable answer. " +
|
|
368
|
+
"You have no answer: do not guess one.");
|
|
369
|
+
}
|
|
370
|
+
return text(`ANSWERED after ${out.waitedSeconds}s: ${JSON.stringify(answer.text)}.` +
|
|
371
|
+
`${confirmation(out)} That is what they typed, sent once in reply to what ` +
|
|
372
|
+
"you asked - use it for that and nothing else.");
|
|
373
|
+
} catch (err) {
|
|
374
|
+
return failure(explain(err) + " No answer was obtained, so do not guess one.");
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
);
|
|
378
|
+
|
|
150
379
|
return server;
|
|
151
380
|
}
|
|
152
381
|
|