@hwip/mcp-server 0.0.0-stage → 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 +190 -0
- package/NOTICE +10 -0
- package/README.md +413 -2
- package/dist/agent-target.d.ts +45 -0
- package/dist/agent-target.js +81 -0
- package/dist/api-client.d.ts +162 -0
- package/dist/api-client.js +517 -0
- package/dist/ask-context-attach.d.ts +67 -0
- package/dist/ask-context-attach.js +147 -0
- package/dist/ask-seal.d.ts +118 -0
- package/dist/ask-seal.js +194 -0
- package/dist/command-seal.d.ts +49 -0
- package/dist/command-seal.js +90 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +147 -0
- package/dist/config.d.ts +93 -0
- package/dist/config.js +561 -0
- package/dist/crypto.d.ts +234 -0
- package/dist/crypto.js +606 -0
- package/dist/error-format.d.ts +7 -0
- package/dist/error-format.js +77 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +30 -0
- package/dist/key-changes.d.ts +50 -0
- package/dist/key-changes.js +163 -0
- package/dist/mime.d.ts +16 -0
- package/dist/mime.js +58 -0
- package/dist/peer-key.d.ts +36 -0
- package/dist/peer-key.js +35 -0
- package/dist/poll.d.ts +24 -0
- package/dist/poll.js +156 -0
- package/dist/push-audience.d.ts +35 -0
- package/dist/push-audience.js +64 -0
- package/dist/redirect.d.ts +8 -0
- package/dist/redirect.js +17 -0
- package/dist/remote-state.d.ts +38 -0
- package/dist/remote-state.js +81 -0
- package/dist/resources/channels.d.ts +4 -0
- package/dist/resources/channels.js +24 -0
- package/dist/resources/devices.d.ts +4 -0
- package/dist/resources/devices.js +24 -0
- package/dist/sanitize.d.ts +41 -0
- package/dist/sanitize.js +106 -0
- package/dist/self-register.d.ts +34 -0
- package/dist/self-register.js +51 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +63 -0
- package/dist/tools/agent-local.d.ts +17 -0
- package/dist/tools/agent-local.js +139 -0
- package/dist/tools/agent-send.d.ts +6 -0
- package/dist/tools/agent-send.js +89 -0
- package/dist/tools/ask.d.ts +9 -0
- package/dist/tools/ask.js +275 -0
- package/dist/tools/broadcast.d.ts +5 -0
- package/dist/tools/broadcast.js +42 -0
- package/dist/tools/clipboard.d.ts +5 -0
- package/dist/tools/clipboard.js +47 -0
- package/dist/tools/dismiss.d.ts +5 -0
- package/dist/tools/dismiss.js +49 -0
- package/dist/tools/file.d.ts +5 -0
- package/dist/tools/file.js +169 -0
- package/dist/tools/list.d.ts +4 -0
- package/dist/tools/list.js +44 -0
- package/dist/tools/lookup.d.ts +10 -0
- package/dist/tools/lookup.js +45 -0
- package/dist/tools/notify.d.ts +5 -0
- package/dist/tools/notify.js +118 -0
- package/dist/tools/rename.d.ts +5 -0
- package/dist/tools/rename.js +45 -0
- package/dist/trust-pins.d.ts +70 -0
- package/dist/trust-pins.js +269 -0
- package/dist/types.d.ts +157 -0
- package/dist/types.js +2 -0
- package/dist/url-check.d.ts +11 -0
- package/dist/url-check.js +57 -0
- package/dist/ws-wait.d.ts +72 -0
- package/dist/ws-wait.js +200 -0
- package/package.json +70 -4
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerDismissAllTool = exports.registerDismissTool = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const error_format_js_1 = require("../error-format.js");
|
|
6
|
+
const registerDismissTool = (server, client) => {
|
|
7
|
+
server.registerTool('hwip_dismiss', {
|
|
8
|
+
description: 'Dismiss (mark as read) a specific push notification by ID. Use after processing a notification to clear it from the user\'s feed.',
|
|
9
|
+
annotations: {
|
|
10
|
+
readOnlyHint: false,
|
|
11
|
+
destructiveHint: false,
|
|
12
|
+
idempotentHint: true,
|
|
13
|
+
openWorldHint: true,
|
|
14
|
+
},
|
|
15
|
+
inputSchema: {
|
|
16
|
+
pushId: zod_1.z.string().describe('Push ID to dismiss (e.g., "push_01HX...")'),
|
|
17
|
+
},
|
|
18
|
+
}, async ({ pushId }) => {
|
|
19
|
+
try {
|
|
20
|
+
await client.dismissPush(pushId);
|
|
21
|
+
return (0, error_format_js_1.textResult)({ dismissed: true, pushId });
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return (0, error_format_js_1.formatToolError)(err);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
exports.registerDismissTool = registerDismissTool;
|
|
29
|
+
const registerDismissAllTool = (server, client) => {
|
|
30
|
+
server.registerTool('hwip_dismiss_all', {
|
|
31
|
+
description: 'Dismiss all push notifications at once. Clears the entire notification feed.',
|
|
32
|
+
annotations: {
|
|
33
|
+
readOnlyHint: false,
|
|
34
|
+
destructiveHint: true,
|
|
35
|
+
idempotentHint: true,
|
|
36
|
+
openWorldHint: true,
|
|
37
|
+
},
|
|
38
|
+
inputSchema: {},
|
|
39
|
+
}, async () => {
|
|
40
|
+
try {
|
|
41
|
+
const result = await client.dismissAllPushes();
|
|
42
|
+
return (0, error_format_js_1.textResult)({ dismissed: result.data.dismissed, badge: result.data.badge ?? 0 });
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return (0, error_format_js_1.formatToolError)(err);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
exports.registerDismissAllTool = registerDismissAllTool;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { type HwipApiClient } from '../api-client.js';
|
|
3
|
+
import { type McpServerConfig } from '../config.js';
|
|
4
|
+
export declare const registerFileTool: (server: McpServer, client: HwipApiClient, config: McpServerConfig) => void;
|
|
5
|
+
//# sourceMappingURL=file.d.ts.map
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerFileTool = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const promises_1 = require("node:fs/promises");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const api_client_js_1 = require("../api-client.js");
|
|
8
|
+
const config_js_1 = require("../config.js");
|
|
9
|
+
const error_format_js_1 = require("../error-format.js");
|
|
10
|
+
const key_changes_js_1 = require("../key-changes.js");
|
|
11
|
+
const crypto_js_1 = require("../crypto.js");
|
|
12
|
+
const push_audience_js_1 = require("../push-audience.js");
|
|
13
|
+
const mime_js_1 = require("../mime.js");
|
|
14
|
+
class FileTooLargeError extends Error {
|
|
15
|
+
size;
|
|
16
|
+
constructor(size) {
|
|
17
|
+
super(`File is ${size} bytes; the most hwip carries is ${api_client_js_1.MAX_FILE_BYTES}`);
|
|
18
|
+
this.size = size;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Collapse the two accepted input shapes into one payload.
|
|
23
|
+
*
|
|
24
|
+
* A `filePath` stays a Buffer end to end — images and PDFs only survive the
|
|
25
|
+
* trip as raw bytes, and reading from disk keeps a several-hundred-KB
|
|
26
|
+
* attachment out of the tool call itself (base64 in the arguments would
|
|
27
|
+
* inflate it by a third and burn the agent's context).
|
|
28
|
+
*/
|
|
29
|
+
const resolvePayload = async (args) => {
|
|
30
|
+
if (args.filePath) {
|
|
31
|
+
// Look before reading: the file lands in memory whole, so a directory or an
|
|
32
|
+
// oversize path must be turned away while it is still just a path.
|
|
33
|
+
const info = await (0, promises_1.stat)(args.filePath);
|
|
34
|
+
if (!info.isFile())
|
|
35
|
+
throw new Error(`${args.filePath} is not a regular file`);
|
|
36
|
+
if (info.size > api_client_js_1.MAX_FILE_BYTES)
|
|
37
|
+
throw new FileTooLargeError(info.size);
|
|
38
|
+
const body = await (0, promises_1.readFile)(args.filePath);
|
|
39
|
+
return { fileName: args.fileName ?? (0, node_path_1.basename)(args.filePath), body, size: body.byteLength };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
fileName: args.fileName,
|
|
43
|
+
body: args.content,
|
|
44
|
+
size: new TextEncoder().encode(args.content).byteLength,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const registerFileTool = (server, client, config) => {
|
|
48
|
+
server.registerTool('hwip_file', {
|
|
49
|
+
description: 'Send a file to the user\'s device. Pass `filePath` to send a file that already exists on disk — images (png/jpg/gif/webp/heic), PDFs, logs, anything. Pass `content` instead to send text you generated. Images render inline on the device.',
|
|
50
|
+
annotations: {
|
|
51
|
+
readOnlyHint: false,
|
|
52
|
+
destructiveHint: false,
|
|
53
|
+
openWorldHint: true,
|
|
54
|
+
},
|
|
55
|
+
inputSchema: {
|
|
56
|
+
filePath: zod_1.z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('Absolute path to a local file to send. Required for images, PDFs, and any other binary — never base64 a file into `content`.'),
|
|
60
|
+
content: zod_1.z.string().optional().describe('Text content of the file. Use only for text you generated; requires `fileName`.'),
|
|
61
|
+
fileName: zod_1.z
|
|
62
|
+
.string()
|
|
63
|
+
.optional()
|
|
64
|
+
.describe('File name with extension (e.g., "report.txt"). Required with `content`; defaults to the basename of `filePath`.'),
|
|
65
|
+
title: zod_1.z.string().optional().describe('Notification title (defaults to fileName)'),
|
|
66
|
+
targetDeviceId: zod_1.z.string().optional().describe('Target device ID. Omit to use configured default or send to all devices.'),
|
|
67
|
+
},
|
|
68
|
+
}, (0, key_changes_js_1.notingKeyChanges)(async ({ filePath, fileName: fileNameArg, content, title, targetDeviceId }, extra) => {
|
|
69
|
+
if (!filePath && content === undefined) {
|
|
70
|
+
return (0, error_format_js_1.errorResult)({
|
|
71
|
+
error: 'INVALID_INPUT',
|
|
72
|
+
message: 'Either filePath or content is required',
|
|
73
|
+
suggestion: 'Pass filePath for a file on disk (images, PDFs), or content + fileName for generated text',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (!filePath && !fileNameArg) {
|
|
77
|
+
return (0, error_format_js_1.errorResult)({
|
|
78
|
+
error: 'INVALID_INPUT',
|
|
79
|
+
message: 'fileName is required when sending content',
|
|
80
|
+
suggestion: 'Add fileName with an extension, e.g. "report.md"',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
let payload;
|
|
84
|
+
try {
|
|
85
|
+
payload = await resolvePayload({ filePath, content, fileName: fileNameArg });
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err instanceof FileTooLargeError) {
|
|
89
|
+
return (0, error_format_js_1.errorResult)({
|
|
90
|
+
error: 'FILE_TOO_LARGE',
|
|
91
|
+
message: err.message,
|
|
92
|
+
suggestion: 'Send a smaller file, or an archive of the part the user needs. Free accounts send up to 25 MiB, Pro up to 1 GiB',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return (0, error_format_js_1.errorResult)({
|
|
96
|
+
error: 'FILE_READ_FAILED',
|
|
97
|
+
message: err instanceof Error ? err.message : String(err),
|
|
98
|
+
suggestion: 'Check that filePath is an absolute path to a readable file',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const { fileName, body, size: originalSize } = payload;
|
|
102
|
+
// The whole upload-then-send sequence sits in this closure so the file is
|
|
103
|
+
// sealed for the same audience as the push that carries it.
|
|
104
|
+
const send = async (recipients) => {
|
|
105
|
+
const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title ?? fileName);
|
|
106
|
+
const target = targetDeviceId ?? config.deviceId;
|
|
107
|
+
const fileType = (0, mime_js_1.inferMimeType)(fileName);
|
|
108
|
+
// An agent that abandoned the call abandoned the send.
|
|
109
|
+
if (extra?.signal?.aborted) {
|
|
110
|
+
return (0, error_format_js_1.errorResult)({ error: 'CANCELLED', message: 'The send was cancelled before the file went out' });
|
|
111
|
+
}
|
|
112
|
+
// Step 1: Seal the attachment and the push body together, before
|
|
113
|
+
// anything is uploaded: a failure in between would leave ciphertext
|
|
114
|
+
// under no push.
|
|
115
|
+
let sealed;
|
|
116
|
+
try {
|
|
117
|
+
sealed = {
|
|
118
|
+
file: await (0, crypto_js_1.encryptFileForDevices)(body, recipients),
|
|
119
|
+
push: await (0, crypto_js_1.encryptPushBodyForDevices)({ title: pushTitle }, recipients),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error('[Crypto] Sealing the file failed:', err);
|
|
124
|
+
return (0, error_format_js_1.errorResult)({ error: push_audience_js_1.E2E_REQUIRED, message: (0, push_audience_js_1.sealFailed)('sealing the file failed') });
|
|
125
|
+
}
|
|
126
|
+
const uploadType = 'application/octet-stream';
|
|
127
|
+
// Step 2: Request upload URL
|
|
128
|
+
const upload = await client.requestUpload({ fileName, fileType: uploadType, fileSize: sealed.file.ciphertext.length });
|
|
129
|
+
// Step 3: Upload content to S3
|
|
130
|
+
await client.uploadToS3(upload.data.uploadUrl, sealed.file.ciphertext, uploadType);
|
|
131
|
+
// Step 4: Send the push. `fileType` on the descriptor stays the real
|
|
132
|
+
// type — it drives how the client renders the decrypted bytes.
|
|
133
|
+
const result = await client.sendPush({
|
|
134
|
+
type: 'file',
|
|
135
|
+
targetDeviceId: target,
|
|
136
|
+
sessionId: config.sessionId,
|
|
137
|
+
body: sealed.push.body,
|
|
138
|
+
isEncrypted: sealed.push.isEncrypted,
|
|
139
|
+
deviceKeyMap: sealed.push.deviceKeyMap,
|
|
140
|
+
senderPublicKey: sealed.push.senderPublicKey,
|
|
141
|
+
files: [{
|
|
142
|
+
fileKey: upload.data.fileKey,
|
|
143
|
+
fileName,
|
|
144
|
+
fileSize: originalSize,
|
|
145
|
+
fileType,
|
|
146
|
+
iv: sealed.file.iv,
|
|
147
|
+
deviceKeyMap: sealed.file.deviceKeyMap,
|
|
148
|
+
}],
|
|
149
|
+
});
|
|
150
|
+
return (0, error_format_js_1.textResult)({
|
|
151
|
+
pushId: result.data.pushId,
|
|
152
|
+
fileKey: upload.data.fileKey,
|
|
153
|
+
fileSize: originalSize,
|
|
154
|
+
encrypted: true,
|
|
155
|
+
delivery: 'Sent via cloud',
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
try {
|
|
159
|
+
const audience = await (0, push_audience_js_1.resolveAudience)(client);
|
|
160
|
+
if ('refused' in audience)
|
|
161
|
+
return (0, error_format_js_1.errorResult)({ error: audience.code, message: audience.refused });
|
|
162
|
+
return await send(audience.recipients);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
return (0, error_format_js_1.formatToolError)(err);
|
|
166
|
+
}
|
|
167
|
+
}));
|
|
168
|
+
};
|
|
169
|
+
exports.registerFileTool = registerFileTool;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerListTool = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const error_format_js_1 = require("../error-format.js");
|
|
6
|
+
const registerListTool = (server, client) => {
|
|
7
|
+
server.registerTool('hwip_list', {
|
|
8
|
+
description: 'List recent push notifications. Use this to check notification history, avoid duplicates, or reference previous messages. An end-to-end encrypted push shows `encrypted: true` with no title or body.',
|
|
9
|
+
annotations: {
|
|
10
|
+
readOnlyHint: true,
|
|
11
|
+
destructiveHint: false,
|
|
12
|
+
idempotentHint: true,
|
|
13
|
+
openWorldHint: true,
|
|
14
|
+
},
|
|
15
|
+
inputSchema: {
|
|
16
|
+
limit: zod_1.z
|
|
17
|
+
.number()
|
|
18
|
+
.min(1)
|
|
19
|
+
.max(20)
|
|
20
|
+
.default(5)
|
|
21
|
+
.describe('Number of pushes to return (default: 5, max: 20)'),
|
|
22
|
+
type: zod_1.z
|
|
23
|
+
.enum(['note', 'link', 'file', 'clipboard', 'hook'])
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('Filter by push type'),
|
|
26
|
+
},
|
|
27
|
+
}, async ({ limit, type }) => {
|
|
28
|
+
try {
|
|
29
|
+
const result = await client.listPushes({ limit, type });
|
|
30
|
+
// An encrypted push's body is ciphertext only the user's devices open: say so instead.
|
|
31
|
+
const summary = result.data.map((p) => ({
|
|
32
|
+
pushId: p.pushId,
|
|
33
|
+
type: p.type,
|
|
34
|
+
...(p.isEncrypted ? { encrypted: true } : { title: p.title, body: p.body?.slice(0, 100) }),
|
|
35
|
+
createdAt: p.createdAt,
|
|
36
|
+
}));
|
|
37
|
+
return (0, error_format_js_1.textResult)({ pushes: summary, total: summary.length, hasMore: result.pagination.hasMore });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return (0, error_format_js_1.formatToolError)(err);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
exports.registerListTool = registerListTool;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { HwipApiClient } from '../api-client.js';
|
|
3
|
+
/**
|
|
4
|
+
* Read-only lookups for the ids `hwip_notify` takes (`targetDeviceId`,
|
|
5
|
+
* `channelId`). The same lists are MCP resources, but an agent that only
|
|
6
|
+
* calls tools never reads a resource, so without these it had no way to
|
|
7
|
+
* find an id to target.
|
|
8
|
+
*/
|
|
9
|
+
export declare const registerLookupTools: (server: McpServer, client: HwipApiClient) => void;
|
|
10
|
+
//# sourceMappingURL=lookup.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerLookupTools = void 0;
|
|
4
|
+
const error_format_js_1 = require("../error-format.js");
|
|
5
|
+
/**
|
|
6
|
+
* Read-only lookups for the ids `hwip_notify` takes (`targetDeviceId`,
|
|
7
|
+
* `channelId`). The same lists are MCP resources, but an agent that only
|
|
8
|
+
* calls tools never reads a resource, so without these it had no way to
|
|
9
|
+
* find an id to target.
|
|
10
|
+
*/
|
|
11
|
+
const registerLookupTools = (server, client) => {
|
|
12
|
+
const readOnly = {
|
|
13
|
+
readOnlyHint: true,
|
|
14
|
+
destructiveHint: false,
|
|
15
|
+
idempotentHint: true,
|
|
16
|
+
openWorldHint: true,
|
|
17
|
+
};
|
|
18
|
+
server.registerTool('hwip_devices', {
|
|
19
|
+
description: "List the user's devices with their online status. Use a `deviceId` as hwip_notify's `targetDeviceId` to reach one device.",
|
|
20
|
+
annotations: readOnly,
|
|
21
|
+
inputSchema: {},
|
|
22
|
+
}, async () => {
|
|
23
|
+
try {
|
|
24
|
+
const result = await client.listDevices();
|
|
25
|
+
return (0, error_format_js_1.textResult)({ devices: result.data });
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
return (0, error_format_js_1.formatToolError)(err);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
server.registerTool('hwip_channels', {
|
|
32
|
+
description: "List the user's channels. Use a `channelId` with hwip_broadcast to reach a channel's subscribers.",
|
|
33
|
+
annotations: readOnly,
|
|
34
|
+
inputSchema: {},
|
|
35
|
+
}, async () => {
|
|
36
|
+
try {
|
|
37
|
+
const result = await client.listChannels();
|
|
38
|
+
return (0, error_format_js_1.textResult)({ channels: result.data });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return (0, error_format_js_1.formatToolError)(err);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
exports.registerLookupTools = registerLookupTools;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { HwipApiClient } from '../api-client.js';
|
|
3
|
+
import { type McpServerConfig } from '../config.js';
|
|
4
|
+
export declare const registerNotifyTool: (server: McpServer, client: HwipApiClient, config: McpServerConfig) => void;
|
|
5
|
+
//# sourceMappingURL=notify.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerNotifyTool = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const error_format_js_1 = require("../error-format.js");
|
|
6
|
+
const key_changes_js_1 = require("../key-changes.js");
|
|
7
|
+
const config_js_1 = require("../config.js");
|
|
8
|
+
const crypto_js_1 = require("../crypto.js");
|
|
9
|
+
const push_audience_js_1 = require("../push-audience.js");
|
|
10
|
+
const mime_js_1 = require("../mime.js");
|
|
11
|
+
const sanitize_js_1 = require("../sanitize.js");
|
|
12
|
+
// The device feed shows a short preview of the body. Anything longer gets
|
|
13
|
+
// truncated there, so we attach the full text as a file for full viewing.
|
|
14
|
+
const PREVIEW_LENGTH = 200;
|
|
15
|
+
const registerNotifyTool = (server, client, config) => {
|
|
16
|
+
server.registerTool('hwip_notify', {
|
|
17
|
+
description: 'Send a one-way push notification to the user\'s devices. Use this to inform the user about task completion, errors, or status updates. Long bodies are automatically uploaded as a file for full viewing.',
|
|
18
|
+
annotations: {
|
|
19
|
+
readOnlyHint: false,
|
|
20
|
+
destructiveHint: false,
|
|
21
|
+
openWorldHint: true,
|
|
22
|
+
},
|
|
23
|
+
inputSchema: {
|
|
24
|
+
title: zod_1.z.string().describe('Notification title'),
|
|
25
|
+
body: zod_1.z.string().optional().describe('Notification body text'),
|
|
26
|
+
url: zod_1.z.string().url().optional().describe('Optional URL to open on the device.'),
|
|
27
|
+
priority: zod_1.z
|
|
28
|
+
.enum(['low', 'normal', 'high', 'urgent'])
|
|
29
|
+
.default('normal')
|
|
30
|
+
.describe('Notification priority. Use "urgent" for critical alerts, "low" for background info.'),
|
|
31
|
+
targetDeviceId: zod_1.z.string().optional().describe('Target device ID. Omit to use configured default or send to all devices.'),
|
|
32
|
+
},
|
|
33
|
+
}, (0, key_changes_js_1.notingKeyChanges)(async ({ title, body, url, priority, targetDeviceId }) => {
|
|
34
|
+
const send = async (recipients) => {
|
|
35
|
+
const deviceId = targetDeviceId ?? config.deviceId;
|
|
36
|
+
const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title);
|
|
37
|
+
// Strip any tool-call markup that leaked into the body argument.
|
|
38
|
+
const cleanBody = (0, sanitize_js_1.sanitizeText)(body);
|
|
39
|
+
// Attach a file whenever the body would be clipped in the feed preview.
|
|
40
|
+
const isLongBody = !!cleanBody && cleanBody.length > PREVIEW_LENGTH;
|
|
41
|
+
if (isLongBody && cleanBody) {
|
|
42
|
+
const fileName = 'response.md';
|
|
43
|
+
const fileType = (0, mime_js_1.inferMimeType)(fileName);
|
|
44
|
+
// Self-contained Markdown so the file alone carries the full text.
|
|
45
|
+
const fileMarkdown = `# ${title}\n\n${cleanBody}`;
|
|
46
|
+
const fileBytes = new TextEncoder().encode(fileMarkdown).byteLength;
|
|
47
|
+
const preview = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
|
|
48
|
+
// Seal the attachment and the push body together, before anything is
|
|
49
|
+
// uploaded: a failure in between would leave ciphertext under no push.
|
|
50
|
+
let sealed;
|
|
51
|
+
try {
|
|
52
|
+
sealed = {
|
|
53
|
+
file: await (0, crypto_js_1.encryptFileForDevices)(fileMarkdown, recipients),
|
|
54
|
+
push: await (0, crypto_js_1.encryptPushBodyForDevices)({ title: pushTitle, body: preview, url }, recipients),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.error('[Crypto] Sealing the notification failed:', err);
|
|
59
|
+
return (0, error_format_js_1.errorResult)({ error: push_audience_js_1.E2E_REQUIRED, message: (0, push_audience_js_1.sealFailed)('sealing the notification failed') });
|
|
60
|
+
}
|
|
61
|
+
const uploadContentType = 'application/octet-stream';
|
|
62
|
+
const upload = await client.requestUpload({ fileName, fileType: uploadContentType, fileSize: sealed.file.ciphertext.length });
|
|
63
|
+
await client.uploadToS3(upload.data.uploadUrl, sealed.file.ciphertext, uploadContentType);
|
|
64
|
+
// No title or url beside the envelope: `isEncrypted` promises the server sees neither.
|
|
65
|
+
const pushPayload = {
|
|
66
|
+
body: sealed.push.body,
|
|
67
|
+
type: 'file',
|
|
68
|
+
priority,
|
|
69
|
+
files: [{
|
|
70
|
+
fileKey: upload.data.fileKey,
|
|
71
|
+
fileName,
|
|
72
|
+
fileSize: fileBytes,
|
|
73
|
+
fileType,
|
|
74
|
+
iv: sealed.file.iv,
|
|
75
|
+
deviceKeyMap: sealed.file.deviceKeyMap,
|
|
76
|
+
}],
|
|
77
|
+
targetDeviceId: deviceId,
|
|
78
|
+
sessionId: config.sessionId,
|
|
79
|
+
isEncrypted: sealed.push.isEncrypted,
|
|
80
|
+
deviceKeyMap: sealed.push.deviceKeyMap,
|
|
81
|
+
senderPublicKey: sealed.push.senderPublicKey,
|
|
82
|
+
};
|
|
83
|
+
const result = await client.sendPush(pushPayload);
|
|
84
|
+
return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, fileKey: upload.data.fileKey, autoFile: true, encrypted: true });
|
|
85
|
+
}
|
|
86
|
+
let enc;
|
|
87
|
+
try {
|
|
88
|
+
enc = await (0, crypto_js_1.encryptPushBodyForDevices)({ title: pushTitle, body: cleanBody, url }, recipients);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error('[Crypto] Sealing the notification failed:', err);
|
|
92
|
+
return (0, error_format_js_1.errorResult)({ error: push_audience_js_1.E2E_REQUIRED, message: (0, push_audience_js_1.sealFailed)('sealing the notification failed') });
|
|
93
|
+
}
|
|
94
|
+
const pushPayload = {
|
|
95
|
+
body: enc.body,
|
|
96
|
+
type: 'hook',
|
|
97
|
+
priority,
|
|
98
|
+
targetDeviceId: deviceId,
|
|
99
|
+
sessionId: config.sessionId,
|
|
100
|
+
isEncrypted: enc.isEncrypted,
|
|
101
|
+
deviceKeyMap: enc.deviceKeyMap,
|
|
102
|
+
senderPublicKey: enc.senderPublicKey,
|
|
103
|
+
};
|
|
104
|
+
const result = await client.sendPush(pushPayload);
|
|
105
|
+
return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, encrypted: true });
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
const audience = await (0, push_audience_js_1.resolveAudience)(client);
|
|
109
|
+
if ('refused' in audience)
|
|
110
|
+
return (0, error_format_js_1.errorResult)({ error: audience.code, message: audience.refused });
|
|
111
|
+
return await send(audience.recipients);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return (0, error_format_js_1.formatToolError)(err);
|
|
115
|
+
}
|
|
116
|
+
}));
|
|
117
|
+
};
|
|
118
|
+
exports.registerNotifyTool = registerNotifyTool;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { HwipApiClient } from '../api-client.js';
|
|
3
|
+
import type { McpServerConfig } from '../config.js';
|
|
4
|
+
export declare const registerRenameTool: (server: McpServer, client: HwipApiClient, config: McpServerConfig) => void;
|
|
5
|
+
//# sourceMappingURL=rename.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerRenameTool = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const error_format_js_1 = require("../error-format.js");
|
|
6
|
+
// Mirrors the server cap (hwip apps/server/src/functions/devices.ts).
|
|
7
|
+
const MAX_ALIAS_LENGTH = 60;
|
|
8
|
+
const registerRenameTool = (server, client, config) => {
|
|
9
|
+
server.registerTool('hwip_session_rename', {
|
|
10
|
+
description: "Set a custom display name for THIS agent session in the user's Hwip app (the Streams › Agents list). " +
|
|
11
|
+
'Label what this session is working on — e.g. "Prod deploy" or "Auth refactor" — so the user can tell ' +
|
|
12
|
+
'parallel sessions apart on their phone. Renames the current session; the name persists until changed.',
|
|
13
|
+
annotations: {
|
|
14
|
+
readOnlyHint: false,
|
|
15
|
+
destructiveHint: false,
|
|
16
|
+
openWorldHint: true,
|
|
17
|
+
},
|
|
18
|
+
inputSchema: {
|
|
19
|
+
alias: zod_1.z
|
|
20
|
+
.string()
|
|
21
|
+
.trim()
|
|
22
|
+
.min(1)
|
|
23
|
+
.max(MAX_ALIAS_LENGTH)
|
|
24
|
+
.describe(`Display name for this session (max ${MAX_ALIAS_LENGTH} characters).`),
|
|
25
|
+
},
|
|
26
|
+
}, async ({ alias }) => {
|
|
27
|
+
try {
|
|
28
|
+
const { agentDeviceId, agentSessionName } = config;
|
|
29
|
+
// The session key is resolved once at startup from the listener device
|
|
30
|
+
// id + tmux session name. Absent it, there is nothing to rename.
|
|
31
|
+
if (!agentDeviceId || !agentSessionName) {
|
|
32
|
+
return (0, error_format_js_1.textResult)({
|
|
33
|
+
renamed: false,
|
|
34
|
+
reason: 'No active agent session detected — this tool renames the tmux session it runs in, which requires `hwip listener` and a tmux session.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
await client.renameAgentSession(agentDeviceId, agentSessionName, alias);
|
|
38
|
+
return (0, error_format_js_1.textResult)({ renamed: true, session: agentSessionName, alias });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return (0, error_format_js_1.formatToolError)(err);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
exports.registerRenameTool = registerRenameTool;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinning the account's devices as a sender sees them (twin of the cli's `src/trust-pins.ts` and `src/trust-lock.ts`).
|
|
3
|
+
*
|
|
4
|
+
* `hwip_notify`, `hwip_file`, `hwip_ask` and `hwip_agent_send` read `GET /devices` before they seal. Every device that
|
|
5
|
+
* lists a key is pinned the first time it is seen, and a pin whose device now lists another key is replaced — with one
|
|
6
|
+
* warning, because a key that changed is also what a relay swapping the list looks like, and a record (`key-changes.ts`)
|
|
7
|
+
* the next tool result shows, since this server's stderr reaches nobody. Nothing here decides who is
|
|
8
|
+
* sealed for: the list does. The pins are what the cli listener holds a request against and what a person compares
|
|
9
|
+
* with `hwip trust`; trust never stops a send.
|
|
10
|
+
*
|
|
11
|
+
* The file is shared with the cli, which also keeps the listener's sealed-command numbers in it (`commands`,
|
|
12
|
+
* `commandKeys`). Every writer re-reads it under `trusted-devices.lock` and rewrites only its own part: here that is
|
|
13
|
+
* `devices` (and, for a replaced pin, the old key's command number carried into `commandKeys`, as the cli does), with
|
|
14
|
+
* every other field kept as it was. `sealedOnce` is read past and not written back.
|
|
15
|
+
*/
|
|
16
|
+
/** The cli listener's state file. */
|
|
17
|
+
export declare const TRUST_PATH: string;
|
|
18
|
+
/** A device id as the app, the cli and the server mint them. */
|
|
19
|
+
export declare const DEVICE_ID: RegExp;
|
|
20
|
+
/** Devices pinned at most: a list padded with made-up devices must not grow the file one rewrite at a time. */
|
|
21
|
+
export declare const MAX_PINNED_DEVICES = 64;
|
|
22
|
+
type Pin = {
|
|
23
|
+
publicKey: string;
|
|
24
|
+
pinnedAt: string;
|
|
25
|
+
};
|
|
26
|
+
/** The file as read: `devices` checked, every other field kept for the rewrite. */
|
|
27
|
+
type TrustFile = Record<string, unknown> & {
|
|
28
|
+
devices: Record<string, Pin>;
|
|
29
|
+
};
|
|
30
|
+
/** The trust file: a missing one is empty, one that will not read or parse is `null`, never a guess. */
|
|
31
|
+
export declare const readTrustFile: (path: string) => TrustFile | null;
|
|
32
|
+
/**
|
|
33
|
+
* Run `fn` holding `trusted-devices.lock` beside the trust file, created with `wx` as the cli creates it. `locked: false`:
|
|
34
|
+
* the lock was held past `waitMs` or cannot be made, and `fn` did not run.
|
|
35
|
+
*/
|
|
36
|
+
export declare const withTrustLock: <T>(trustPath: string, fn: () => T, { waitMs }?: {
|
|
37
|
+
waitMs?: number;
|
|
38
|
+
}) => {
|
|
39
|
+
locked: true;
|
|
40
|
+
value: T;
|
|
41
|
+
} | {
|
|
42
|
+
locked: false;
|
|
43
|
+
};
|
|
44
|
+
export interface ObservedDevice {
|
|
45
|
+
deviceId: string;
|
|
46
|
+
publicKey?: string;
|
|
47
|
+
/** The name the account lists it under, for the key-change record. */
|
|
48
|
+
nickname?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface Observed {
|
|
51
|
+
/** Devices pinned for the first time. */
|
|
52
|
+
pinned: string[];
|
|
53
|
+
/** Devices whose pin was replaced by the key they list now — or would have been, when the status is not `ok`. */
|
|
54
|
+
replaced: string[];
|
|
55
|
+
/** Devices not pinned: their key is pinned for another id, or the cap is reached. */
|
|
56
|
+
skipped: string[];
|
|
57
|
+
/** `unreadable`: nothing was checked or written. `unlocked`: nothing was written. A send goes on in both cases. */
|
|
58
|
+
status: 'ok' | 'unreadable' | 'unlocked';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Pin what `devices` lists: a new device is pinned, a changed key replaces its pin with one warning. Never refuses and
|
|
62
|
+
* never throws — a file that cannot be read or written leaves the pins as they were and the send goes on.
|
|
63
|
+
*/
|
|
64
|
+
export declare const observeDevices: (path: string, devices: ObservedDevice[], { selfId, now, warn }?: {
|
|
65
|
+
selfId?: string;
|
|
66
|
+
now?: () => Date;
|
|
67
|
+
warn?: (line: string) => void;
|
|
68
|
+
}) => Observed;
|
|
69
|
+
export {};
|
|
70
|
+
//# sourceMappingURL=trust-pins.d.ts.map
|