@palmpush/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.
- package/README.md +115 -0
- package/package.json +35 -0
- package/src/cli.js +23 -0
- package/src/config.js +174 -0
- package/src/index.js +7 -0
- package/src/server.js +231 -0
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# @palmpush/mcp
|
|
2
|
+
|
|
3
|
+
Local MCP server for sending palmpush notifications from AI agents.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @palmpush/mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You can also run it with `npx @palmpush/mcp`.
|
|
12
|
+
|
|
13
|
+
## Single-listener setup
|
|
14
|
+
|
|
15
|
+
Use this when every notification should go to one palmpush listener.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
PALMPUSH_LISTENER_ID=abc123abc123abc123abc123abc123ab palmpush-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The MCP server exposes one tool: `send_push_notification`.
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"title": "Hello",
|
|
26
|
+
"body": "world."
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Named-listener setup
|
|
31
|
+
|
|
32
|
+
Use a config file when the agent should choose between multiple palmpush
|
|
33
|
+
listeners. When `PALMPUSH_CONFIG` is set, `PALMPUSH_LISTENER_ID` is ignored.
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"listeners": [
|
|
38
|
+
{
|
|
39
|
+
"name": "human in the loop",
|
|
40
|
+
"listenerId": "aaa123aaa123aaa123aaa123aaa123aa",
|
|
41
|
+
"description": "Use when the user needs to decide, approve, or intervene."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "informative only",
|
|
45
|
+
"listenerId": "bbb123bbb123bbb123bbb123bbb123bb",
|
|
46
|
+
"description": "Use for status updates, completions, and non-blocking information."
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Start the server with:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
PALMPUSH_CONFIG=/path/to/palmpush-mcp.json palmpush-mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
In named-listener mode, the tool requires `listenerName`:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"title": "Approval needed",
|
|
63
|
+
"body": "Should I continue?",
|
|
64
|
+
"listenerName": "human in the loop"
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The MCP tool description shows listener names and descriptions, but never shows
|
|
69
|
+
listener IDs.
|
|
70
|
+
|
|
71
|
+
## Optional API URL
|
|
72
|
+
|
|
73
|
+
By default, the server sends to the deployed palmpush API. To override it:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
PALMPUSH_LISTENER_ID=abc123abc123abc123abc123abc123ab PALMPUSH_API_URL=http://127.0.0.1:5001/local/api palmpush-mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## MCP client example
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"mcpServers": {
|
|
84
|
+
"palmpush": {
|
|
85
|
+
"command": "npx",
|
|
86
|
+
"args": ["-y", "@palmpush/mcp"],
|
|
87
|
+
"env": {
|
|
88
|
+
"PALMPUSH_CONFIG": "/path/to/palmpush-mcp.json"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
For a single listener, use:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"mcpServers": {
|
|
100
|
+
"palmpush": {
|
|
101
|
+
"command": "npx",
|
|
102
|
+
"args": ["-y", "@palmpush/mcp"],
|
|
103
|
+
"env": {
|
|
104
|
+
"PALMPUSH_LISTENER_ID": "abc123abc123abc123abc123abc123ab"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Security
|
|
112
|
+
|
|
113
|
+
palmpush listener IDs are capability secrets. Anyone with a listener ID can send
|
|
114
|
+
notifications to that listener. Keep the MCP config private, avoid committing it
|
|
115
|
+
to projects, and prefer storing it outside workspaces that AI agents can read.
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@palmpush/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local MCP server for sending palmpush notifications from AI agents.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://www.palmpush.com",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"palmpush",
|
|
10
|
+
"mcp",
|
|
11
|
+
"model-context-protocol",
|
|
12
|
+
"push-notifications",
|
|
13
|
+
"ai-agents"
|
|
14
|
+
],
|
|
15
|
+
"bin": {
|
|
16
|
+
"palmpush-mcp": "src/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"main": "./src/index.js",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.js",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
32
|
+
"palmpush": "0.2.0",
|
|
33
|
+
"zod": "^3.25.76"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { readPalmpushMcpConfig } from './config.js';
|
|
5
|
+
import { createPalmpushMcpServer } from './server.js';
|
|
6
|
+
|
|
7
|
+
const main = async () => {
|
|
8
|
+
const config = readPalmpushMcpConfig();
|
|
9
|
+
const server = createPalmpushMcpServer(config);
|
|
10
|
+
const transport = new StdioServerTransport();
|
|
11
|
+
|
|
12
|
+
await server.connect(transport);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
main().catch((error) => {
|
|
16
|
+
const message =
|
|
17
|
+
error && typeof error.message === 'string'
|
|
18
|
+
? error.message
|
|
19
|
+
: 'palmpush MCP server failed to start.';
|
|
20
|
+
|
|
21
|
+
process.stderr.write(`${message}\n`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
package/src/config.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { readFileSync as defaultReadFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
|
|
5
|
+
const uuidWithoutHyphensPattern = /^[0-9a-f]{32}$/;
|
|
6
|
+
|
|
7
|
+
const isDefined = (value) => value !== undefined && value !== null;
|
|
8
|
+
|
|
9
|
+
const readOptionalEnvString = (value) => {
|
|
10
|
+
if (!isDefined(value)) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const normalizedValue = String(value).trim();
|
|
15
|
+
|
|
16
|
+
return normalizedValue || undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const normalizeListenerId = (value, fieldName) => {
|
|
20
|
+
if (typeof value !== 'string') {
|
|
21
|
+
throw new Error(`${fieldName} must be a string.`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const listenerId = value.trim().toLowerCase().replaceAll('-', '');
|
|
25
|
+
|
|
26
|
+
if (!uuidWithoutHyphensPattern.test(listenerId)) {
|
|
27
|
+
throw new Error(`${fieldName} must be a UUID with or without hyphens.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return listenerId;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const readListenerName = (value, fieldName) => {
|
|
34
|
+
if (typeof value !== 'string') {
|
|
35
|
+
throw new Error(`${fieldName} must be a string.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const listenerName = value.trim();
|
|
39
|
+
|
|
40
|
+
if (!listenerName) {
|
|
41
|
+
throw new Error(`${fieldName} is required.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (listenerName.includes('<') || listenerName.includes('>')) {
|
|
45
|
+
throw new Error(`${fieldName} must not contain "<" or ">".`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return listenerName;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const readOptionalDescription = (value, fieldName) => {
|
|
52
|
+
if (!isDefined(value)) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof value !== 'string') {
|
|
57
|
+
throw new Error(`${fieldName} must be a string when provided.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const description = value.trim();
|
|
61
|
+
|
|
62
|
+
return description || undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const resolveConfigPath = (configPath, cwd) =>
|
|
66
|
+
path.isAbsolute(configPath) ? configPath : path.resolve(cwd, configPath);
|
|
67
|
+
|
|
68
|
+
const readJsonConfig = ({ configPath, cwd, readFileSync }) => {
|
|
69
|
+
const resolvedConfigPath = resolveConfigPath(configPath, cwd);
|
|
70
|
+
const configText = readFileSync(resolvedConfigPath, 'utf8');
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
return {
|
|
74
|
+
config: JSON.parse(configText),
|
|
75
|
+
configPath: resolvedConfigPath,
|
|
76
|
+
};
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`PALMPUSH_CONFIG must point to valid JSON: ${error.message}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const readConfigListeners = (config) => {
|
|
85
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
86
|
+
throw new Error('PALMPUSH_CONFIG JSON must be an object.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!Array.isArray(config.listeners)) {
|
|
90
|
+
throw new Error('PALMPUSH_CONFIG JSON must include a listeners array.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (config.listeners.length === 0) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
'PALMPUSH_CONFIG listeners must include at least one listener.',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const seenListenerNames = new Set();
|
|
100
|
+
|
|
101
|
+
return config.listeners.map((listener, index) => {
|
|
102
|
+
const fieldPrefix = `listeners[${index}]`;
|
|
103
|
+
|
|
104
|
+
if (!listener || typeof listener !== 'object' || Array.isArray(listener)) {
|
|
105
|
+
throw new Error(`${fieldPrefix} must be an object.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const name = readListenerName(listener.name, `${fieldPrefix}.name`);
|
|
109
|
+
|
|
110
|
+
if (seenListenerNames.has(name)) {
|
|
111
|
+
throw new Error(`Duplicate listener name in PALMPUSH_CONFIG: "${name}".`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
seenListenerNames.add(name);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
name,
|
|
118
|
+
listenerId: normalizeListenerId(
|
|
119
|
+
listener.listenerId,
|
|
120
|
+
`${fieldPrefix}.listenerId`,
|
|
121
|
+
),
|
|
122
|
+
description: readOptionalDescription(
|
|
123
|
+
listener.description,
|
|
124
|
+
`${fieldPrefix}.description`,
|
|
125
|
+
),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Reads the palmpush MCP runtime configuration.
|
|
132
|
+
*
|
|
133
|
+
* Config-file mode is enabled only when `PALMPUSH_CONFIG` is set. In that mode,
|
|
134
|
+
* listener IDs come exclusively from the config file and `PALMPUSH_LISTENER_ID`
|
|
135
|
+
* is ignored.
|
|
136
|
+
*
|
|
137
|
+
* @param {{env?: Record<string, string | undefined>, cwd?: string, readFileSync?: typeof defaultReadFileSync}} [options] - Runtime inputs.
|
|
138
|
+
* @returns {{mode: 'config', apiUrl?: string, configPath: string, listeners: {name: string, listenerId: string, description?: string}[]} | {mode: 'env', apiUrl?: string, listenerId: string}} MCP runtime config.
|
|
139
|
+
*/
|
|
140
|
+
export const readPalmpushMcpConfig = ({
|
|
141
|
+
env = process.env,
|
|
142
|
+
cwd = process.cwd(),
|
|
143
|
+
readFileSync = defaultReadFileSync,
|
|
144
|
+
} = {}) => {
|
|
145
|
+
const apiUrl = readOptionalEnvString(env.PALMPUSH_API_URL);
|
|
146
|
+
const configPath = readOptionalEnvString(env.PALMPUSH_CONFIG);
|
|
147
|
+
|
|
148
|
+
if (configPath) {
|
|
149
|
+
const { config, configPath: resolvedConfigPath } = readJsonConfig({
|
|
150
|
+
configPath,
|
|
151
|
+
cwd,
|
|
152
|
+
readFileSync,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
mode: 'config',
|
|
157
|
+
apiUrl,
|
|
158
|
+
configPath: resolvedConfigPath,
|
|
159
|
+
listeners: readConfigListeners(config),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const listenerId = readOptionalEnvString(env.PALMPUSH_LISTENER_ID);
|
|
164
|
+
|
|
165
|
+
if (!listenerId) {
|
|
166
|
+
throw new Error('Set PALMPUSH_LISTENER_ID or PALMPUSH_CONFIG.');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
mode: 'env',
|
|
171
|
+
apiUrl,
|
|
172
|
+
listenerId: normalizeListenerId(listenerId, 'PALMPUSH_LISTENER_ID'),
|
|
173
|
+
};
|
|
174
|
+
};
|
package/src/index.js
ADDED
package/src/server.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import palmpush from 'palmpush';
|
|
3
|
+
import * as z from 'zod/v4';
|
|
4
|
+
|
|
5
|
+
export const SERVER_NAME = '@palmpush/mcp';
|
|
6
|
+
export const SERVER_VERSION = '0.1.0';
|
|
7
|
+
export const SEND_PUSH_TOOL_NAME = 'send_push_notification';
|
|
8
|
+
|
|
9
|
+
const redactedListenerIdPattern =
|
|
10
|
+
/\b[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\b/gi;
|
|
11
|
+
|
|
12
|
+
const redactListenerIds = (value) =>
|
|
13
|
+
String(value).replaceAll(redactedListenerIdPattern, '[listener-id-redacted]');
|
|
14
|
+
|
|
15
|
+
const escapeToolDescriptionText = (value) =>
|
|
16
|
+
String(value)
|
|
17
|
+
.replaceAll('&', '&')
|
|
18
|
+
.replaceAll('<', '<')
|
|
19
|
+
.replaceAll('>', '>');
|
|
20
|
+
|
|
21
|
+
const getSafeErrorMessage = (error) => {
|
|
22
|
+
const message =
|
|
23
|
+
error && typeof error.message === 'string'
|
|
24
|
+
? error.message
|
|
25
|
+
: 'palmpush failed to send the notification.';
|
|
26
|
+
|
|
27
|
+
return redactListenerIds(message);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const getDeliverySummary = (response) => {
|
|
31
|
+
const delivery = response?.delivery;
|
|
32
|
+
|
|
33
|
+
if (!delivery || typeof delivery !== 'object') {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sentCount =
|
|
38
|
+
typeof delivery.sentCount === 'number' ? delivery.sentCount : undefined;
|
|
39
|
+
const failedCount =
|
|
40
|
+
typeof delivery.failedCount === 'number' ? delivery.failedCount : undefined;
|
|
41
|
+
|
|
42
|
+
if (sentCount === undefined && failedCount === undefined) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const parts = [];
|
|
47
|
+
|
|
48
|
+
if (sentCount !== undefined) {
|
|
49
|
+
parts.push(`${sentCount} sent`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (failedCount !== undefined) {
|
|
53
|
+
parts.push(`${failedCount} failed`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return parts.join(', ');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const formatAvailableListeners = (listeners) =>
|
|
60
|
+
listeners
|
|
61
|
+
.map((listener) => {
|
|
62
|
+
const name = escapeToolDescriptionText(listener.name);
|
|
63
|
+
const description = listener.description
|
|
64
|
+
? escapeToolDescriptionText(listener.description)
|
|
65
|
+
: 'No description configured.';
|
|
66
|
+
|
|
67
|
+
return `- <listenerName>${name}</listenerName>: ${description}`;
|
|
68
|
+
})
|
|
69
|
+
.join('\n');
|
|
70
|
+
|
|
71
|
+
const createToolDescription = (config) => {
|
|
72
|
+
if (config.mode === 'env') {
|
|
73
|
+
return [
|
|
74
|
+
'Send a palmpush notification to the single listener configured with PALMPUSH_LISTENER_ID.',
|
|
75
|
+
'Use this for notifying the user from an AI agent, automation, or local workflow.',
|
|
76
|
+
].join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return [
|
|
80
|
+
'Send a palmpush notification to one configured listener.',
|
|
81
|
+
'You must set listenerName by copying exactly one value from the <listenerName> entries below.',
|
|
82
|
+
'Available listeners:',
|
|
83
|
+
formatAvailableListeners(config.listeners),
|
|
84
|
+
].join('\n');
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const createInputSchema = (config) => {
|
|
88
|
+
const baseSchema = {
|
|
89
|
+
title: z
|
|
90
|
+
.string()
|
|
91
|
+
.trim()
|
|
92
|
+
.min(1)
|
|
93
|
+
.max(120)
|
|
94
|
+
.describe('Short push notification title.'),
|
|
95
|
+
body: z
|
|
96
|
+
.string()
|
|
97
|
+
.trim()
|
|
98
|
+
.min(1)
|
|
99
|
+
.max(4096)
|
|
100
|
+
.describe('Push notification body text.'),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (config.mode === 'env') {
|
|
104
|
+
return baseSchema;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
...baseSchema,
|
|
109
|
+
listenerName: z
|
|
110
|
+
.string()
|
|
111
|
+
.trim()
|
|
112
|
+
.min(1)
|
|
113
|
+
.describe(
|
|
114
|
+
'Copy exactly one configured listener name from the <listenerName> entries in the tool description.',
|
|
115
|
+
),
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const getListenerTarget = ({ config, listenerName }) => {
|
|
120
|
+
if (config.mode === 'env') {
|
|
121
|
+
return {
|
|
122
|
+
listenerId: config.listenerId,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const listener = config.listeners.find((item) => item.name === listenerName);
|
|
127
|
+
|
|
128
|
+
if (!listener) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Unknown listenerName "${redactListenerIds(listenerName)}". Use one of: ${config.listeners
|
|
131
|
+
.map((item) => `"${item.name}"`)
|
|
132
|
+
.join(', ')}.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
listenerId: listener.listenerId,
|
|
138
|
+
listenerName: listener.name,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const createClient = ({ apiUrl, fetchImpl, listenerId }) =>
|
|
143
|
+
palmpush({
|
|
144
|
+
apiUrl,
|
|
145
|
+
fetch: fetchImpl,
|
|
146
|
+
listenerId,
|
|
147
|
+
mode: 'strict',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Creates the local stdio palmpush MCP server.
|
|
152
|
+
*
|
|
153
|
+
* @param {{mode: 'config', apiUrl?: string, listeners: {name: string, listenerId: string, description?: string}[]} | {mode: 'env', apiUrl?: string, listenerId: string}} config - Runtime config.
|
|
154
|
+
* @param {{fetch?: typeof fetch}} [options] - Test/runtime overrides.
|
|
155
|
+
* @returns {McpServer} Configured MCP server.
|
|
156
|
+
*/
|
|
157
|
+
export const createPalmpushMcpServer = (config, options = {}) => {
|
|
158
|
+
const server = new McpServer({
|
|
159
|
+
name: SERVER_NAME,
|
|
160
|
+
version: SERVER_VERSION,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
server.registerTool(
|
|
164
|
+
SEND_PUSH_TOOL_NAME,
|
|
165
|
+
{
|
|
166
|
+
title: 'Send palmpush notification',
|
|
167
|
+
description: createToolDescription(config),
|
|
168
|
+
inputSchema: createInputSchema(config),
|
|
169
|
+
annotations: {
|
|
170
|
+
title: 'Send palmpush notification',
|
|
171
|
+
readOnlyHint: false,
|
|
172
|
+
destructiveHint: false,
|
|
173
|
+
idempotentHint: false,
|
|
174
|
+
openWorldHint: true,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
async ({ title, body, listenerName }) => {
|
|
178
|
+
let target;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
target = getListenerTarget({ config, listenerName });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return {
|
|
184
|
+
isError: true,
|
|
185
|
+
content: [
|
|
186
|
+
{
|
|
187
|
+
type: 'text',
|
|
188
|
+
text: getSafeErrorMessage(error),
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const client = createClient({
|
|
196
|
+
apiUrl: config.apiUrl,
|
|
197
|
+
fetchImpl: options.fetch,
|
|
198
|
+
listenerId: target.listenerId,
|
|
199
|
+
});
|
|
200
|
+
const response = await client.pushStrict({ title, body });
|
|
201
|
+
const deliverySummary = getDeliverySummary(response);
|
|
202
|
+
const targetSummary = target.listenerName
|
|
203
|
+
? ` to "${target.listenerName}"`
|
|
204
|
+
: '';
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
content: [
|
|
208
|
+
{
|
|
209
|
+
type: 'text',
|
|
210
|
+
text: deliverySummary
|
|
211
|
+
? `palmpush notification sent${targetSummary}. Delivery: ${deliverySummary}.`
|
|
212
|
+
: `palmpush notification sent${targetSummary}.`,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return {
|
|
218
|
+
isError: true,
|
|
219
|
+
content: [
|
|
220
|
+
{
|
|
221
|
+
type: 'text',
|
|
222
|
+
text: getSafeErrorMessage(error),
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return server;
|
|
231
|
+
};
|