lauther-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.
Files changed (3) hide show
  1. package/README.md +96 -0
  2. package/package.json +48 -0
  3. package/src/index.js +161 -0
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # lauther-mcp
2
+
3
+ Let your coding agent **ask you first**, on your phone, before it does
4
+ something you can't undo.
5
+
6
+ ```
7
+ Agent: About to drop and recreate `users` on staging. Approving?
8
+ Phone: [ Approve ] [ Deny ] ← optionally behind your fingerprint
9
+ Agent: APPROVED after 34s. Confirmed by biometric.
10
+ ```
11
+
12
+ Works with Claude Code, Cursor, Windsurf, or anything else that speaks MCP.
13
+
14
+ ## Setup
15
+
16
+ Get a token in the [Lauther](https://lauther.app) app: **Apps → + → New
17
+ token**. Then add the server to your MCP config:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "lauther": {
23
+ "command": "npx",
24
+ "args": ["-y", "lauther-mcp"],
25
+ "env": { "LAUTHER_TOKEN": "lpt_your_token" }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ Nothing to install — `npx` fetches it. Node 18+.
32
+
33
+ ## The two tools
34
+
35
+ ### `ask_approval`
36
+
37
+ Blocks until you answer, then tells the agent one of three things:
38
+
39
+ | Result | What the agent is told |
40
+ | --- | --- |
41
+ | `APPROVED` | proceed with exactly what was asked, and nothing further |
42
+ | `DENIED` | stop; do not retry, do not find another way |
43
+ | `NO ANSWER` | stop — **silence is not consent** |
44
+
45
+ That last row is the one that matters. A timeout is not a yes, and the tool
46
+ says so in words the model has to read.
47
+
48
+ Options: `details` for context, `require_biometric` to demand a fingerprint,
49
+ `timeout_seconds` for how long you're willing to wait (default 120).
50
+
51
+ **On waiting.** Lauther's API long-polls for at most 25 seconds. Past that this
52
+ server keeps asking about the *same* notification rather than sending another,
53
+ so a ten-minute wait still puts **one** question on your phone — not one every
54
+ half minute.
55
+
56
+ ### `send_notification`
57
+
58
+ One-way: "the migration finished", "the build broke". Takes `title`,
59
+ `message`, `priority` and `url`. Only `urgent` crosses your quiet hours.
60
+
61
+ It reports what actually happened, including the case worth knowing about: if
62
+ no device is registered on your account, you're told that rather than given a
63
+ cheerful success for a notification nobody received.
64
+
65
+ ## Telling your agent when to use it
66
+
67
+ Most models won't reach for an approval tool unless asked. Something like this
68
+ in your project rules works well:
69
+
70
+ ```
71
+ Before anything irreversible — deploying, deleting data, spending money,
72
+ force-pushing, emailing real people, touching production — call
73
+ ask_approval and wait. Proceed only on APPROVED.
74
+ ```
75
+
76
+ ## Suggesting it as a habit
77
+
78
+ The approval is signed and, when you ask for it, biometric — the answer
79
+ records *that a person confirmed it*, not merely that something clicked. That
80
+ is the difference between an audit trail and a log line.
81
+
82
+ ## Related
83
+
84
+ - [`lauther-client`](https://www.npmjs.com/package/lauther-client) — the plain Node client,
85
+ if you want push and approvals in your own code rather than in an agent.
86
+ - [Docs](https://lauther.app/docs.html) · [Recipes](https://github.com/csanadymiklos/lauther-examples)
87
+ - Something wrong? **hello@lauther.id** reaches a human.
88
+
89
+ ## Worth knowing
90
+
91
+ - **Hosted only.** The relay holds the APNs connection and the identity layer.
92
+ - **Not end-to-end encrypted.** Don't put secrets in the question text.
93
+ - The app is free on the [App Store](https://apps.apple.com/app/id6802635695)
94
+ and [Google Play](https://play.google.com/store/apps/details?id=id.lauther).
95
+
96
+ MIT
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "lauther-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server that lets an AI agent ask its owner for approval on their phone before doing something irreversible.",
5
+ "keywords": [
6
+ "mcp",
7
+ "modelcontextprotocol",
8
+ "claude",
9
+ "cursor",
10
+ "agent",
11
+ "human-in-the-loop",
12
+ "approval",
13
+ "push",
14
+ "notifications",
15
+ "lauther"
16
+ ],
17
+ "homepage": "https://lauther.app",
18
+ "license": "MIT",
19
+ "author": "Dipling Kft.",
20
+ "type": "module",
21
+ "bin": {
22
+ "lauther-mcp": "./src/index.js"
23
+ },
24
+ "main": "./src/index.js",
25
+ "files": [
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "scripts": {
33
+ "test": "node --test"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.30.0",
37
+ "lauther-client": "^0.2.0",
38
+ "zod": "^3.25.0"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/csanadymiklos/lauther-js.git",
43
+ "directory": "packages/lauther-mcp"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/csanadymiklos/lauther-js/issues"
47
+ }
48
+ }
package/src/index.js ADDED
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lauther-mcp — an MCP server that puts a human in the loop, on their phone.
4
+ *
5
+ * Two tools, and resisting a third is the point:
6
+ *
7
+ * ask_approval the agent is about to do something irreversible and
8
+ * asks first. Blocks until a person answers.
9
+ * send_notification "the migration finished", "the build broke".
10
+ *
11
+ * The interesting one is ask_approval. Lauther's /v1/approve long-polls for at
12
+ * most 25 seconds, which suits a shell one-liner and not an agent whose owner
13
+ * has wandered off — so the client keeps asking about the SAME notification
14
+ * past that, and the person sees one question rather than a pile of them.
15
+ *
16
+ * @see https://lauther.app/docs.html
17
+ */
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
+ import { z } from "zod";
21
+ import { Lauther, LautherError } from "lauther-client";
22
+
23
+ const TOKEN = process.env.LAUTHER_TOKEN;
24
+ const BASE_URL = process.env.LAUTHER_BASE_URL; // tests and self-hosted relays
25
+
26
+ if (!TOKEN) {
27
+ // stderr, never stdout: stdout is the MCP transport and anything else on it
28
+ // corrupts the stream before the client can report a useful error.
29
+ process.stderr.write(
30
+ "lauther-mcp: LAUTHER_TOKEN is not set.\n" +
31
+ "Get a token in the Lauther app under Apps -> + -> New token, then put it\n" +
32
+ "in the env block of your MCP config. https://lauther.app/docs.html\n");
33
+ process.exit(1);
34
+ }
35
+
36
+ const lauther = new Lauther(TOKEN, BASE_URL ? { baseUrl: BASE_URL } : {});
37
+
38
+ const text = (s) => ({ content: [{ type: "text", text: s }] });
39
+ const failure = (s) => ({ content: [{ type: "text", text: s }], isError: true });
40
+
41
+ /** Turn any thrown thing into one line an agent can act on. */
42
+ function explain(err) {
43
+ if (err instanceof LautherError) {
44
+ if (err.status === 401 || err.status === 403) {
45
+ return `Lauther rejected the token (HTTP ${err.status}): ${err.message}. ` +
46
+ "Check LAUTHER_TOKEN, and that it has not been revoked in the app.";
47
+ }
48
+ return `Lauther error${err.status ? ` (HTTP ${err.status})` : ""}: ${err.message}`;
49
+ }
50
+ return `Unexpected error: ${err?.message ?? String(err)}`;
51
+ }
52
+
53
+ export function buildServer() {
54
+ const server = new McpServer({ name: "lauther", version: "0.1.0" });
55
+
56
+ server.registerTool(
57
+ "ask_approval",
58
+ {
59
+ title: "Ask a human for approval",
60
+ description:
61
+ "Ask the human who owns this machine to approve or deny something, on " +
62
+ "their phone, and wait for the answer. Use this BEFORE anything " +
63
+ "irreversible or expensive: deploying, deleting data, spending money, " +
64
+ "sending mail to real people, force-pushing, touching production. " +
65
+ "Returns APPROVED, DENIED, or NO ANSWER. Proceed only on APPROVED — " +
66
+ "a denial and a timeout both mean stop.",
67
+ inputSchema: {
68
+ question: z.string().min(1).max(250)
69
+ .describe("The question, as the person will read it on a lock screen. " +
70
+ "Be specific: 'Drop and recreate the users table on staging?' beats " +
71
+ "'Proceed?'."),
72
+ details: z.string().max(2000).optional()
73
+ .describe("Context that helps them decide — what changes, how much it " +
74
+ "costs, what breaks if it goes wrong."),
75
+ require_biometric: z.boolean().optional()
76
+ .describe("Demand a fingerprint or face before the approval counts. " +
77
+ "Denying never requires one; refusing should always be cheap."),
78
+ timeout_seconds: z.number().int().min(5).max(900).optional()
79
+ .describe("How long to wait for an answer. Defaults to 120."),
80
+ },
81
+ },
82
+ async ({ question, details, require_biometric, timeout_seconds = 120 }) => {
83
+ try {
84
+ const out = await lauther.requestApproval(question, {
85
+ message: details,
86
+ requireBiometric: require_biometric,
87
+ timeoutSeconds: timeout_seconds,
88
+ });
89
+ if (out.response === "approve") {
90
+ const how = out.confirmedBy
91
+ ? ` Confirmed by ${out.confirmedBy}.`
92
+ : "";
93
+ return text(`APPROVED after ${out.waitedSeconds}s.${how} You may proceed ` +
94
+ "with exactly what you asked about, and nothing further.");
95
+ }
96
+ if (out.response === "deny") {
97
+ return text(`DENIED after ${out.waitedSeconds}s. Do not proceed. Do not ` +
98
+ "retry the same question, and do not look for another way to do it — " +
99
+ "report the refusal and stop.");
100
+ }
101
+ return text(`NO ANSWER after ${out.waitedSeconds}s — nobody responded. Do ` +
102
+ "not proceed: silence is not consent. Say the request went unanswered " +
103
+ "and let the human restart it when they are back.");
104
+ } catch (err) {
105
+ return failure(explain(err) + " No approval was obtained, so do not proceed.");
106
+ }
107
+ },
108
+ );
109
+
110
+ server.registerTool(
111
+ "send_notification",
112
+ {
113
+ title: "Send a notification to the human's phone",
114
+ description:
115
+ "Tell the human something, on their phone. Use for finishing a long " +
116
+ "job, a build breaking, or anything worth interrupting them for. This " +
117
+ "does NOT wait for a reply — use ask_approval when you need permission.",
118
+ inputSchema: {
119
+ title: z.string().min(1).max(250).describe("The headline."),
120
+ message: z.string().max(2000).optional().describe("The body."),
121
+ priority: z.enum(["lowest", "low", "normal", "high", "urgent"]).optional()
122
+ .describe("Defaults to normal. 'urgent' is the only level that crosses " +
123
+ "quiet hours — worth using on about three things and no more."),
124
+ url: z.string().url().optional().describe("A link the notification opens."),
125
+ },
126
+ },
127
+ async ({ title, message, priority = "normal", url }) => {
128
+ const LEVELS = { lowest: -2, low: -1, normal: 0, high: 1, urgent: 2 };
129
+ try {
130
+ const out = await lauther.push({
131
+ title, message, url, priority: LEVELS[priority],
132
+ });
133
+ if (out.devices === 0) {
134
+ return text("Lauther accepted the notification but NO DEVICE is " +
135
+ "registered on this account, so no phone buzzed. Usually the app has " +
136
+ "not been opened since signing in.");
137
+ }
138
+ if (out.delivered === 0) {
139
+ return text(`Lauther accepted it but all ${out.devices} device(s) ` +
140
+ "rejected it. The push token is probably stale — reopening the app " +
141
+ "usually fixes it.");
142
+ }
143
+ return text(`Sent — delivered to ${out.delivered} of ${out.devices} device(s).`);
144
+ } catch (err) {
145
+ return failure(explain(err));
146
+ }
147
+ },
148
+ );
149
+
150
+ return server;
151
+ }
152
+
153
+ // Only start a transport when run as a program, so tests can import
154
+ // buildServer() without hijacking stdio.
155
+ const isMain = process.argv[1] &&
156
+ import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, "/")}`).href;
157
+
158
+ if (isMain || process.env.LAUTHER_MCP_FORCE_START === "1") {
159
+ const server = buildServer();
160
+ await server.connect(new StdioServerTransport());
161
+ }