lauther-mcp 0.1.1 → 0.2.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 +26 -1
- package/package.json +2 -2
- package/src/index.js +178 -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,31 @@ 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
|
+
|
|
65
90
|
## Telling your agent when to use it
|
|
66
91
|
|
|
67
92
|
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.2.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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
|
-
"lauther-client": "^0.
|
|
38
|
+
"lauther-client": "^0.3.0",
|
|
39
39
|
"zod": "^3.25.0"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
package/src/index.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* lauther-mcp — an MCP server that puts a human in the loop, on their phone.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Four 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.
|
|
10
12
|
*
|
|
11
13
|
* The interesting one is ask_approval. Lauther's /v1/approve long-polls for at
|
|
12
14
|
* most 25 seconds, which suits a shell one-liner and not an agent whose owner
|
|
@@ -19,6 +21,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
19
21
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
20
22
|
import { z } from "zod";
|
|
21
23
|
import { Lauther, LautherError } from "lauther-client";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
|
|
26
|
+
const PKG_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
22
27
|
|
|
23
28
|
const TOKEN = process.env.LAUTHER_TOKEN;
|
|
24
29
|
const BASE_URL = process.env.LAUTHER_BASE_URL; // tests and self-hosted relays
|
|
@@ -35,6 +40,12 @@ if (!TOKEN) {
|
|
|
35
40
|
|
|
36
41
|
const lauther = new Lauther(TOKEN, BASE_URL ? { baseUrl: BASE_URL } : {});
|
|
37
42
|
|
|
43
|
+
/** The server keeps shared photos for 7 days (SPEC_REQUESTS 1.5). */
|
|
44
|
+
const PHOTO_RETENTION = "7 days";
|
|
45
|
+
|
|
46
|
+
/** A shared photo is shown to the agent inline; the API caps uploads at 2 MB. */
|
|
47
|
+
const MAX_INLINE_PHOTO_BYTES = 4 * 1024 * 1024;
|
|
48
|
+
|
|
38
49
|
const text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
39
50
|
const failure = (s) => ({ content: [{ type: "text", text: s }], isError: true });
|
|
40
51
|
|
|
@@ -50,8 +61,54 @@ function explain(err) {
|
|
|
50
61
|
return `Unexpected error: ${err?.message ?? String(err)}`;
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
/** `2026-10-13T09:00:00.000Z` becomes `2026-10-13T09:00:00Z`; anything odd passes through. */
|
|
65
|
+
function isoSeconds(at) {
|
|
66
|
+
const d = new Date(at);
|
|
67
|
+
return Number.isNaN(d.getTime()) ? String(at) : d.toISOString().replace(/\.\d+Z$/, "Z");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** " Confirmed by biometric." or nothing. */
|
|
71
|
+
const confirmation = (out) => (out.confirmedBy ? ` Confirmed by ${out.confirmedBy}.` : "");
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The non-yes outcomes of a request, worded for an agent. Returns null when
|
|
75
|
+
* the person did approve, so the caller can describe what was shared.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} what "location" or "photo", for the sentence
|
|
78
|
+
*/
|
|
79
|
+
function notShared(out, what) {
|
|
80
|
+
if (out.response === "approve") return null;
|
|
81
|
+
if (out.response === "deny") {
|
|
82
|
+
return text(`DECLINED after ${out.waitedSeconds}s. Do not retry, and do not try ` +
|
|
83
|
+
`to get the ${what} another way - report the refusal and stop.`);
|
|
84
|
+
}
|
|
85
|
+
if (out.expired) {
|
|
86
|
+
return text(`EXPIRED after ${out.waitedSeconds}s - the request lapsed on their ` +
|
|
87
|
+
`phone before anyone answered, and no ${what} was shared. Expiry is not ` +
|
|
88
|
+
"consent: say it expired and let the human restart it when they are back.");
|
|
89
|
+
}
|
|
90
|
+
return text(`NO ANSWER after ${out.waitedSeconds}s - nobody responded, so no ` +
|
|
91
|
+
`${what} was shared. Silence is not consent: say the request went unanswered ` +
|
|
92
|
+
"and let the human restart it when they are back.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Fetch a shared photo for inline display. Null on any failure: the text still works. */
|
|
96
|
+
async function fetchPhoto(url) {
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
99
|
+
if (!res.ok) return null;
|
|
100
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
101
|
+
if (bytes.length === 0 || bytes.length > MAX_INLINE_PHOTO_BYTES) return null;
|
|
102
|
+
return bytes.toString("base64");
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
53
108
|
export function buildServer() {
|
|
54
|
-
|
|
109
|
+
// The version comes from package.json so it cannot drift from what npm
|
|
110
|
+
// published (it did once: 0.1.1 on npm reported itself as 0.1.0).
|
|
111
|
+
const server = new McpServer({ name: "lauther", version: PKG_VERSION });
|
|
55
112
|
|
|
56
113
|
server.registerTool(
|
|
57
114
|
"ask_approval",
|
|
@@ -147,6 +204,123 @@ export function buildServer() {
|
|
|
147
204
|
},
|
|
148
205
|
);
|
|
149
206
|
|
|
207
|
+
server.registerTool(
|
|
208
|
+
"get_location",
|
|
209
|
+
{
|
|
210
|
+
title: "Ask a human to share their location",
|
|
211
|
+
description:
|
|
212
|
+
"Ask the human who owns this phone to share where they are right now. " +
|
|
213
|
+
"They get a prompt and choose Share or Decline; if they share, one " +
|
|
214
|
+
"position is sent, once, as it was at that moment. Use it when you " +
|
|
215
|
+
"need their location to do the task (find the nearest branch, set a " +
|
|
216
|
+
"timezone, log a site visit), not to track them. Returns SHARED with " +
|
|
217
|
+
"latitude, longitude and accuracy, or DECLINED, NO ANSWER or EXPIRED. " +
|
|
218
|
+
"On anything but SHARED you have no location: do not guess one.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
question: z.string().min(1).max(250).optional()
|
|
221
|
+
.describe("What they see on the prompt. Say why you need it: " +
|
|
222
|
+
"'Share your location so I can find the nearest pharmacy?'. " +
|
|
223
|
+
"Defaults to 'Share your location?'."),
|
|
224
|
+
details: z.string().max(2000).optional()
|
|
225
|
+
.describe("Anything else that helps them decide."),
|
|
226
|
+
require_biometric: z.boolean().optional()
|
|
227
|
+
.describe("Demand a fingerprint or face before the location is sent."),
|
|
228
|
+
timeout_seconds: z.number().int().min(5).max(900).optional()
|
|
229
|
+
.describe("How long to wait for an answer. Defaults to 120."),
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
async ({ question = "Share your location?", details, require_biometric,
|
|
233
|
+
timeout_seconds = 120 }) => {
|
|
234
|
+
try {
|
|
235
|
+
const out = await lauther.requestLocation({
|
|
236
|
+
title: question,
|
|
237
|
+
message: details,
|
|
238
|
+
requireBiometric: require_biometric,
|
|
239
|
+
timeoutSeconds: timeout_seconds,
|
|
240
|
+
});
|
|
241
|
+
const notYes = notShared(out, "location");
|
|
242
|
+
if (notYes) return notYes;
|
|
243
|
+
|
|
244
|
+
const loc = out.result;
|
|
245
|
+
if (!loc || typeof loc.lat !== "number" || typeof loc.lng !== "number") {
|
|
246
|
+
return failure("They approved, but Lauther returned no usable position. " +
|
|
247
|
+
"You have no location: do not guess one.");
|
|
248
|
+
}
|
|
249
|
+
const accuracy = typeof loc.accuracy === "number"
|
|
250
|
+
? ` (±${Math.round(loc.accuracy)} m)` : "";
|
|
251
|
+
const at = loc.at ? ` at ${isoSeconds(loc.at)}` : "";
|
|
252
|
+
return text(`SHARED after ${out.waitedSeconds}s: ` +
|
|
253
|
+
`${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)}${accuracy}${at}.` +
|
|
254
|
+
`${confirmation(out)} That is where they were at that moment, shared once ` +
|
|
255
|
+
"for what you asked about - use it for that and nothing else.");
|
|
256
|
+
} catch (err) {
|
|
257
|
+
return failure(explain(err) + " No location was obtained, so do not guess one.");
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
server.registerTool(
|
|
263
|
+
"request_photo",
|
|
264
|
+
{
|
|
265
|
+
title: "Ask a human for a photo",
|
|
266
|
+
description:
|
|
267
|
+
"Ask the human who owns this phone for one picture. They get a prompt " +
|
|
268
|
+
"and choose to take a photo (or pick one) or Decline. Use it when you " +
|
|
269
|
+
"need to see something only they can show you: a label, a screen, a " +
|
|
270
|
+
"whiteboard, a damaged part. Returns SHARED with a link plus the image " +
|
|
271
|
+
"itself so you can look at it directly, or DECLINED, NO ANSWER or " +
|
|
272
|
+
"EXPIRED. The link works for 7 days and the photo is then deleted.",
|
|
273
|
+
inputSchema: {
|
|
274
|
+
question: z.string().min(1).max(250).optional()
|
|
275
|
+
.describe("What they see on the prompt. Say what you need to see: " +
|
|
276
|
+
"'Take a photo of the error on the printer display?'. " +
|
|
277
|
+
"Defaults to 'Take a photo?'."),
|
|
278
|
+
details: z.string().max(2000).optional()
|
|
279
|
+
.describe("Anything else that helps them decide or frame the shot."),
|
|
280
|
+
require_biometric: z.boolean().optional()
|
|
281
|
+
.describe("Demand a fingerprint or face before the photo is sent."),
|
|
282
|
+
timeout_seconds: z.number().int().min(5).max(900).optional()
|
|
283
|
+
.describe("How long to wait for an answer. Defaults to 120."),
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
async ({ question = "Take a photo?", details, require_biometric,
|
|
287
|
+
timeout_seconds = 120 }) => {
|
|
288
|
+
try {
|
|
289
|
+
const out = await lauther.requestPhoto({
|
|
290
|
+
title: question,
|
|
291
|
+
message: details,
|
|
292
|
+
requireBiometric: require_biometric,
|
|
293
|
+
timeoutSeconds: timeout_seconds,
|
|
294
|
+
});
|
|
295
|
+
const notYes = notShared(out, "photo");
|
|
296
|
+
if (notYes) return notYes;
|
|
297
|
+
|
|
298
|
+
const photo = out.result;
|
|
299
|
+
if (!photo || typeof photo.url !== "string") {
|
|
300
|
+
return failure("They approved, but Lauther returned no photo. You have " +
|
|
301
|
+
"nothing to look at: do not guess what it showed.");
|
|
302
|
+
}
|
|
303
|
+
const size = photo.width && photo.height ? ` ${photo.width}x${photo.height}` : "";
|
|
304
|
+
const line = `SHARED after ${out.waitedSeconds}s: photo${size} at ${photo.url} ` +
|
|
305
|
+
`- available for ${PHOTO_RETENTION}.${confirmation(out)}`;
|
|
306
|
+
|
|
307
|
+
const data = await fetchPhoto(photo.url);
|
|
308
|
+
if (!data) {
|
|
309
|
+
return text(`${line} The image could not be fetched to show you inline, ` +
|
|
310
|
+
"but the link above still works.");
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
content: [
|
|
314
|
+
{ type: "text", text: `${line} The picture is attached.` },
|
|
315
|
+
{ type: "image", data, mimeType: "image/jpeg" },
|
|
316
|
+
],
|
|
317
|
+
};
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return failure(explain(err) + " No photo was obtained, so do not guess what it showed.");
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
);
|
|
323
|
+
|
|
150
324
|
return server;
|
|
151
325
|
}
|
|
152
326
|
|