@pilat/mcp-notify 1.0.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 +64 -0
- package/dist/cache/channels.d.ts +3 -0
- package/dist/cache/channels.d.ts.map +1 -0
- package/dist/cache/channels.js +45 -0
- package/dist/cache/channels.js.map +1 -0
- package/dist/cache/db.d.ts +4 -0
- package/dist/cache/db.d.ts.map +1 -0
- package/dist/cache/db.js +56 -0
- package/dist/cache/db.js.map +1 -0
- package/dist/cache/sync.d.ts +15 -0
- package/dist/cache/sync.d.ts.map +1 -0
- package/dist/cache/sync.js +88 -0
- package/dist/cache/sync.js.map +1 -0
- package/dist/cache/users.d.ts +4 -0
- package/dist/cache/users.d.ts.map +1 -0
- package/dist/cache/users.js +72 -0
- package/dist/cache/users.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/mentions/resolver.d.ts +10 -0
- package/dist/mentions/resolver.d.ts.map +1 -0
- package/dist/mentions/resolver.js +42 -0
- package/dist/mentions/resolver.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +108 -0
- package/dist/server.js.map +1 -0
- package/dist/slack/client.d.ts +3 -0
- package/dist/slack/client.d.ts.map +1 -0
- package/dist/slack/client.js +20 -0
- package/dist/slack/client.js.map +1 -0
- package/dist/slack/send.d.ts +3 -0
- package/dist/slack/send.d.ts.map +1 -0
- package/dist/slack/send.js +90 -0
- package/dist/slack/send.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/errors.d.ts +14 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +28 -0
- package/dist/utils/errors.js.map +1 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# mcp-notify
|
|
2
|
+
|
|
3
|
+
Minimal MCP server for sending Slack messages as user (xoxc + cookie auth). Fire-and-forget — single `send_message` tool, nothing else.
|
|
4
|
+
|
|
5
|
+
> **Looking for a full-featured Slack MCP?** Check out [korotovsky/slack-mcp-server](https://github.com/korotovsky/slack-mcp-server) — it supports reading, searching, reactions, threads, DMs, and much more. We recommend it for most use cases.
|
|
6
|
+
>
|
|
7
|
+
> This project exists because we needed two things it doesn't offer:
|
|
8
|
+
> - **Bot signature** — every message gets a `:robot_face:` context block so it's clear the message was sent by an AI assistant, not a human
|
|
9
|
+
> - **Concurrent safety** — SQLite with WAL mode and check-lock-recheck sync pattern, safe for multiple MCP instances running in parallel
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
### Claude Code
|
|
14
|
+
|
|
15
|
+
Add to your MCP config (`~/.claude/settings.json` or project settings):
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"mcp-notify": {
|
|
21
|
+
"type": "stdio",
|
|
22
|
+
"command": "npx",
|
|
23
|
+
"args": ["-y", "@pilat/mcp-notify"],
|
|
24
|
+
"env": {
|
|
25
|
+
"SLACK_MCP_XOXC_TOKEN": "xoxc-...",
|
|
26
|
+
"SLACK_MCP_XOXD_TOKEN": "xoxd-..."
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Other MCP clients
|
|
34
|
+
|
|
35
|
+
Use `npx @pilat/mcp-notify` as the command with stdio transport. Pass credentials as environment variables:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
SLACK_MCP_XOXC_TOKEN=xoxc-... SLACK_MCP_XOXD_TOKEN=xoxd-... npx @pilat/mcp-notify
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Environment Variables
|
|
42
|
+
|
|
43
|
+
| Variable | Required | Description |
|
|
44
|
+
|----------|----------|-------------|
|
|
45
|
+
| `SLACK_MCP_XOXC_TOKEN` | Yes | User's `xoxc-...` token |
|
|
46
|
+
| `SLACK_MCP_XOXD_TOKEN` | Yes | User's `xoxd-...` session token (value of the `d` cookie) |
|
|
47
|
+
| `SLACK_MCP_DATA_DIR` | No | Custom path for SQLite cache (default: `~/.local/share/mcp-notify`) |
|
|
48
|
+
|
|
49
|
+
### How to get credentials
|
|
50
|
+
|
|
51
|
+
1. Open Slack in browser (not desktop app)
|
|
52
|
+
2. Open DevTools → Network tab
|
|
53
|
+
3. Make any action in Slack (switch channel, send message)
|
|
54
|
+
4. Find any request to `api.slack.com` → Headers tab
|
|
55
|
+
5. **SLACK_MCP_XOXC_TOKEN**: from request payload, find `token=xoxc-...`
|
|
56
|
+
6. **SLACK_MCP_XOXD_TOKEN**: from Cookie header, find `d=xoxd-...` — copy only the `xoxd-...` part (without `d=`)
|
|
57
|
+
|
|
58
|
+
## Architecture
|
|
59
|
+
|
|
60
|
+
Single tool: `send_message`. Messages are sent with Block Kit (section + context `:robot_face:`) with plain text fallback.
|
|
61
|
+
|
|
62
|
+
- **SQLite cache** (`~/.local/share/mcp-notify/data.db`, WAL mode) — channels, users, user groups with lazy sync on first cache miss, 24h TTL
|
|
63
|
+
- **Mention resolution** — `@username` → `<@U123>`, `@grouphandle` → `<!subteam^ID>`. Groups take priority. Resolved in parallel.
|
|
64
|
+
- **Concurrent sync safety** — CAS-based check-lock-recheck pattern via `sync_meta` table
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channels.d.ts","sourceRoot":"","sources":["../../src/cache/channels.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AA+ChD,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAS1F"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getDb } from './db.js';
|
|
2
|
+
import { syncIfNeeded, isTableSynced, CACHE_TTL_MS } from './sync.js';
|
|
3
|
+
function lookupChannel(name) {
|
|
4
|
+
const db = getDb();
|
|
5
|
+
const row = db.prepare('SELECT id FROM channels WHERE name = ? AND synced_at > ?').get(name, Date.now() - CACHE_TTL_MS);
|
|
6
|
+
return row?.id ?? null;
|
|
7
|
+
}
|
|
8
|
+
async function fetchChannels(client) {
|
|
9
|
+
const db = getDb();
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
const channels = [];
|
|
12
|
+
let cursor;
|
|
13
|
+
do {
|
|
14
|
+
const result = await client.conversations.list({
|
|
15
|
+
types: 'public_channel,private_channel',
|
|
16
|
+
exclude_archived: true,
|
|
17
|
+
limit: 1000,
|
|
18
|
+
cursor,
|
|
19
|
+
});
|
|
20
|
+
for (const ch of result.channels ?? []) {
|
|
21
|
+
if (ch.id && ch.name) {
|
|
22
|
+
channels.push({ id: ch.id, name: ch.name });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
cursor = result.response_metadata?.next_cursor || undefined;
|
|
26
|
+
} while (cursor);
|
|
27
|
+
const insert = db.prepare('INSERT OR REPLACE INTO channels (id, name, synced_at) VALUES (?, ?, ?)');
|
|
28
|
+
const tx = db.transaction(() => {
|
|
29
|
+
db.prepare('DELETE FROM channels').run();
|
|
30
|
+
for (const ch of channels) {
|
|
31
|
+
insert.run(ch.id, ch.name, now);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
tx();
|
|
35
|
+
}
|
|
36
|
+
export async function getChannelId(name, client) {
|
|
37
|
+
const cached = lookupChannel(name);
|
|
38
|
+
if (cached !== null)
|
|
39
|
+
return cached;
|
|
40
|
+
if (!isTableSynced('channels')) {
|
|
41
|
+
return syncIfNeeded('channels', () => lookupChannel(name), () => fetchChannels(client));
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=channels.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channels.js","sourceRoot":"","sources":["../../src/cache/channels.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAEtE,SAAS,aAAa,CAAC,IAAY;IACjC,MAAM,EAAE,GAAG,KAAK,EAAE,CAAC;IACnB,MAAM,GAAG,GAAG,EAAE,CAAC,OAAO,CACpB,0DAA0D,CAC3D,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,YAAY,CAA+B,CAAC;IACrE,OAAO,GAAG,EAAE,EAAE,IAAI,IAAI,CAAC;AACzB,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,MAAiB;IAC5C,MAAM,EAAE,GAAG,KAAK,EAAE,CAAC;IACnB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,MAAM,QAAQ,GAAmC,EAAE,CAAC;IAEpD,IAAI,MAA0B,CAAC;IAC/B,GAAG,CAAC;QACF,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC;YAC7C,KAAK,EAAE,gCAAgC;YACvC,gBAAgB,EAAE,IAAI;YACtB,KAAK,EAAE,IAAI;YACX,MAAM;SACP,CAAC,CAAC;QAEH,KAAK,MAAM,EAAE,IAAI,MAAM,CAAC,QAAQ,IAAI,EAAE,EAAE,CAAC;YACvC,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC;gBACrB,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC;YAC9C,CAAC;QACH,CAAC;QAED,MAAM,GAAG,MAAM,CAAC,iBAAiB,EAAE,WAAW,IAAI,SAAS,CAAC;IAC9D,CAAC,QAAQ,MAAM,EAAE;IAEjB,MAAM,MAAM,GAAG,EAAE,CAAC,OAAO,CACvB,wEAAwE,CACzE,CAAC;IACF,MAAM,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE;QAC7B,EAAE,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAC,GAAG,EAAE,CAAC;QACzC,KAAK,MAAM,EAAE,IAAI,QAAQ,EAAE,CAAC;YAC1B,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAClC,CAAC;IACH,CAAC,CAAC,CAAC;IACH,EAAE,EAAE,CAAC;AACP,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAY,EAAE,MAAiB;IAChE,MAAM,MAAM,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;IACnC,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,MAAM,CAAC;IAEnC,IAAI,CAAC,aAAa,CAAC,UAAU,CAAC,EAAE,CAAC;QAC/B,OAAO,YAAY,CAAC,UAAU,EAAE,GAAG,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC;IAC1F,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/cache/db.ts"],"names":[],"mappings":"AAGA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAItC,wBAAgB,KAAK,IAAI,QAAQ,CAAC,QAAQ,CAkDzC;AAED,wBAAgB,OAAO,IAAI,IAAI,CAK9B"}
|
package/dist/cache/db.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
let db = null;
|
|
6
|
+
export function getDb() {
|
|
7
|
+
if (db)
|
|
8
|
+
return db;
|
|
9
|
+
const dataDir = process.env.SLACK_MCP_DATA_DIR ?? join(homedir(), '.local', 'share', 'mcp-notify');
|
|
10
|
+
mkdirSync(dataDir, { recursive: true });
|
|
11
|
+
const dbPath = join(dataDir, 'data.db');
|
|
12
|
+
db = new Database(dbPath);
|
|
13
|
+
db.pragma('journal_mode = WAL');
|
|
14
|
+
db.exec(`
|
|
15
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
name TEXT NOT NULL,
|
|
18
|
+
synced_at INTEGER NOT NULL
|
|
19
|
+
);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_channels_name ON channels(name);
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
name TEXT NOT NULL,
|
|
25
|
+
synced_at INTEGER NOT NULL
|
|
26
|
+
);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_users_name ON users(name);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS user_groups (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
handle TEXT NOT NULL,
|
|
32
|
+
synced_at INTEGER NOT NULL
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_user_groups_handle ON user_groups(handle);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS sync_meta (
|
|
37
|
+
table_name TEXT PRIMARY KEY,
|
|
38
|
+
started_at INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
completed_at INTEGER NOT NULL DEFAULT 0
|
|
40
|
+
);
|
|
41
|
+
`);
|
|
42
|
+
const upsert = db.prepare('INSERT OR IGNORE INTO sync_meta (table_name, started_at, completed_at) VALUES (?, 0, 0)');
|
|
43
|
+
db.transaction(() => {
|
|
44
|
+
upsert.run('channels');
|
|
45
|
+
upsert.run('users');
|
|
46
|
+
upsert.run('user_groups');
|
|
47
|
+
})();
|
|
48
|
+
return db;
|
|
49
|
+
}
|
|
50
|
+
export function closeDb() {
|
|
51
|
+
if (db) {
|
|
52
|
+
db.close();
|
|
53
|
+
db = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=db.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db.js","sourceRoot":"","sources":["../../src/cache/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAEtC,IAAI,EAAE,GAA6B,IAAI,CAAC;AAExC,MAAM,UAAU,KAAK;IACnB,IAAI,EAAE;QAAE,OAAO,EAAE,CAAC;IAElB,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,YAAY,CAAC,CAAC;IACnG,SAAS,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAExC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IACxC,EAAE,GAAG,IAAI,QAAQ,CAAC,MAAM,CAAC,CAAC;IAE1B,EAAE,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC;IAEhC,EAAE,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BP,CAAC,CAAC;IAEH,MAAM,MAAM,GAAG,EAAE,CAAC,OAAO,CACvB,yFAAyF,CAC1F,CAAC;IACF,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE;QAClB,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QACvB,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACpB,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAC5B,CAAC,CAAC,EAAE,CAAC;IAEL,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,OAAO;IACrB,IAAI,EAAE,EAAE,CAAC;QACP,EAAE,CAAC,KAAK,EAAE,CAAC;QACX,EAAE,GAAG,IAAI,CAAC;IACZ,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const CACHE_TTL_MS: number;
|
|
2
|
+
export declare function isTableSynced(tableName: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Generic check-lock-recheck sync coordinator.
|
|
5
|
+
*
|
|
6
|
+
* 1. lookupFn() — if found, return immediately
|
|
7
|
+
* 2. Check sync_meta — if another instance is syncing, poll until done
|
|
8
|
+
* 3. Claim sync atomically (CAS) — set started_at only if no one else is syncing
|
|
9
|
+
* 4. Call apiFetchFn() — fetch data from Slack API
|
|
10
|
+
* 5. Write results — apiFetchFn handles the DB writes
|
|
11
|
+
* 6. Mark completed — set completed_at (only on success)
|
|
12
|
+
* 7. lookupFn() — return final result
|
|
13
|
+
*/
|
|
14
|
+
export declare function syncIfNeeded<T>(tableName: string, lookupFn: () => T | null, apiFetchFn: () => Promise<void>): Promise<T | null>;
|
|
15
|
+
//# sourceMappingURL=sync.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/cache/sync.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,YAAY,QAAsB,CAAC;AAMhD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAMxD;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAClC,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,CAAC,GAAG,IAAI,EACxB,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAC9B,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CA8EnB"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getDb } from './db.js';
|
|
2
|
+
import { SlackMcpError, ErrorCode } from '../utils/errors.js';
|
|
3
|
+
const SYNC_TIMEOUT_MS = 30_000;
|
|
4
|
+
const POLL_INTERVAL_MS = 500;
|
|
5
|
+
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
6
|
+
function sleep(ms) {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
export function isTableSynced(tableName) {
|
|
10
|
+
const db = getDb();
|
|
11
|
+
const meta = db.prepare('SELECT completed_at FROM sync_meta WHERE table_name = ?').get(tableName);
|
|
12
|
+
return (meta?.completed_at ?? 0) > Date.now() - CACHE_TTL_MS;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Generic check-lock-recheck sync coordinator.
|
|
16
|
+
*
|
|
17
|
+
* 1. lookupFn() — if found, return immediately
|
|
18
|
+
* 2. Check sync_meta — if another instance is syncing, poll until done
|
|
19
|
+
* 3. Claim sync atomically (CAS) — set started_at only if no one else is syncing
|
|
20
|
+
* 4. Call apiFetchFn() — fetch data from Slack API
|
|
21
|
+
* 5. Write results — apiFetchFn handles the DB writes
|
|
22
|
+
* 6. Mark completed — set completed_at (only on success)
|
|
23
|
+
* 7. lookupFn() — return final result
|
|
24
|
+
*/
|
|
25
|
+
export async function syncIfNeeded(tableName, lookupFn, apiFetchFn) {
|
|
26
|
+
// Step 1: check cache
|
|
27
|
+
const cached = lookupFn();
|
|
28
|
+
if (cached !== null)
|
|
29
|
+
return cached;
|
|
30
|
+
const db = getDb();
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
// Step 2: check if another instance is syncing
|
|
33
|
+
const meta = db.prepare('SELECT started_at, completed_at FROM sync_meta WHERE table_name = ?').get(tableName);
|
|
34
|
+
if (meta && meta.started_at > meta.completed_at) {
|
|
35
|
+
const elapsed = now - meta.started_at;
|
|
36
|
+
if (elapsed < SYNC_TIMEOUT_MS) {
|
|
37
|
+
// Another instance is syncing — poll
|
|
38
|
+
const deadline = meta.started_at + SYNC_TIMEOUT_MS;
|
|
39
|
+
while (Date.now() < deadline) {
|
|
40
|
+
await sleep(POLL_INTERVAL_MS);
|
|
41
|
+
const result = lookupFn();
|
|
42
|
+
if (result !== null)
|
|
43
|
+
return result;
|
|
44
|
+
const updated = db.prepare('SELECT completed_at FROM sync_meta WHERE table_name = ?').get(tableName);
|
|
45
|
+
if (updated && updated.completed_at >= meta.started_at) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const afterWait = lookupFn();
|
|
50
|
+
if (afterWait !== null)
|
|
51
|
+
return afterWait;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Step 3: CAS claim — atomic check-and-set, also claims stale locks
|
|
55
|
+
const claimTime = Date.now();
|
|
56
|
+
const claimed = db.prepare(`UPDATE sync_meta SET started_at = ?
|
|
57
|
+
WHERE table_name = ? AND (completed_at >= started_at OR started_at + ? < ?)`).run(claimTime, tableName, SYNC_TIMEOUT_MS, claimTime);
|
|
58
|
+
if (claimed.changes === 0) {
|
|
59
|
+
// Someone else just claimed it — poll and return
|
|
60
|
+
const deadline = claimTime + SYNC_TIMEOUT_MS;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
await sleep(POLL_INTERVAL_MS);
|
|
63
|
+
const result = lookupFn();
|
|
64
|
+
if (result !== null)
|
|
65
|
+
return result;
|
|
66
|
+
const updated = db.prepare('SELECT completed_at FROM sync_meta WHERE table_name = ?').get(tableName);
|
|
67
|
+
if (updated && updated.completed_at >= claimTime) {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return lookupFn();
|
|
72
|
+
}
|
|
73
|
+
// Step 4-5: fetch and write
|
|
74
|
+
try {
|
|
75
|
+
await apiFetchFn();
|
|
76
|
+
// Step 6: mark completed only on success
|
|
77
|
+
db.prepare('UPDATE sync_meta SET completed_at = ? WHERE table_name = ?').run(Date.now(), tableName);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// Reset lock to allow immediate retry
|
|
81
|
+
db.prepare('UPDATE sync_meta SET started_at = completed_at WHERE table_name = ?').run(tableName);
|
|
82
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
83
|
+
throw new SlackMcpError(ErrorCode.SYNC_FAILED, `Failed to sync ${tableName}: ${msg}`);
|
|
84
|
+
}
|
|
85
|
+
// Step 7: final lookup
|
|
86
|
+
return lookupFn();
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync.js","sourceRoot":"","sources":["../../src/cache/sync.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { WebClient } from '@slack/web-api';
|
|
2
|
+
export declare function resolveUser(name: string, client: WebClient): Promise<string | null>;
|
|
3
|
+
export declare function resolveUserGroup(handle: string, client: WebClient): Promise<string | null>;
|
|
4
|
+
//# sourceMappingURL=users.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/cache/users.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AA4ChD,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CASzF;AAiCD,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAShG"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getDb } from './db.js';
|
|
2
|
+
import { syncIfNeeded, isTableSynced, CACHE_TTL_MS } from './sync.js';
|
|
3
|
+
// --- Users ---
|
|
4
|
+
function lookupUser(name) {
|
|
5
|
+
const db = getDb();
|
|
6
|
+
const row = db.prepare('SELECT id FROM users WHERE name = ? AND synced_at > ?').get(name, Date.now() - CACHE_TTL_MS);
|
|
7
|
+
return row?.id ?? null;
|
|
8
|
+
}
|
|
9
|
+
async function fetchUsers(client) {
|
|
10
|
+
const db = getDb();
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
const users = [];
|
|
13
|
+
let cursor;
|
|
14
|
+
do {
|
|
15
|
+
const result = await client.users.list({ limit: 1000, cursor });
|
|
16
|
+
for (const member of result.members ?? []) {
|
|
17
|
+
if (member.id && member.name) {
|
|
18
|
+
users.push({ id: member.id, name: member.name });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
cursor = result.response_metadata?.next_cursor || undefined;
|
|
22
|
+
} while (cursor);
|
|
23
|
+
const insert = db.prepare('INSERT OR REPLACE INTO users (id, name, synced_at) VALUES (?, ?, ?)');
|
|
24
|
+
const tx = db.transaction(() => {
|
|
25
|
+
db.prepare('DELETE FROM users').run();
|
|
26
|
+
for (const u of users) {
|
|
27
|
+
insert.run(u.id, u.name, now);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
tx();
|
|
31
|
+
}
|
|
32
|
+
export async function resolveUser(name, client) {
|
|
33
|
+
const cached = lookupUser(name);
|
|
34
|
+
if (cached !== null)
|
|
35
|
+
return cached;
|
|
36
|
+
if (!isTableSynced('users')) {
|
|
37
|
+
return syncIfNeeded('users', () => lookupUser(name), () => fetchUsers(client));
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// --- User Groups ---
|
|
42
|
+
function lookupUserGroup(handle) {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
const row = db.prepare('SELECT id FROM user_groups WHERE handle = ? AND synced_at > ?').get(handle, Date.now() - CACHE_TTL_MS);
|
|
45
|
+
return row?.id ?? null;
|
|
46
|
+
}
|
|
47
|
+
async function fetchUserGroups(client) {
|
|
48
|
+
const db = getDb();
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const result = await client.usergroups.list({ include_users: false });
|
|
51
|
+
const groups = result.usergroups ?? [];
|
|
52
|
+
const insert = db.prepare('INSERT OR REPLACE INTO user_groups (id, handle, synced_at) VALUES (?, ?, ?)');
|
|
53
|
+
const tx = db.transaction(() => {
|
|
54
|
+
db.prepare('DELETE FROM user_groups').run();
|
|
55
|
+
for (const g of groups) {
|
|
56
|
+
if (g.id && g.handle) {
|
|
57
|
+
insert.run(g.id, g.handle, now);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
tx();
|
|
62
|
+
}
|
|
63
|
+
export async function resolveUserGroup(handle, client) {
|
|
64
|
+
const cached = lookupUserGroup(handle);
|
|
65
|
+
if (cached !== null)
|
|
66
|
+
return cached;
|
|
67
|
+
if (!isTableSynced('user_groups')) {
|
|
68
|
+
return syncIfNeeded('user_groups', () => lookupUserGroup(handle), () => fetchUserGroups(client));
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=users.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"users.js","sourceRoot":"","sources":["../../src/cache/users.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runServer } from './server.js';
|
|
3
|
+
import { closeDb } from './cache/db.js';
|
|
4
|
+
function shutdown() {
|
|
5
|
+
try {
|
|
6
|
+
closeDb();
|
|
7
|
+
}
|
|
8
|
+
catch { /* ignore */ }
|
|
9
|
+
process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
async function main() {
|
|
12
|
+
process.on('SIGINT', shutdown);
|
|
13
|
+
process.on('SIGTERM', shutdown);
|
|
14
|
+
await runServer();
|
|
15
|
+
}
|
|
16
|
+
main().catch((error) => {
|
|
17
|
+
try {
|
|
18
|
+
closeDb();
|
|
19
|
+
}
|
|
20
|
+
catch { /* ignore */ }
|
|
21
|
+
console.error('Fatal error:', error instanceof Error ? error.message : String(error));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
|
24
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAExC,SAAS,QAAQ;IACf,IAAI,CAAC;QAAC,OAAO,EAAE,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;IACzC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;IAEhC,MAAM,SAAS,EAAE,CAAC;AACpB,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IAC9B,IAAI,CAAC;QAAC,OAAO,EAAE,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;IACzC,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACtF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { WebClient } from '@slack/web-api';
|
|
2
|
+
/**
|
|
3
|
+
* Convert @mentions in message text to Slack format.
|
|
4
|
+
*
|
|
5
|
+
* - @grouphandle → <!subteam^ID> (groups take priority)
|
|
6
|
+
* - @username → <@ID>
|
|
7
|
+
* - Unknown mentions are left as-is.
|
|
8
|
+
*/
|
|
9
|
+
export declare function convertMentions(message: string, client: WebClient): Promise<string>;
|
|
10
|
+
//# sourceMappingURL=resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["../../src/mentions/resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAGhD;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAmCzF"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolveUserGroup, resolveUser } from '../cache/users.js';
|
|
2
|
+
/**
|
|
3
|
+
* Convert @mentions in message text to Slack format.
|
|
4
|
+
*
|
|
5
|
+
* - @grouphandle → <!subteam^ID> (groups take priority)
|
|
6
|
+
* - @username → <@ID>
|
|
7
|
+
* - Unknown mentions are left as-is.
|
|
8
|
+
*/
|
|
9
|
+
export async function convertMentions(message, client) {
|
|
10
|
+
if (!message.includes('@'))
|
|
11
|
+
return message;
|
|
12
|
+
const regex = /(?<!\w)@([\w.-]+\w)/g;
|
|
13
|
+
const names = new Set();
|
|
14
|
+
let match;
|
|
15
|
+
while ((match = regex.exec(message)) !== null) {
|
|
16
|
+
if (match[1])
|
|
17
|
+
names.add(match[1]);
|
|
18
|
+
}
|
|
19
|
+
if (names.size === 0)
|
|
20
|
+
return message;
|
|
21
|
+
// Resolve all mentions in parallel
|
|
22
|
+
const entries = await Promise.all([...names].map(async (name) => {
|
|
23
|
+
const groupId = await resolveUserGroup(name, client);
|
|
24
|
+
if (groupId)
|
|
25
|
+
return [name, `<!subteam^${groupId}>`];
|
|
26
|
+
const userId = await resolveUser(name, client);
|
|
27
|
+
if (userId)
|
|
28
|
+
return [name, `<@${userId}>`];
|
|
29
|
+
return [name, null];
|
|
30
|
+
}));
|
|
31
|
+
const replacements = new Map();
|
|
32
|
+
for (const [name, replacement] of entries) {
|
|
33
|
+
if (replacement)
|
|
34
|
+
replacements.set(name, replacement);
|
|
35
|
+
}
|
|
36
|
+
if (replacements.size === 0)
|
|
37
|
+
return message;
|
|
38
|
+
return message.replace(/(?<!\w)@([\w.-]+\w)/g, (full, name) => {
|
|
39
|
+
return replacements.get(name) ?? full;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolver.js","sourceRoot":"","sources":["../../src/mentions/resolver.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAElE;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,OAAe,EAAE,MAAiB;IACtE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,OAAO,CAAC;IAE3C,MAAM,KAAK,GAAG,sBAAsB,CAAC;IACrC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,IAAI,KAAK,CAAC;IACV,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;QAC9C,IAAI,KAAK,CAAC,CAAC,CAAC;YAAE,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IACpC,CAAC;IAED,IAAI,KAAK,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,OAAO,CAAC;IAErC,mCAAmC;IACnC,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,GAAG,CAC/B,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAoC,EAAE;QAC9D,MAAM,OAAO,GAAG,MAAM,gBAAgB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QACrD,IAAI,OAAO;YAAE,OAAO,CAAC,IAAI,EAAE,aAAa,OAAO,GAAG,CAAC,CAAC;QAEpD,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QAC/C,IAAI,MAAM;YAAE,OAAO,CAAC,IAAI,EAAE,KAAK,MAAM,GAAG,CAAC,CAAC;QAE1C,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACtB,CAAC,CAAC,CACH,CAAC;IAEF,MAAM,YAAY,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC/C,KAAK,MAAM,CAAC,IAAI,EAAE,WAAW,CAAC,IAAI,OAAO,EAAE,CAAC;QAC1C,IAAI,WAAW;YAAE,YAAY,CAAC,GAAG,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;IACvD,CAAC;IAED,IAAI,YAAY,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,OAAO,CAAC;IAE5C,OAAO,OAAO,CAAC,OAAO,CAAC,sBAAsB,EAAE,CAAC,IAAI,EAAE,IAAY,EAAE,EAAE;QACpE,OAAO,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC;IACxC,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAUnE,wBAAgB,YAAY,IAAI,MAAM,CAoGrC;AAED,wBAAsB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAI/C"}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { sendMessage } from './slack/send.js';
|
|
5
|
+
import { SlackMcpError, ErrorCode } from './utils/errors.js';
|
|
6
|
+
const SERVER_INSTRUCTIONS = `Slack message sender. Use send_message to post messages to channels or reply to threads.
|
|
7
|
+
@mentions like @username are automatically resolved to Slack user IDs.`;
|
|
8
|
+
export function createServer() {
|
|
9
|
+
const server = new Server({
|
|
10
|
+
name: 'mcp-notify',
|
|
11
|
+
version: '1.0.0',
|
|
12
|
+
}, {
|
|
13
|
+
capabilities: {
|
|
14
|
+
tools: {},
|
|
15
|
+
},
|
|
16
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
17
|
+
});
|
|
18
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
19
|
+
tools: [
|
|
20
|
+
{
|
|
21
|
+
name: 'send_message',
|
|
22
|
+
description: `Send a message to a Slack channel or thread as the authenticated user. Messages are tagged with a :robot_face: indicator so recipients know AI assisted.
|
|
23
|
+
|
|
24
|
+
WHEN TO USE: User asks to post, send, write, notify, tell, or reply in Slack.
|
|
25
|
+
|
|
26
|
+
MENTIONS: Write @username or @groupname naturally — they are auto-resolved to Slack IDs. Do NOT manually construct <@U...> syntax.
|
|
27
|
+
|
|
28
|
+
THREAD REPLIES: To reply in a thread, provide thread_ts. Extract from Slack URLs by converting the p-prefixed ID: p1718033467085279 → 1718033467.085279 (insert dot before last 6 digits).
|
|
29
|
+
|
|
30
|
+
CRITICAL — Use Slack mrkdwn, NOT Markdown:
|
|
31
|
+
*bold* (NOT **bold**)
|
|
32
|
+
_italic_ (NOT *italic*)
|
|
33
|
+
~strikethrough~
|
|
34
|
+
\`inline code\`
|
|
35
|
+
\`\`\`code block\`\`\`
|
|
36
|
+
<https://url.com|link text> (NOT [text](url))
|
|
37
|
+
> blockquote
|
|
38
|
+
• or - for bullets, 1. for numbered lists
|
|
39
|
+
:emoji_name: for emoji
|
|
40
|
+
|
|
41
|
+
NEVER use: **bold**, [link](url), # headers, tables, ---, ![images]. These render as literal text in Slack.`,
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
channel_name: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Channel name without # prefix (e.g. "general", "team-backend"). Works with public and private channels you have access to.',
|
|
48
|
+
},
|
|
49
|
+
message: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Message content in Slack mrkdwn format. IMPORTANT: Use *bold* not **bold**, use <url|text> not [text](url). @mentions like @username are auto-resolved.',
|
|
52
|
+
},
|
|
53
|
+
thread_ts: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
description: 'Thread timestamp to reply in a thread (e.g. "1718033467.085279"). From Slack URLs: strip the "p" prefix and insert a dot before the last 6 digits.',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
required: ['channel_name', 'message'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
}));
|
|
63
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
64
|
+
const { name, arguments: args } = request.params;
|
|
65
|
+
try {
|
|
66
|
+
switch (name) {
|
|
67
|
+
case 'send_message': {
|
|
68
|
+
const params = args;
|
|
69
|
+
if (!params?.channel_name || typeof params.channel_name !== 'string') {
|
|
70
|
+
throw new SlackMcpError(ErrorCode.SEND_FAILED, 'channel_name is required and must be a string');
|
|
71
|
+
}
|
|
72
|
+
if (!params?.message || typeof params.message !== 'string') {
|
|
73
|
+
throw new SlackMcpError(ErrorCode.SEND_FAILED, 'message is required and must be a string');
|
|
74
|
+
}
|
|
75
|
+
const result = await sendMessage({
|
|
76
|
+
channel_name: params.channel_name,
|
|
77
|
+
message: params.message,
|
|
78
|
+
thread_ts: typeof params.thread_ts === 'string' ? params.thread_ts : undefined,
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof SlackMcpError) {
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: 'text', text: JSON.stringify(error.toJSON(), null, 2) }],
|
|
92
|
+
isError: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (error instanceof Error) {
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
throw new Error(String(error));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
return server;
|
|
102
|
+
}
|
|
103
|
+
export async function runServer() {
|
|
104
|
+
const server = createServer();
|
|
105
|
+
const transport = new StdioServerTransport();
|
|
106
|
+
await server.connect(transport);
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACnE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,oCAAoC,CAAC;AAEnG,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAE7D,MAAM,mBAAmB,GAAG;uEAC2C,CAAC;AAExE,MAAM,UAAU,YAAY;IAC1B,MAAM,MAAM,GAAG,IAAI,MAAM,CACvB;QACE,IAAI,EAAE,YAAY;QAClB,OAAO,EAAE,OAAO;KACjB,EACD;QACE,YAAY,EAAE;YACZ,KAAK,EAAE,EAAE;SACV;QACD,YAAY,EAAE,mBAAmB;KAClC,CACF,CAAC;IAEF,MAAM,CAAC,iBAAiB,CAAC,sBAAsB,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC;QAC5D,KAAK,EAAE;YACL;gBACE,IAAI,EAAE,cAAc;gBACpB,WAAW,EAAE;;;;;;;;;;;;;;;;;;;4GAmBuF;gBACpG,WAAW,EAAE;oBACX,IAAI,EAAE,QAAQ;oBACd,UAAU,EAAE;wBACV,YAAY,EAAE;4BACZ,IAAI,EAAE,QAAQ;4BACd,WAAW,EAAE,4HAA4H;yBAC1I;wBACD,OAAO,EAAE;4BACP,IAAI,EAAE,QAAQ;4BACd,WAAW,EAAE,yJAAyJ;yBACvK;wBACD,SAAS,EAAE;4BACT,IAAI,EAAE,QAAQ;4BACd,WAAW,EAAE,oJAAoJ;yBAClK;qBACF;oBACD,QAAQ,EAAE,CAAC,cAAc,EAAE,SAAS,CAAC;iBACtC;aACF;SACF;KACF,CAAC,CAAC,CAAC;IAEJ,MAAM,CAAC,iBAAiB,CAAC,qBAAqB,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;QAChE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC;QAEjD,IAAI,CAAC;YACH,QAAQ,IAAI,EAAE,CAAC;gBACb,KAAK,cAAc,CAAC,CAAC,CAAC;oBACpB,MAAM,MAAM,GAAG,IAA2C,CAAC;oBAC3D,IAAI,CAAC,MAAM,EAAE,YAAY,IAAI,OAAO,MAAM,CAAC,YAAY,KAAK,QAAQ,EAAE,CAAC;wBACrE,MAAM,IAAI,aAAa,CAAC,SAAS,CAAC,WAAW,EAAE,+CAA+C,CAAC,CAAC;oBAClG,CAAC;oBACD,IAAI,CAAC,MAAM,EAAE,OAAO,IAAI,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ,EAAE,CAAC;wBAC3D,MAAM,IAAI,aAAa,CAAC,SAAS,CAAC,WAAW,EAAE,0CAA0C,CAAC,CAAC;oBAC7F,CAAC;oBACD,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC;wBAC/B,YAAY,EAAE,MAAM,CAAC,YAAY;wBACjC,OAAO,EAAE,MAAM,CAAC,OAAO;wBACvB,SAAS,EAAE,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS;qBAC/E,CAAC,CAAC;oBACH,OAAO;wBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;qBACnE,CAAC;gBACJ,CAAC;gBACD;oBACE,MAAM,IAAI,KAAK,CAAC,iBAAiB,IAAI,EAAE,CAAC,CAAC;YAC7C,CAAC;QACH,CAAC;QAAC,OAAO,KAAc,EAAE,CAAC;YACxB,IAAI,KAAK,YAAY,aAAa,EAAE,CAAC;gBACnC,OAAO;oBACL,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;oBAC1E,OAAO,EAAE,IAAI;iBACd,CAAC;YACJ,CAAC;YACD,IAAI,KAAK,YAAY,KAAK,EAAE,CAAC;gBAC3B,MAAM,KAAK,CAAC;YACd,CAAC;YACD,MAAM,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QACjC,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS;IAC7B,MAAM,MAAM,GAAG,YAAY,EAAE,CAAC;IAC9B,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/slack/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAK3C,wBAAgB,cAAc,IAAI,SAAS,CAiB1C"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { WebClient } from '@slack/web-api';
|
|
2
|
+
import { SlackMcpError, ErrorCode } from '../utils/errors.js';
|
|
3
|
+
let _client = null;
|
|
4
|
+
export function getSlackClient() {
|
|
5
|
+
if (_client)
|
|
6
|
+
return _client;
|
|
7
|
+
const token = process.env.SLACK_MCP_XOXC_TOKEN;
|
|
8
|
+
const xoxd = process.env.SLACK_MCP_XOXD_TOKEN;
|
|
9
|
+
if (!token) {
|
|
10
|
+
throw new SlackMcpError(ErrorCode.CONFIG_MISSING, 'SLACK_MCP_XOXC_TOKEN environment variable is required');
|
|
11
|
+
}
|
|
12
|
+
if (!xoxd) {
|
|
13
|
+
throw new SlackMcpError(ErrorCode.CONFIG_MISSING, 'SLACK_MCP_XOXD_TOKEN environment variable is required');
|
|
14
|
+
}
|
|
15
|
+
_client = new WebClient(token, {
|
|
16
|
+
headers: { Cookie: `d=${xoxd}` },
|
|
17
|
+
});
|
|
18
|
+
return _client;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/slack/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9D,IAAI,OAAO,GAAqB,IAAI,CAAC;AAErC,MAAM,UAAU,cAAc;IAC5B,IAAI,OAAO;QAAE,OAAO,OAAO,CAAC;IAE5B,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC;IAC/C,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC;IAE9C,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,aAAa,CAAC,SAAS,CAAC,cAAc,EAAE,uDAAuD,CAAC,CAAC;IAC7G,CAAC;IACD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,aAAa,CAAC,SAAS,CAAC,cAAc,EAAE,uDAAuD,CAAC,CAAC;IAC7G,CAAC;IAED,OAAO,GAAG,IAAI,SAAS,CAAC,KAAK,EAAE;QAC7B,OAAO,EAAE,EAAE,MAAM,EAAE,KAAK,IAAI,EAAE,EAAE;KACjC,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"send.d.ts","sourceRoot":"","sources":["../../src/slack/send.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAMxE,wBAAsB,WAAW,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA4EvF"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { SlackMcpError, ErrorCode } from '../utils/errors.js';
|
|
2
|
+
import { getSlackClient } from './client.js';
|
|
3
|
+
import { getChannelId } from '../cache/channels.js';
|
|
4
|
+
import { convertMentions } from '../mentions/resolver.js';
|
|
5
|
+
export async function sendMessage(params) {
|
|
6
|
+
const client = getSlackClient();
|
|
7
|
+
// Resolve mentions
|
|
8
|
+
const convertedMessage = await convertMentions(params.message, client);
|
|
9
|
+
// Resolve channel
|
|
10
|
+
const channelId = await getChannelId(params.channel_name, client);
|
|
11
|
+
if (!channelId) {
|
|
12
|
+
throw new SlackMcpError(ErrorCode.CHANNEL_NOT_FOUND, `Channel "#${params.channel_name}" not found. Check the channel name and ensure you have access.`);
|
|
13
|
+
}
|
|
14
|
+
// Build Block Kit blocks
|
|
15
|
+
const blocks = [
|
|
16
|
+
{
|
|
17
|
+
type: 'section',
|
|
18
|
+
text: { type: 'mrkdwn', text: convertedMessage },
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
type: 'context',
|
|
22
|
+
elements: [
|
|
23
|
+
{ type: 'mrkdwn', text: ':robot_face: Sent by AI assistant' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
const baseArgs = {
|
|
28
|
+
channel: channelId,
|
|
29
|
+
unfurl_links: false,
|
|
30
|
+
unfurl_media: false,
|
|
31
|
+
...(params.thread_ts ? { thread_ts: params.thread_ts } : {}),
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
// Try with Block Kit first
|
|
35
|
+
const result = await client.chat.postMessage({
|
|
36
|
+
...baseArgs,
|
|
37
|
+
text: convertedMessage,
|
|
38
|
+
blocks,
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
status: 'success',
|
|
42
|
+
message: 'Message sent successfully',
|
|
43
|
+
channel_name: params.channel_name,
|
|
44
|
+
channel_id: channelId,
|
|
45
|
+
message_ts: result.ts,
|
|
46
|
+
sent_message: convertedMessage,
|
|
47
|
+
thread_ts: params.thread_ts,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (blockKitError) {
|
|
51
|
+
// Fallback to plain text without blocks
|
|
52
|
+
try {
|
|
53
|
+
const result = await client.chat.postMessage({
|
|
54
|
+
...baseArgs,
|
|
55
|
+
text: `:robot_face: ${convertedMessage}`,
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
status: 'success',
|
|
59
|
+
message: 'Message sent successfully (plain text fallback)',
|
|
60
|
+
channel_name: params.channel_name,
|
|
61
|
+
channel_id: channelId,
|
|
62
|
+
message_ts: result.ts,
|
|
63
|
+
sent_message: convertedMessage,
|
|
64
|
+
thread_ts: params.thread_ts,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Both failed — report the original Block Kit error with specifics
|
|
69
|
+
const err = blockKitError instanceof Error ? blockKitError : new Error(String(blockKitError));
|
|
70
|
+
throw mapSlackError(err, params.channel_name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function mapSlackError(error, channelName) {
|
|
75
|
+
const msg = error.message;
|
|
76
|
+
if (msg.includes('channel_not_found')) {
|
|
77
|
+
return new SlackMcpError(ErrorCode.CHANNEL_NOT_FOUND, `Channel "#${channelName}" not found. It may not exist or you may not have access.`);
|
|
78
|
+
}
|
|
79
|
+
if (msg.includes('not_in_channel')) {
|
|
80
|
+
return new SlackMcpError(ErrorCode.SEND_FAILED, `You are not a member of "#${channelName}". Join the channel first.`);
|
|
81
|
+
}
|
|
82
|
+
if (msg.includes('restricted_action')) {
|
|
83
|
+
return new SlackMcpError(ErrorCode.SEND_FAILED, `Restricted action: you don't have permission to post in "#${channelName}".`);
|
|
84
|
+
}
|
|
85
|
+
if (msg.includes('thread_not_found')) {
|
|
86
|
+
return new SlackMcpError(ErrorCode.SEND_FAILED, 'Thread not found. The thread_ts may be invalid or the parent message was deleted.');
|
|
87
|
+
}
|
|
88
|
+
return new SlackMcpError(ErrorCode.SEND_FAILED, `Slack API error: ${msg}`);
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=send.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"send.js","sourceRoot":"","sources":["../../src/slack/send.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SendMessageParams {
|
|
2
|
+
channel_name: string;
|
|
3
|
+
message: string;
|
|
4
|
+
thread_ts?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SendMessageResult {
|
|
7
|
+
status: 'success' | 'error';
|
|
8
|
+
message: string;
|
|
9
|
+
channel_name?: string;
|
|
10
|
+
channel_id?: string;
|
|
11
|
+
message_ts?: string;
|
|
12
|
+
sent_message?: string;
|
|
13
|
+
thread_ts?: string;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const ErrorCode: {
|
|
2
|
+
readonly CHANNEL_NOT_FOUND: "CHANNEL_NOT_FOUND";
|
|
3
|
+
readonly SEND_FAILED: "SEND_FAILED";
|
|
4
|
+
readonly SYNC_FAILED: "SYNC_FAILED";
|
|
5
|
+
readonly CONFIG_MISSING: "CONFIG_MISSING";
|
|
6
|
+
};
|
|
7
|
+
export type ErrorCodeType = typeof ErrorCode[keyof typeof ErrorCode];
|
|
8
|
+
export declare class SlackMcpError extends Error {
|
|
9
|
+
readonly code: ErrorCodeType;
|
|
10
|
+
readonly details?: Record<string, unknown>;
|
|
11
|
+
constructor(code: ErrorCodeType, message: string, details?: Record<string, unknown>);
|
|
12
|
+
toJSON(): Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/utils/errors.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,SAAS;;;;;CAKZ,CAAC;AAEX,MAAM,MAAM,aAAa,GAAG,OAAO,SAAS,CAAC,MAAM,OAAO,SAAS,CAAC,CAAC;AAErE,qBAAa,aAAc,SAAQ,KAAK;IACtC,SAAgB,IAAI,EAAE,aAAa,CAAC;IACpC,SAAgB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;gBAEtC,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAWnF,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAQlC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const ErrorCode = {
|
|
2
|
+
CHANNEL_NOT_FOUND: 'CHANNEL_NOT_FOUND',
|
|
3
|
+
SEND_FAILED: 'SEND_FAILED',
|
|
4
|
+
SYNC_FAILED: 'SYNC_FAILED',
|
|
5
|
+
CONFIG_MISSING: 'CONFIG_MISSING',
|
|
6
|
+
};
|
|
7
|
+
export class SlackMcpError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
details;
|
|
10
|
+
constructor(code, message, details) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'SlackMcpError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.details = details;
|
|
15
|
+
if (Error.captureStackTrace) {
|
|
16
|
+
Error.captureStackTrace(this, SlackMcpError);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
toJSON() {
|
|
20
|
+
return {
|
|
21
|
+
name: this.name,
|
|
22
|
+
code: this.code,
|
|
23
|
+
message: this.message,
|
|
24
|
+
details: this.details,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/utils/errors.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,iBAAiB,EAAE,mBAAmB;IACtC,WAAW,EAAE,aAAa;IAC1B,WAAW,EAAE,aAAa;IAC1B,cAAc,EAAE,gBAAgB;CACxB,CAAC;AAIX,MAAM,OAAO,aAAc,SAAQ,KAAK;IACtB,IAAI,CAAgB;IACpB,OAAO,CAA2B;IAElD,YAAY,IAAmB,EAAE,OAAe,EAAE,OAAiC;QACjF,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QAEvB,IAAI,KAAK,CAAC,iBAAiB,EAAE,CAAC;YAC5B,KAAK,CAAC,iBAAiB,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;IAED,MAAM;QACJ,OAAO;YACL,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,OAAO,EAAE,IAAI,CAAC,OAAO;SACtB,CAAC;IACJ,CAAC;CACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pilat/mcp-notify",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for sending Slack messages as user (xoxc + cookie auth)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-notify": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"prepare": "npm run build",
|
|
13
|
+
"prepublishOnly": "npm run lint",
|
|
14
|
+
"test": "vitest --run --passWithNoTests",
|
|
15
|
+
"lint": "eslint src"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mcp",
|
|
19
|
+
"slack",
|
|
20
|
+
"claude",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"model-context-protocol"
|
|
23
|
+
],
|
|
24
|
+
"author": "Vladimir Urushev",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/pilat/mcp-notify"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"homepage": "https://github.com/pilat/mcp-notify",
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22.0.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
42
|
+
"@slack/web-api": "^7.0.0",
|
|
43
|
+
"better-sqlite3": "^12.6.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@eslint/js": "^9.39.2",
|
|
47
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"eslint": "^9.39.2",
|
|
50
|
+
"typescript": "^5.3.0",
|
|
51
|
+
"typescript-eslint": "^8.51.0",
|
|
52
|
+
"vitest": "^4.0.17"
|
|
53
|
+
}
|
|
54
|
+
}
|