@dayby/mcp-server 0.1.0 → 0.1.1
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 +7 -28
- package/dist/index.js +7 -1
- package/package.json +1 -5
- package/sanitizer.example.json +6 -0
- package/src/dayby-client.ts +107 -0
- package/src/index.ts +338 -0
- package/src/sanitizer.ts +132 -0
- package/tsconfig.json +14 -0
package/README.md
CHANGED
|
@@ -56,41 +56,20 @@ Create `~/.dayby/sanitizer.json`:
|
|
|
56
56
|
}
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
### 3.
|
|
59
|
+
### 3. Add to Claude Desktop / Cursor
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
npm install -g @dayby/mcp-server
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
### 4. Add to Claude Code / Claude Desktop / Cursor
|
|
66
|
-
|
|
67
|
-
**Claude Code (simplest):**
|
|
68
|
-
```bash
|
|
69
|
-
claude mcp add dayby -- dayby-mcp
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
**Claude Desktop** (`claude_desktop_config.json`):
|
|
73
|
-
```json
|
|
74
|
-
{
|
|
75
|
-
"mcpServers": {
|
|
76
|
-
"dayby": {
|
|
77
|
-
"command": "dayby-mcp",
|
|
78
|
-
"env": {
|
|
79
|
-
"DAYBY_API_KEY": "your-api-key-here"
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
```
|
|
61
|
+
In your MCP config (`claude_desktop_config.json` or Cursor settings):
|
|
85
62
|
|
|
86
|
-
**Cursor** (`.cursor/mcp.json`):
|
|
87
63
|
```json
|
|
88
64
|
{
|
|
89
65
|
"mcpServers": {
|
|
90
66
|
"dayby": {
|
|
91
|
-
"command": "
|
|
67
|
+
"command": "node",
|
|
68
|
+
"args": ["/path/to/mcp-server/dist/index.js"],
|
|
92
69
|
"env": {
|
|
93
|
-
"DAYBY_API_KEY": "your-api-key-here"
|
|
70
|
+
"DAYBY_API_KEY": "your-api-key-here",
|
|
71
|
+
"DAYBY_API_URL": "https://dayby.dev",
|
|
72
|
+
"DAYBY_BLOCKED_TERMS": "CompanyName,SecretProject"
|
|
94
73
|
}
|
|
95
74
|
}
|
|
96
75
|
}
|
package/dist/index.js
CHANGED
|
@@ -116,6 +116,7 @@ async function main() {
|
|
|
116
116
|
originalContent: content,
|
|
117
117
|
sanitizedTitle: titleResult.clean,
|
|
118
118
|
sanitizedContent: contentResult.clean,
|
|
119
|
+
visibility,
|
|
119
120
|
strippedItems: allStripped,
|
|
120
121
|
createdAt: new Date(),
|
|
121
122
|
};
|
|
@@ -149,7 +150,8 @@ async function main() {
|
|
|
149
150
|
draft_id: zod_1.z.string().describe('The draft ID from draft_post'),
|
|
150
151
|
title: zod_1.z.string().optional().describe('Updated title (will be re-sanitized)'),
|
|
151
152
|
content: zod_1.z.string().optional().describe('Updated content (will be re-sanitized)'),
|
|
152
|
-
|
|
153
|
+
visibility: zod_1.z.enum(['published', 'draft']).optional().describe('Updated visibility'),
|
|
154
|
+
}, async ({ draft_id, title, content, visibility }) => {
|
|
153
155
|
const draft = drafts.get(draft_id);
|
|
154
156
|
if (!draft) {
|
|
155
157
|
return {
|
|
@@ -167,6 +169,9 @@ async function main() {
|
|
|
167
169
|
draft.originalContent = content;
|
|
168
170
|
draft.strippedItems.push(...result.stripped);
|
|
169
171
|
}
|
|
172
|
+
if (visibility) {
|
|
173
|
+
draft.visibility = visibility;
|
|
174
|
+
}
|
|
170
175
|
let response = `✏️ **Draft Updated** (ID: ${draft_id})\n\n`;
|
|
171
176
|
response += `**Title:** ${draft.sanitizedTitle}\n\n`;
|
|
172
177
|
response += `**Content:**\n${draft.sanitizedContent}\n\n`;
|
|
@@ -200,6 +205,7 @@ async function main() {
|
|
|
200
205
|
const result = await client.createPost({
|
|
201
206
|
title: draft.sanitizedTitle,
|
|
202
207
|
content: draft.sanitizedContent,
|
|
208
|
+
visibility: draft.visibility,
|
|
203
209
|
});
|
|
204
210
|
let response = `✅ **Published to DayBy!**\n\n`;
|
|
205
211
|
response += `**Title:** ${result.post.title}\n`;
|
package/package.json
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dayby/mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "DayBy MCP Server — Post your dev progress from Claude, Cursor, or any MCP client. Local sanitization built in.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"dayby-mcp": "dist/index.js"
|
|
8
8
|
},
|
|
9
|
-
"files": [
|
|
10
|
-
"dist/",
|
|
11
|
-
"README.md"
|
|
12
|
-
],
|
|
13
9
|
"scripts": {
|
|
14
10
|
"build": "tsc",
|
|
15
11
|
"dev": "tsc --watch",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DayBy API v2 client — thin wrapper around the REST API.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface DayByConfig {
|
|
6
|
+
apiUrl: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DayByPost {
|
|
11
|
+
id: number;
|
|
12
|
+
title: string;
|
|
13
|
+
slug: string;
|
|
14
|
+
content: string;
|
|
15
|
+
visibility: string;
|
|
16
|
+
has_article: boolean;
|
|
17
|
+
url: string;
|
|
18
|
+
created_at: string;
|
|
19
|
+
updated_at: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PostsListResponse {
|
|
23
|
+
posts: DayByPost[];
|
|
24
|
+
meta: {
|
|
25
|
+
total: number;
|
|
26
|
+
page: number;
|
|
27
|
+
per_page: number;
|
|
28
|
+
total_pages: number;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class DayByClient {
|
|
33
|
+
private apiUrl: string;
|
|
34
|
+
private apiKey: string;
|
|
35
|
+
|
|
36
|
+
constructor(config: DayByConfig) {
|
|
37
|
+
this.apiUrl = config.apiUrl.replace(/\/$/, '');
|
|
38
|
+
this.apiKey = config.apiKey;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async request<T>(
|
|
42
|
+
method: string,
|
|
43
|
+
path: string,
|
|
44
|
+
body?: Record<string, unknown>
|
|
45
|
+
): Promise<T> {
|
|
46
|
+
const url = `${this.apiUrl}${path}`;
|
|
47
|
+
const headers: Record<string, string> = {
|
|
48
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
'Accept': 'application/json',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const res = await fetch(url, {
|
|
54
|
+
method,
|
|
55
|
+
headers,
|
|
56
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const error = await res.json().catch(() => ({ error: res.statusText }));
|
|
61
|
+
throw new Error(`DayBy API error (${res.status}): ${JSON.stringify(error)}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return res.json() as Promise<T>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async listPosts(page = 1, perPage = 10): Promise<PostsListResponse> {
|
|
68
|
+
return this.request<PostsListResponse>(
|
|
69
|
+
'GET',
|
|
70
|
+
`/api/v2/posts?page=${page}&per_page=${perPage}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getPost(slug: string): Promise<{ post: DayByPost }> {
|
|
75
|
+
return this.request<{ post: DayByPost }>('GET', `/api/v2/posts/${slug}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async createPost(params: {
|
|
79
|
+
title: string;
|
|
80
|
+
content: string;
|
|
81
|
+
visibility?: string;
|
|
82
|
+
}): Promise<{ post: DayByPost }> {
|
|
83
|
+
return this.request<{ post: DayByPost }>('POST', '/api/v2/posts', {
|
|
84
|
+
post: params,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async updatePost(
|
|
89
|
+
slug: string,
|
|
90
|
+
params: { title?: string; content?: string; visibility?: string }
|
|
91
|
+
): Promise<{ post: DayByPost }> {
|
|
92
|
+
return this.request<{ post: DayByPost }>('PUT', `/api/v2/posts/${slug}`, {
|
|
93
|
+
post: params,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async deletePost(slug: string): Promise<{ message: string }> {
|
|
98
|
+
return this.request<{ message: string }>('DELETE', `/api/v2/posts/${slug}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async generateArticle(slug: string): Promise<{ post: DayByPost; message: string }> {
|
|
102
|
+
return this.request<{ post: DayByPost; message: string }>(
|
|
103
|
+
'POST',
|
|
104
|
+
`/api/v2/posts/${slug}/generate_article`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DayBy MCP Server
|
|
5
|
+
*
|
|
6
|
+
* Post your dev progress from Claude, Cursor, or any MCP client.
|
|
7
|
+
* All content is sanitized locally before it ever touches the network.
|
|
8
|
+
*
|
|
9
|
+
* Flow: draft_post → review → publish_post (nothing leaves without approval)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { Sanitizer, type SanitizerConfig } from './sanitizer.js';
|
|
16
|
+
import { DayByClient } from './dayby-client.js';
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
|
|
20
|
+
// --- Configuration ---
|
|
21
|
+
|
|
22
|
+
interface Config {
|
|
23
|
+
apiUrl: string;
|
|
24
|
+
apiKey: string;
|
|
25
|
+
sanitizer: Partial<SanitizerConfig>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadConfig(): Config {
|
|
29
|
+
// 1. Check env vars
|
|
30
|
+
const apiUrl = process.env.DAYBY_API_URL || 'https://dayby.dev';
|
|
31
|
+
const apiKey = process.env.DAYBY_API_KEY || '';
|
|
32
|
+
|
|
33
|
+
// 2. Load sanitizer config from file if it exists
|
|
34
|
+
let sanitizerConfig: Partial<SanitizerConfig> = {};
|
|
35
|
+
const configPaths = [
|
|
36
|
+
path.join(process.env.HOME || '~', '.dayby', 'sanitizer.json'),
|
|
37
|
+
path.join(process.cwd(), '.dayby-sanitizer.json'),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
for (const configPath of configPaths) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
43
|
+
sanitizerConfig = JSON.parse(raw);
|
|
44
|
+
break;
|
|
45
|
+
} catch {
|
|
46
|
+
// File doesn't exist, that's fine
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Also load from env (comma-separated)
|
|
51
|
+
if (process.env.DAYBY_BLOCKED_TERMS) {
|
|
52
|
+
sanitizerConfig.blockedTerms = [
|
|
53
|
+
...(sanitizerConfig.blockedTerms || []),
|
|
54
|
+
...process.env.DAYBY_BLOCKED_TERMS.split(',').map(s => s.trim()),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
if (process.env.DAYBY_BLOCKED_DOMAINS) {
|
|
58
|
+
sanitizerConfig.blockedDomains = [
|
|
59
|
+
...(sanitizerConfig.blockedDomains || []),
|
|
60
|
+
...process.env.DAYBY_BLOCKED_DOMAINS.split(',').map(s => s.trim()),
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { apiUrl, apiKey, sanitizer: sanitizerConfig };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Draft Store (in-memory, local only) ---
|
|
68
|
+
|
|
69
|
+
interface Draft {
|
|
70
|
+
id: string;
|
|
71
|
+
originalContent: string;
|
|
72
|
+
sanitizedTitle: string;
|
|
73
|
+
sanitizedContent: string;
|
|
74
|
+
visibility: 'published' | 'draft';
|
|
75
|
+
strippedItems: string[];
|
|
76
|
+
createdAt: Date;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const drafts = new Map<string, Draft>();
|
|
80
|
+
|
|
81
|
+
function generateDraftId(): string {
|
|
82
|
+
return `draft_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- Main ---
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
const sanitizer = new Sanitizer(config.sanitizer);
|
|
90
|
+
const client = new DayByClient({ apiUrl: config.apiUrl, apiKey: config.apiKey });
|
|
91
|
+
|
|
92
|
+
const server = new McpServer({
|
|
93
|
+
name: 'dayby',
|
|
94
|
+
version: '0.1.0',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ========================================
|
|
98
|
+
// Tool: draft_post
|
|
99
|
+
// Sanitizes content locally, returns preview. NOTHING leaves the machine.
|
|
100
|
+
// ========================================
|
|
101
|
+
server.tool(
|
|
102
|
+
'draft_post',
|
|
103
|
+
'Create a sanitized draft of a dev progress post. Content is cleaned locally — nothing is sent to the network. Returns a preview for the user to review before publishing.',
|
|
104
|
+
{
|
|
105
|
+
title: z.string().describe('Post title — focus on the technology/skill learned'),
|
|
106
|
+
content: z.string().describe('Post content — describe what you learned, built, or solved. The sanitizer will strip any sensitive data automatically.'),
|
|
107
|
+
visibility: z.enum(['published', 'draft']).default('published').describe('Post visibility on DayBy'),
|
|
108
|
+
},
|
|
109
|
+
async ({ title, content, visibility }) => {
|
|
110
|
+
// Sanitize both title and content locally
|
|
111
|
+
const titleResult = sanitizer.sanitize(title);
|
|
112
|
+
const contentResult = sanitizer.sanitize(content);
|
|
113
|
+
|
|
114
|
+
const allStripped = [...titleResult.stripped, ...contentResult.stripped];
|
|
115
|
+
const draftId = generateDraftId();
|
|
116
|
+
|
|
117
|
+
const draft: Draft = {
|
|
118
|
+
id: draftId,
|
|
119
|
+
originalContent: content,
|
|
120
|
+
sanitizedTitle: titleResult.clean,
|
|
121
|
+
sanitizedContent: contentResult.clean,
|
|
122
|
+
visibility,
|
|
123
|
+
strippedItems: allStripped,
|
|
124
|
+
createdAt: new Date(),
|
|
125
|
+
};
|
|
126
|
+
drafts.set(draftId, draft);
|
|
127
|
+
|
|
128
|
+
// Build response
|
|
129
|
+
let response = `📝 **Draft Preview** (ID: ${draftId})\n\n`;
|
|
130
|
+
response += `**Title:** ${draft.sanitizedTitle}\n\n`;
|
|
131
|
+
response += `**Content:**\n${draft.sanitizedContent}\n\n`;
|
|
132
|
+
response += `**Visibility:** ${visibility}\n`;
|
|
133
|
+
|
|
134
|
+
if (allStripped.length > 0) {
|
|
135
|
+
response += `\n⚠️ **Sanitizer removed ${allStripped.length} sensitive item(s):**\n`;
|
|
136
|
+
for (const item of allStripped.slice(0, 10)) {
|
|
137
|
+
response += ` • ${item}\n`;
|
|
138
|
+
}
|
|
139
|
+
if (allStripped.length > 10) {
|
|
140
|
+
response += ` • ...and ${allStripped.length - 10} more\n`;
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
response += `\n✅ No sensitive data detected.\n`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
response += `\n👉 Review the above. If it looks good, use **publish_post** with draft ID: \`${draftId}\``;
|
|
147
|
+
response += `\n💡 You can also use **edit_draft** to modify before publishing.`;
|
|
148
|
+
|
|
149
|
+
return { content: [{ type: 'text' as const, text: response }] };
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ========================================
|
|
154
|
+
// Tool: edit_draft
|
|
155
|
+
// Modify a draft before publishing
|
|
156
|
+
// ========================================
|
|
157
|
+
server.tool(
|
|
158
|
+
'edit_draft',
|
|
159
|
+
'Edit a draft post before publishing. Provide updated title and/or content — they will be re-sanitized locally.',
|
|
160
|
+
{
|
|
161
|
+
draft_id: z.string().describe('The draft ID from draft_post'),
|
|
162
|
+
title: z.string().optional().describe('Updated title (will be re-sanitized)'),
|
|
163
|
+
content: z.string().optional().describe('Updated content (will be re-sanitized)'),
|
|
164
|
+
visibility: z.enum(['published', 'draft']).optional().describe('Updated visibility'),
|
|
165
|
+
},
|
|
166
|
+
async ({ draft_id, title, content, visibility }) => {
|
|
167
|
+
const draft = drafts.get(draft_id);
|
|
168
|
+
if (!draft) {
|
|
169
|
+
return {
|
|
170
|
+
content: [{ type: 'text' as const, text: `❌ Draft not found: ${draft_id}. Use draft_post to create a new one.` }],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (title) {
|
|
175
|
+
const result = sanitizer.sanitize(title);
|
|
176
|
+
draft.sanitizedTitle = result.clean;
|
|
177
|
+
draft.strippedItems.push(...result.stripped);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (content) {
|
|
181
|
+
const result = sanitizer.sanitize(content);
|
|
182
|
+
draft.sanitizedContent = result.clean;
|
|
183
|
+
draft.originalContent = content;
|
|
184
|
+
draft.strippedItems.push(...result.stripped);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (visibility) {
|
|
188
|
+
draft.visibility = visibility;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let response = `✏️ **Draft Updated** (ID: ${draft_id})\n\n`;
|
|
192
|
+
response += `**Title:** ${draft.sanitizedTitle}\n\n`;
|
|
193
|
+
response += `**Content:**\n${draft.sanitizedContent}\n\n`;
|
|
194
|
+
response += `\n👉 Use **publish_post** with draft ID: \`${draft_id}\` when ready.`;
|
|
195
|
+
|
|
196
|
+
return { content: [{ type: 'text' as const, text: response }] };
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// ========================================
|
|
201
|
+
// Tool: publish_post
|
|
202
|
+
// Only NOW does data leave the machine — and only the sanitized version.
|
|
203
|
+
// ========================================
|
|
204
|
+
server.tool(
|
|
205
|
+
'publish_post',
|
|
206
|
+
'Publish a previously drafted post to DayBy. Only the sanitized version is sent — the original content never leaves your machine.',
|
|
207
|
+
{
|
|
208
|
+
draft_id: z.string().describe('The draft ID from draft_post'),
|
|
209
|
+
generate_article: z.boolean().default(false).describe('Also generate an AI-formatted article on DayBy after publishing'),
|
|
210
|
+
},
|
|
211
|
+
async ({ draft_id, generate_article }) => {
|
|
212
|
+
const draft = drafts.get(draft_id);
|
|
213
|
+
if (!draft) {
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: 'text' as const, text: `❌ Draft not found: ${draft_id}. Use draft_post to create a new one.` }],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!config.apiKey) {
|
|
220
|
+
return {
|
|
221
|
+
content: [{
|
|
222
|
+
type: 'text' as const,
|
|
223
|
+
text: '❌ No API key configured. Set DAYBY_API_KEY environment variable or add it to your MCP config.',
|
|
224
|
+
}],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// Only the sanitized content is sent
|
|
230
|
+
const result = await client.createPost({
|
|
231
|
+
title: draft.sanitizedTitle,
|
|
232
|
+
content: draft.sanitizedContent,
|
|
233
|
+
visibility: draft.visibility,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
let response = `✅ **Published to DayBy!**\n\n`;
|
|
237
|
+
response += `**Title:** ${result.post.title}\n`;
|
|
238
|
+
response += `**URL:** ${result.post.url}\n`;
|
|
239
|
+
response += `**Slug:** ${result.post.slug}\n`;
|
|
240
|
+
|
|
241
|
+
if (generate_article && result.post.slug) {
|
|
242
|
+
try {
|
|
243
|
+
await client.generateArticle(result.post.slug);
|
|
244
|
+
response += `\n🤖 AI article generation triggered.`;
|
|
245
|
+
} catch (e) {
|
|
246
|
+
response += `\n⚠️ Article generation failed: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Clean up draft
|
|
251
|
+
drafts.delete(draft_id);
|
|
252
|
+
|
|
253
|
+
return { content: [{ type: 'text' as const, text: response }] };
|
|
254
|
+
} catch (e) {
|
|
255
|
+
return {
|
|
256
|
+
content: [{
|
|
257
|
+
type: 'text' as const,
|
|
258
|
+
text: `❌ Failed to publish: ${e instanceof Error ? e.message : 'Unknown error'}`,
|
|
259
|
+
}],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// ========================================
|
|
266
|
+
// Tool: list_posts
|
|
267
|
+
// ========================================
|
|
268
|
+
server.tool(
|
|
269
|
+
'list_posts',
|
|
270
|
+
'List your recent DayBy posts.',
|
|
271
|
+
{
|
|
272
|
+
page: z.number().default(1).describe('Page number'),
|
|
273
|
+
per_page: z.number().default(10).describe('Posts per page'),
|
|
274
|
+
},
|
|
275
|
+
async ({ page, per_page }) => {
|
|
276
|
+
if (!config.apiKey) {
|
|
277
|
+
return {
|
|
278
|
+
content: [{ type: 'text' as const, text: '❌ No API key configured.' }],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const result = await client.listPosts(page, per_page);
|
|
284
|
+
let response = `📋 **Your DayBy Posts** (page ${result.meta.page}/${result.meta.total_pages}, ${result.meta.total} total)\n\n`;
|
|
285
|
+
|
|
286
|
+
for (const post of result.posts) {
|
|
287
|
+
response += `• **${post.title}** — ${post.url}\n`;
|
|
288
|
+
response += ` ${post.content.slice(0, 100)}${post.content.length > 100 ? '...' : ''}\n`;
|
|
289
|
+
response += ` _${post.created_at.slice(0, 10)} · ${post.visibility}_\n\n`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { content: [{ type: 'text' as const, text: response }] };
|
|
293
|
+
} catch (e) {
|
|
294
|
+
return {
|
|
295
|
+
content: [{
|
|
296
|
+
type: 'text' as const,
|
|
297
|
+
text: `❌ Failed to list posts: ${e instanceof Error ? e.message : 'Unknown error'}`,
|
|
298
|
+
}],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// ========================================
|
|
305
|
+
// Tool: check_content
|
|
306
|
+
// Dry-run sanitization — see what would get stripped without creating a draft.
|
|
307
|
+
// ========================================
|
|
308
|
+
server.tool(
|
|
309
|
+
'check_content',
|
|
310
|
+
'Check if content contains sensitive data without creating a draft. Useful for quick checks before writing a post.',
|
|
311
|
+
{
|
|
312
|
+
text: z.string().describe('Text to check for sensitive content'),
|
|
313
|
+
},
|
|
314
|
+
async ({ text }) => {
|
|
315
|
+
const result = sanitizer.check(text);
|
|
316
|
+
|
|
317
|
+
if (result.safe) {
|
|
318
|
+
return {
|
|
319
|
+
content: [{ type: 'text' as const, text: '✅ No sensitive data detected. Safe to post.' }],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let response = `⚠️ **Found ${result.issues.length} sensitive item(s):**\n\n`;
|
|
324
|
+
for (const issue of result.issues) {
|
|
325
|
+
response += ` • ${issue}\n`;
|
|
326
|
+
}
|
|
327
|
+
response += `\nUse **draft_post** to create a sanitized version.`;
|
|
328
|
+
|
|
329
|
+
return { content: [{ type: 'text' as const, text: response }] };
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// --- Start server ---
|
|
334
|
+
const transport = new StdioServerTransport();
|
|
335
|
+
await server.connect(transport);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
main().catch(console.error);
|
package/src/sanitizer.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local sanitization layer — strips sensitive data BEFORE anything leaves the machine.
|
|
3
|
+
* Runs entirely offline. No network calls.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SanitizerConfig {
|
|
7
|
+
/** Company/org names to strip */
|
|
8
|
+
blockedTerms: string[];
|
|
9
|
+
/** Internal domains (e.g., "internal.corp.com") */
|
|
10
|
+
blockedDomains: string[];
|
|
11
|
+
/** Teammate/people names to strip */
|
|
12
|
+
blockedNames: string[];
|
|
13
|
+
/** Custom regex patterns to strip */
|
|
14
|
+
customPatterns: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_PATTERNS: RegExp[] = [
|
|
18
|
+
// API keys & tokens
|
|
19
|
+
/(?:api[_-]?key|token|secret|password|bearer)\s*[:=]\s*['"]?[A-Za-z0-9_\-/.+]{16,}['"]?/gi,
|
|
20
|
+
// AWS ARNs
|
|
21
|
+
/arn:aws:[a-z0-9\-]+:[a-z0-9\-]*:\d{12}:[a-zA-Z0-9\-_/:.]+/g,
|
|
22
|
+
// AWS access keys
|
|
23
|
+
/(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}/g,
|
|
24
|
+
// Private IPs
|
|
25
|
+
/\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
|
|
26
|
+
// Email addresses
|
|
27
|
+
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
|
28
|
+
// SSH keys
|
|
29
|
+
/ssh-(?:rsa|ed25519|ecdsa)\s+[A-Za-z0-9+/=]{40,}/g,
|
|
30
|
+
// JWT tokens
|
|
31
|
+
/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g,
|
|
32
|
+
// GitHub tokens
|
|
33
|
+
/gh[ps]_[A-Za-z0-9_]{36,}/g,
|
|
34
|
+
// Generic secrets in env-like format
|
|
35
|
+
/[A-Z_]{3,}=["']?[A-Za-z0-9+/=_\-]{20,}["']?/g,
|
|
36
|
+
// Database URLs
|
|
37
|
+
/(?:postgres|mysql|mongodb|redis):\/\/[^\s"']+/gi,
|
|
38
|
+
// File paths with usernames
|
|
39
|
+
/\/(?:home|Users)\/[a-zA-Z0-9._-]+/g,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export class Sanitizer {
|
|
43
|
+
private config: SanitizerConfig;
|
|
44
|
+
private compiledBlockedTerms: RegExp[];
|
|
45
|
+
private compiledCustomPatterns: RegExp[];
|
|
46
|
+
|
|
47
|
+
constructor(config: Partial<SanitizerConfig> = {}) {
|
|
48
|
+
this.config = {
|
|
49
|
+
blockedTerms: config.blockedTerms || [],
|
|
50
|
+
blockedDomains: config.blockedDomains || [],
|
|
51
|
+
blockedNames: config.blockedNames || [],
|
|
52
|
+
customPatterns: config.customPatterns || [],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Compile blocked terms into case-insensitive regexes with word boundaries
|
|
56
|
+
this.compiledBlockedTerms = [
|
|
57
|
+
...this.config.blockedTerms,
|
|
58
|
+
...this.config.blockedNames,
|
|
59
|
+
]
|
|
60
|
+
.filter(t => t.length > 0)
|
|
61
|
+
.map(term => new RegExp(`\\b${escapeRegex(term)}\\b`, 'gi'));
|
|
62
|
+
|
|
63
|
+
this.compiledCustomPatterns = this.config.customPatterns
|
|
64
|
+
.filter(p => p.length > 0)
|
|
65
|
+
.map(p => new RegExp(p, 'gi'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sanitize text locally. Returns the cleaned text and a list of what was stripped.
|
|
70
|
+
*/
|
|
71
|
+
sanitize(text: string): { clean: string; stripped: string[] } {
|
|
72
|
+
const stripped: string[] = [];
|
|
73
|
+
let clean = text;
|
|
74
|
+
|
|
75
|
+
// 1. Strip default sensitive patterns
|
|
76
|
+
for (const pattern of DEFAULT_PATTERNS) {
|
|
77
|
+
const matches = clean.match(pattern);
|
|
78
|
+
if (matches) {
|
|
79
|
+
stripped.push(...matches.map(m => `[pattern: ${m.slice(0, 20)}...]`));
|
|
80
|
+
clean = clean.replace(pattern, '[REDACTED]');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. Strip blocked domains
|
|
85
|
+
for (const domain of this.config.blockedDomains) {
|
|
86
|
+
const domainRegex = new RegExp(
|
|
87
|
+
`(?:https?://)?(?:[a-z0-9-]+\\.)*${escapeRegex(domain)}[/\\w.-]*`,
|
|
88
|
+
'gi'
|
|
89
|
+
);
|
|
90
|
+
const matches = clean.match(domainRegex);
|
|
91
|
+
if (matches) {
|
|
92
|
+
stripped.push(...matches.map(m => `[domain: ${domain}]`));
|
|
93
|
+
clean = clean.replace(domainRegex, '[REDACTED-URL]');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. Strip blocked terms and names
|
|
98
|
+
for (const termRegex of this.compiledBlockedTerms) {
|
|
99
|
+
const matches = clean.match(termRegex);
|
|
100
|
+
if (matches) {
|
|
101
|
+
stripped.push(...matches.map(m => `[term: ${m}]`));
|
|
102
|
+
clean = clean.replace(termRegex, '[REDACTED]');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. Strip custom patterns
|
|
107
|
+
for (const pattern of this.compiledCustomPatterns) {
|
|
108
|
+
const matches = clean.match(pattern);
|
|
109
|
+
if (matches) {
|
|
110
|
+
stripped.push(...matches.map(m => `[custom: ${m.slice(0, 20)}...]`));
|
|
111
|
+
clean = clean.replace(pattern, '[REDACTED]');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 5. Clean up multiple consecutive [REDACTED] tags
|
|
116
|
+
clean = clean.replace(/(\[REDACTED(?:-URL)?\]\s*){2,}/g, '[REDACTED] ');
|
|
117
|
+
|
|
118
|
+
return { clean: clean.trim(), stripped };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if text contains any blocked content without modifying it.
|
|
123
|
+
*/
|
|
124
|
+
check(text: string): { safe: boolean; issues: string[] } {
|
|
125
|
+
const { stripped } = this.sanitize(text);
|
|
126
|
+
return { safe: stripped.length === 0, issues: stripped };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function escapeRegex(str: string): string {
|
|
131
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
132
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "node16",
|
|
5
|
+
"moduleResolution": "node16",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"]
|
|
14
|
+
}
|