@zibby/skills 0.1.20 → 0.1.23

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.
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Zibby Lark / Feishu MCP Server — standalone stdio MCP binary.
4
+ *
5
+ * Mirrors @zibby/mcp-browser + bin/mcp-sentry.mjs: self-contained
6
+ * MCP server that exposes Lark messaging tools to any MCP client
7
+ * (Claude Code, Cursor, Codex, Gemini). Skill's `resolve()` spawns
8
+ * this binary; everything else runs inside the spawned process.
9
+ *
10
+ * Auth: reads PROJECT_API_TOKEN + PROGRESS_API_URL + EXECUTION_ID
11
+ * + PROJECT_ID + STAGE from the inherited env. The backend's
12
+ * resolveIntegrationToken('lark') endpoint returns appId + appSecret;
13
+ * we exchange those for a tenant_access_token cached locally (~2h TTL).
14
+ */
15
+
16
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
+ import { z } from 'zod';
19
+ import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
20
+
21
+ // Lark's tenant_access_token TTL is ~2h. Cache slightly under that —
22
+ // keeps the MCP server alive across multiple tool calls without
23
+ // re-fetching from Lark every time.
24
+ const TOKEN_TTL_MS = 100 * 60 * 1000;
25
+ let tokenCache = null;
26
+
27
+ async function getTenantAccessToken() {
28
+ const { appId, appSecret, host } = await resolveIntegrationToken('lark');
29
+ if (tokenCache && tokenCache.appId === appId && tokenCache.expiresAt > Date.now()) {
30
+ return { token: tokenCache.token, host };
31
+ }
32
+ const res = await fetch(`${host}/open-apis/auth/v3/tenant_access_token/internal`, {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
36
+ });
37
+ const data = await res.json();
38
+ if (data.code !== 0) {
39
+ throw new Error(`Lark tenant_access_token failed: ${data.msg || data.code}`);
40
+ }
41
+ tokenCache = { token: data.tenant_access_token, expiresAt: Date.now() + TOKEN_TTL_MS, appId };
42
+ return { token: data.tenant_access_token, host };
43
+ }
44
+
45
+ async function larkApi(method, path, params = {}) {
46
+ const { token, host } = await getTenantAccessToken();
47
+ const init = {
48
+ method,
49
+ headers: {
50
+ Authorization: `Bearer ${token}`,
51
+ 'Content-Type': 'application/json; charset=utf-8',
52
+ },
53
+ };
54
+ if (method !== 'GET') init.body = JSON.stringify(params);
55
+ const res = await fetch(`${host}${path}`, init);
56
+ const data = await res.json();
57
+ if (data.code !== 0) {
58
+ throw new Error(`Lark API ${path} error: ${data.msg || data.code}`);
59
+ }
60
+ return data.data || {};
61
+ }
62
+
63
+ function textContent(text) {
64
+ return JSON.stringify({ text });
65
+ }
66
+
67
+ // receive_id_type required by Lark; inferred from the id prefix.
68
+ // oc_* → chat_id, ou_* → open_id, on_* → union_id, cli_* → app_id,
69
+ // email-looking → email, else chat_id (most common bot target).
70
+ function inferReceiveIdType(id) {
71
+ if (!id || typeof id !== 'string') return 'chat_id';
72
+ if (id.startsWith('oc_')) return 'chat_id';
73
+ if (id.startsWith('ou_')) return 'open_id';
74
+ if (id.startsWith('on_')) return 'union_id';
75
+ if (id.startsWith('cli_')) return 'app_id';
76
+ if (id.includes('@')) return 'email';
77
+ return 'chat_id';
78
+ }
79
+
80
+ const server = new McpServer(
81
+ { name: 'zibby-lark', version: '1.0.0' },
82
+ { capabilities: { tools: {} } },
83
+ );
84
+
85
+ // ── lark_send_message ──────────────────────────────────────────────
86
+ server.registerTool(
87
+ 'lark_send_message',
88
+ {
89
+ title: 'Send Lark Message',
90
+ description: 'Send a text message to a Lark chat, user, or DM. receive_id can be a chat_id (oc_*), open_id (ou_*), union_id (on_*), or email.',
91
+ inputSchema: z.object({
92
+ receive_id: z.string().describe('Target id: chat_id (oc_*), open_id (ou_*), union_id (on_*), or email'),
93
+ text: z.string().describe('Message text'),
94
+ }),
95
+ },
96
+ async (args = {}) => {
97
+ try {
98
+ if (!args.receive_id || !args.text) {
99
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'receive_id and text are required' }) }], isError: true };
100
+ }
101
+ const receiveIdType = inferReceiveIdType(args.receive_id);
102
+ const data = await larkApi(
103
+ 'POST',
104
+ `/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`,
105
+ { receive_id: args.receive_id, msg_type: 'text', content: textContent(args.text) },
106
+ );
107
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message_id: data.message_id }) }] };
108
+ } catch (err) {
109
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
110
+ }
111
+ },
112
+ );
113
+
114
+ // ── lark_reply ─────────────────────────────────────────────────────
115
+ server.registerTool(
116
+ 'lark_reply',
117
+ {
118
+ title: 'Reply to Lark Message',
119
+ description: 'Reply to an existing Lark message (creates a thread). Use the message_id from the inbound event.',
120
+ inputSchema: z.object({
121
+ message_id: z.string().describe('Lark message id (om_*) to reply to'),
122
+ text: z.string().describe('Reply text'),
123
+ }),
124
+ },
125
+ async (args = {}) => {
126
+ try {
127
+ if (!args.message_id || !args.text) {
128
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'message_id and text are required' }) }], isError: true };
129
+ }
130
+ const data = await larkApi(
131
+ 'POST',
132
+ `/open-apis/im/v1/messages/${encodeURIComponent(args.message_id)}/reply`,
133
+ { msg_type: 'text', content: textContent(args.text) },
134
+ );
135
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message_id: data.message_id }) }] };
136
+ } catch (err) {
137
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
138
+ }
139
+ },
140
+ );
141
+
142
+ // ── lark_list_chats ────────────────────────────────────────────────
143
+ server.registerTool(
144
+ 'lark_list_chats',
145
+ {
146
+ title: 'List Lark Chats',
147
+ description: 'List chats (groups + DMs) the bot is a member of.',
148
+ inputSchema: z.object({
149
+ page_size: z.number().optional().describe('Max results (default 50)'),
150
+ }),
151
+ },
152
+ async (args = {}) => {
153
+ try {
154
+ const pageSize = args.page_size || 50;
155
+ const data = await larkApi('GET', `/open-apis/im/v1/chats?page_size=${pageSize}`);
156
+ const chats = (data.items || []).map((c) => ({
157
+ chat_id: c.chat_id,
158
+ name: c.name,
159
+ description: c.description,
160
+ owner_id: c.owner_id,
161
+ chat_mode: c.chat_mode,
162
+ }));
163
+ return { content: [{ type: 'text', text: JSON.stringify({ chats }) }] };
164
+ } catch (err) {
165
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
166
+ }
167
+ },
168
+ );
169
+
170
+ // ── lark_get_chat_history ──────────────────────────────────────────
171
+ server.registerTool(
172
+ 'lark_get_chat_history',
173
+ {
174
+ title: 'Get Lark Chat History',
175
+ description: 'Fetch recent messages in a chat.',
176
+ inputSchema: z.object({
177
+ chat_id: z.string().describe('Chat id (oc_*)'),
178
+ page_size: z.number().optional().describe('Max messages (default 20)'),
179
+ }),
180
+ },
181
+ async (args = {}) => {
182
+ try {
183
+ if (!args.chat_id) {
184
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'chat_id is required' }) }], isError: true };
185
+ }
186
+ const pageSize = args.page_size || 20;
187
+ const data = await larkApi(
188
+ 'GET',
189
+ `/open-apis/im/v1/messages?container_id_type=chat&container_id=${encodeURIComponent(args.chat_id)}&page_size=${pageSize}&sort_type=ByCreateTimeDesc`,
190
+ );
191
+ const messages = (data.items || []).map((m) => ({
192
+ message_id: m.message_id,
193
+ sender_id: m.sender?.id,
194
+ sender_type: m.sender?.sender_type,
195
+ msg_type: m.msg_type,
196
+ content: m.body?.content,
197
+ create_time: m.create_time,
198
+ }));
199
+ return { content: [{ type: 'text', text: JSON.stringify({ messages }) }] };
200
+ } catch (err) {
201
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
202
+ }
203
+ },
204
+ );
205
+
206
+ const transport = new StdioServerTransport();
207
+ await server.connect(transport);
208
+
209
+ console.error('[mcp-lark] connected (4 tools: lark_send_message, lark_reply, lark_list_chats, lark_get_chat_history)');
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Zibby Sentry MCP Server — standalone stdio MCP binary.
4
+ *
5
+ * Mirrors the @zibby/mcp-browser pattern: this is a self-contained
6
+ * MCP server that exposes Sentry tools (list_projects, list_issues,
7
+ * get_issue) to any MCP client (Claude Code, Cursor, etc.). Skill's
8
+ * `resolve()` just spawns this binary; everything else runs inside
9
+ * the spawned process.
10
+ *
11
+ * Why standalone vs the function-bridge approach: the bridge required
12
+ * the parent's in-memory handler registry to be visible to the child,
13
+ * but each Node process has its own module instance map. Even when
14
+ * the bridge re-imported the skill module, the `registerHandlers`
15
+ * side-effect didn't always land in the same registry the bridge
16
+ * subsequently read from (cross-package vs relative ESM URL resolution
17
+ * subtleties). A self-contained binary side-steps the whole issue.
18
+ *
19
+ * Auth: reads PROJECT_API_TOKEN + PROGRESS_API_URL + EXECUTION_ID
20
+ * + PROJECT_ID + STAGE from the inherited env (set by workflow-executor.js
21
+ * on every Fargate task). resolveIntegrationToken('sentry') hits the
22
+ * project's backend → returns the Sentry OAuth access token + org slug.
23
+ */
24
+
25
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
26
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
27
+ import { z } from 'zod';
28
+ import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
29
+
30
+ async function sentryFetch(path, opts = {}) {
31
+ const { token, organizationSlug } = await resolveIntegrationToken('sentry');
32
+ const url = `https://sentry.io/api/0/organizations/${organizationSlug}${path}`;
33
+ const res = await fetch(url, {
34
+ method: opts.method || 'GET',
35
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
36
+ });
37
+ if (!res.ok) {
38
+ const err = await res.text().catch(() => '');
39
+ throw new Error(`Sentry API ${res.status}: ${err.slice(0, 300)}`);
40
+ }
41
+ return res.json();
42
+ }
43
+
44
+ const server = new McpServer(
45
+ { name: 'zibby-sentry', version: '1.0.0' },
46
+ { capabilities: { tools: {} } },
47
+ );
48
+
49
+ // ── sentry_list_projects ────────────────────────────────────────────
50
+ server.registerTool(
51
+ 'sentry_list_projects',
52
+ {
53
+ title: 'List Sentry Projects',
54
+ description: 'List Sentry projects in the connected organization.',
55
+ inputSchema: z.object({}),
56
+ },
57
+ async () => {
58
+ try {
59
+ const data = await sentryFetch('/projects/?per_page=50');
60
+ const text = JSON.stringify({
61
+ projects: data.map((p) => ({ slug: p.slug, name: p.name, platform: p.platform })),
62
+ });
63
+ return { content: [{ type: 'text', text }] };
64
+ } catch (err) {
65
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
66
+ }
67
+ },
68
+ );
69
+
70
+ // ── sentry_list_issues ──────────────────────────────────────────────
71
+ server.registerTool(
72
+ 'sentry_list_issues',
73
+ {
74
+ title: 'List Sentry Issues',
75
+ description: 'List Sentry issues (errors). Supports Sentry search syntax in the query field (e.g. "is:unresolved level:error age:-1h").',
76
+ inputSchema: z.object({
77
+ project: z.string().optional().describe('Project slug (optional)'),
78
+ query: z.string().optional().describe('Sentry search query (default: is:unresolved)'),
79
+ sort: z.string().optional().describe('Sort order: date, new, priority, freq, user (default: date)'),
80
+ limit: z.number().optional().describe('Max issues to return (default 25)'),
81
+ }),
82
+ },
83
+ async (args = {}) => {
84
+ try {
85
+ const project = args.project || '';
86
+ const query = args.query || 'is:unresolved';
87
+ const sort = args.sort || 'date';
88
+ let path = `/issues/?query=${encodeURIComponent(query)}&sort=${sort}&per_page=${args.limit || 25}`;
89
+ if (project) path += `&project=${encodeURIComponent(project)}`;
90
+ const data = await sentryFetch(path);
91
+ const text = JSON.stringify({
92
+ issues: data.map((i) => ({
93
+ id: i.id, title: i.title, culprit: i.culprit,
94
+ count: i.count, firstSeen: i.firstSeen, lastSeen: i.lastSeen,
95
+ level: i.level, status: i.status,
96
+ })),
97
+ });
98
+ return { content: [{ type: 'text', text }] };
99
+ } catch (err) {
100
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
101
+ }
102
+ },
103
+ );
104
+
105
+ // ── sentry_get_issue ────────────────────────────────────────────────
106
+ server.registerTool(
107
+ 'sentry_get_issue',
108
+ {
109
+ title: 'Get Sentry Issue Details',
110
+ description: 'Get details of a specific Sentry issue (culprit, metadata, userCount, etc).',
111
+ inputSchema: z.object({
112
+ issueId: z.string().describe('Sentry issue ID'),
113
+ }),
114
+ },
115
+ async (args = {}) => {
116
+ try {
117
+ const { issueId } = args;
118
+ if (!issueId) {
119
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'issueId is required' }) }], isError: true };
120
+ }
121
+ // Issue details hit /issues/<id>/ (NOT under /organizations/), so
122
+ // we resolve just the token and build the URL directly.
123
+ const { token } = await resolveIntegrationToken('sentry');
124
+ const res = await fetch(`https://sentry.io/api/0/issues/${issueId}/`, {
125
+ headers: { Authorization: `Bearer ${token}` },
126
+ });
127
+ if (!res.ok) {
128
+ throw new Error(`Sentry API ${res.status}`);
129
+ }
130
+ const data = await res.json();
131
+ const text = JSON.stringify({
132
+ id: data.id, title: data.title, culprit: data.culprit,
133
+ metadata: data.metadata, count: data.count, userCount: data.userCount,
134
+ firstSeen: data.firstSeen, lastSeen: data.lastSeen,
135
+ level: data.level, status: data.status,
136
+ project: { slug: data.project?.slug, name: data.project?.name },
137
+ });
138
+ return { content: [{ type: 'text', text }] };
139
+ } catch (err) {
140
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
141
+ }
142
+ },
143
+ );
144
+
145
+ const transport = new StdioServerTransport();
146
+ await server.connect(transport);
147
+
148
+ // Tiny diagnostic line on stderr so operators can confirm the MCP
149
+ // server actually started. stdout is reserved for MCP JSON-RPC; only
150
+ // stderr is safe for human-readable logs.
151
+ console.error('[mcp-sentry] connected (3 tools: sentry_list_projects, sentry_list_issues, sentry_get_issue)');