kiku-agent 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.
Files changed (45) hide show
  1. package/README.md +122 -0
  2. package/package.json +85 -0
  3. package/server/agent/agentCore.js +782 -0
  4. package/server/agent/agentState.js +140 -0
  5. package/server/agent/audioPlayer.js +673 -0
  6. package/server/agent/cli.js +1111 -0
  7. package/server/agent/commandRegistry.js +58 -0
  8. package/server/agent/commands.js +788 -0
  9. package/server/agent/config.js +90 -0
  10. package/server/agent/errors.js +9 -0
  11. package/server/agent/eventBus.js +103 -0
  12. package/server/agent/llmClient.js +200 -0
  13. package/server/agent/memory.js +566 -0
  14. package/server/agent/sanitizer.js +314 -0
  15. package/server/agent/session.js +239 -0
  16. package/server/agent/theme.js +124 -0
  17. package/server/agent/tools.js +1528 -0
  18. package/server/agent/ui.js +469 -0
  19. package/server/agent/webSearch.js +719 -0
  20. package/server/agent/youtubeAuth.js +299 -0
  21. package/server/mcp/claude-desktop-config.example.json +9 -0
  22. package/server/mcp/data/awards.js +14 -0
  23. package/server/mcp/data/brands.js +9 -0
  24. package/server/mcp/data/discography.js +36 -0
  25. package/server/mcp/data/gallery.js +25 -0
  26. package/server/mcp/data/index.js +7 -0
  27. package/server/mcp/data/profile.js +44 -0
  28. package/server/mcp/data/timeline.js +16 -0
  29. package/server/mcp/data/works.js +104 -0
  30. package/server/mcp/handler.js +159 -0
  31. package/server/mcp/prompts.js +143 -0
  32. package/server/mcp/resources.js +54 -0
  33. package/server/mcp/security.js +138 -0
  34. package/server/mcp/server.js +100 -0
  35. package/server/mcp/stdio.js +33 -0
  36. package/server/mcp/tools.js +126 -0
  37. package/src/data/awardsData.js +146 -0
  38. package/src/data/brandsData.js +249 -0
  39. package/src/data/discographyData.js +874 -0
  40. package/src/data/galleryData.js +666 -0
  41. package/src/data/musicVideoData.js +406 -0
  42. package/src/data/profileData.js +156 -0
  43. package/src/data/socialUpdatesData.js +381 -0
  44. package/src/data/timelineData.js +172 -0
  45. package/src/data/worksData.js +516 -0
@@ -0,0 +1,159 @@
1
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
2
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
+ import { createMcpServer } from './server.js';
4
+ import { checkRateLimit, validateSessionId, securityMiddleware, MAX_REQUEST_BODY_SIZE } from './security.js';
5
+
6
+ const sessions = new Map();
7
+ const SESSION_TTL_MS = 30 * 60_000;
8
+ const MAX_SESSIONS = 100;
9
+
10
+ function sendJson(res, statusCode, payload, headers = {}) {
11
+ if (res.headersSent) return;
12
+ res.statusCode = statusCode;
13
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
14
+ for (const [name, value] of Object.entries(headers)) res.setHeader(name, value);
15
+ res.end(JSON.stringify(payload));
16
+ }
17
+
18
+ async function closeSession(sessionId) {
19
+ const session = sessions.get(sessionId);
20
+ if (!session) return;
21
+ sessions.delete(sessionId);
22
+ clearInterval(session.heartbeat);
23
+ await Promise.allSettled([session.transport.close(), session.server.close()]);
24
+ }
25
+
26
+ function cleanupSessions() {
27
+ const now = Date.now();
28
+ for (const [id, session] of sessions) {
29
+ if (now - session.lastSeen > SESSION_TTL_MS) void closeSession(id);
30
+ }
31
+ if (sessions.size > MAX_SESSIONS) {
32
+ const excess = [...sessions.entries()]
33
+ .sort((a, b) => a[1].lastSeen - b[1].lastSeen)
34
+ .slice(0, sessions.size - MAX_SESSIONS);
35
+ for (const [id] of excess) void closeSession(id);
36
+ }
37
+ }
38
+
39
+ function validateRequestSize(req, res) {
40
+ const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
41
+ if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BODY_SIZE) {
42
+ sendJson(res, 413, { error: 'Request body too large.' }, { 'Retry-After': '60' });
43
+ return false;
44
+ }
45
+ return true;
46
+ }
47
+
48
+ export async function handleMcpSse(req, res) {
49
+ cleanupSessions();
50
+ const security = securityMiddleware(req, res);
51
+ if (security.blocked) return;
52
+
53
+ const transport = new SSEServerTransport('/api/v1/mcp/message', res);
54
+ const sessionId = transport.sessionId;
55
+ const server = createMcpServer();
56
+ const session = {
57
+ server,
58
+ transport,
59
+ createdAt: Date.now(),
60
+ lastSeen: Date.now(),
61
+ messageCount: 0,
62
+ heartbeat: null,
63
+ };
64
+
65
+ sessions.set(sessionId, session);
66
+ transport.onclose = () => { void closeSession(sessionId); };
67
+ transport.onerror = error => console.error(`MCP SSE transport [${sessionId}]: ${error.message}`);
68
+
69
+ try {
70
+ await server.connect(transport);
71
+ session.heartbeat = setInterval(() => {
72
+ if (!res.writableEnded) res.write(': keep-alive\n\n');
73
+ }, 15_000);
74
+ session.heartbeat.unref?.();
75
+ } catch (error) {
76
+ await closeSession(sessionId);
77
+ if (!res.headersSent) sendJson(res, 500, { error: 'Unable to establish MCP SSE transport.' });
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ export async function handleMcpMessage(req, res) {
83
+ const security = securityMiddleware(req, res);
84
+ if (security.blocked || !validateRequestSize(req, res)) return;
85
+
86
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
87
+ const sessionId = url.searchParams.get('sessionId');
88
+ if (!sessionId || !validateSessionId(sessionId)) {
89
+ return sendJson(res, 400, { error: 'Invalid or missing sessionId.' });
90
+ }
91
+
92
+ const session = sessions.get(sessionId);
93
+ if (!session) {
94
+ return sendJson(res, 404, { error: 'Session not found or expired. Reconnect through /api/v1/mcp/sse.' });
95
+ }
96
+
97
+ const rate = checkRateLimit(`mcp:${sessionId}`);
98
+ if (!rate.allowed) {
99
+ return sendJson(res, 429, { error: rate.error }, { 'Retry-After': '60' });
100
+ }
101
+ res.setHeader('X-RateLimit-Remaining', String(rate.remaining));
102
+
103
+ session.messageCount++;
104
+ session.lastSeen = Date.now();
105
+ if (session.messageCount > 500) {
106
+ await closeSession(sessionId);
107
+ return sendJson(res, 429, { error: 'Session message limit reached. Reconnect to create a new session.' });
108
+ }
109
+
110
+ await session.transport.handlePostMessage(req, res);
111
+ }
112
+
113
+ export async function handleMcpHttp(req, res) {
114
+ const security = securityMiddleware(req, res);
115
+ if (security.blocked || !validateRequestSize(req, res)) return;
116
+
117
+ if (req.method !== 'POST') {
118
+ res.setHeader('Allow', 'POST, OPTIONS');
119
+ return sendJson(res, 405, {
120
+ jsonrpc: '2.0',
121
+ error: { code: -32000, message: 'Method not allowed. Use POST for Streamable HTTP.' },
122
+ id: null,
123
+ });
124
+ }
125
+
126
+ const rate = checkRateLimit(`mcp-http:${req.socket?.remoteAddress || 'unknown'}`);
127
+ if (!rate.allowed) return sendJson(res, 429, { error: rate.error }, { 'Retry-After': '60' });
128
+ res.setHeader('X-RateLimit-Remaining', String(rate.remaining));
129
+
130
+ const server = createMcpServer();
131
+ const transport = new StreamableHTTPServerTransport({
132
+ sessionIdGenerator: undefined,
133
+ enableJsonResponse: true,
134
+ });
135
+
136
+ try {
137
+ await server.connect(transport);
138
+ await transport.handleRequest(req, res);
139
+ } catch (error) {
140
+ console.error(`MCP Streamable HTTP: ${error.message}`);
141
+ sendJson(res, 500, {
142
+ jsonrpc: '2.0',
143
+ error: { code: -32603, message: 'Internal MCP server error.' },
144
+ id: null,
145
+ });
146
+ } finally {
147
+ await Promise.allSettled([transport.close(), server.close()]);
148
+ }
149
+ }
150
+
151
+ export function getMcpSessionStats() {
152
+ return { activeSessions: sessions.size, maxSessions: MAX_SESSIONS, ttlMs: SESSION_TTL_MS };
153
+ }
154
+
155
+ export function isMcpRoute(pathname) {
156
+ return pathname === '/api/v1/mcp'
157
+ || pathname === '/api/v1/mcp/sse'
158
+ || pathname === '/api/v1/mcp/message';
159
+ }
@@ -0,0 +1,143 @@
1
+ import { worksData, timelineData, profileData, brandsData } from './data/index.js';
2
+
3
+ export const promptDefinitions = [
4
+ {
5
+ name: 'recommend_series',
6
+ description: 'Recommend Ju Jingyi dramas based on mood, genre preference, or viewing style.',
7
+ arguments: [
8
+ { name: 'mood', description: 'Current mood (romantic, action-packed, mysterious, emotional, lighthearted)', required: false },
9
+ { name: 'genre', description: 'Preferred genre (historical, fantasy, xianxia, romance, suspense)', required: false },
10
+ ],
11
+ },
12
+ {
13
+ name: 'character_summary',
14
+ description: 'Get a detailed summary of a specific character role and its development.',
15
+ arguments: [
16
+ { name: 'character_name', description: 'Name of the character (e.g. "Yang Caiwei", "Bai Suzhen", "Lu Wuyi", "Han Yunxi")', required: true },
17
+ ],
18
+ },
19
+ {
20
+ name: 'career_overview',
21
+ description: 'Get a comprehensive career overview with key milestones organized by era.',
22
+ arguments: [],
23
+ },
24
+ {
25
+ name: 'brand_portfolio',
26
+ description: 'Get a summary of Ju Jingyi\'s brand endorsement portfolio and commercial influence.',
27
+ arguments: [],
28
+ },
29
+ ];
30
+
31
+ function recommendSeries({ mood, genre } = {}) {
32
+ const moodGenreMap = {
33
+ romantic: ['historical-drama'],
34
+ emotional: ['historical-drama', 'fantasy-drama'],
35
+ 'action-packed': ['xianxia-drama', 'fantasy-drama'],
36
+ mysterious: ['historical-drama'],
37
+ lighthearted: ['historical-drama'],
38
+ };
39
+
40
+ const preferredCategories = mood ? (moodGenreMap[mood.toLowerCase()] || []) : [];
41
+ let candidates = [...worksData];
42
+
43
+ if (genre) {
44
+ candidates = candidates.filter(w => w.category.toLowerCase().includes(genre.toLowerCase()));
45
+ }
46
+ if (preferredCategories.length > 0) {
47
+ candidates = candidates.filter(w => preferredCategories.includes(w.category));
48
+ }
49
+
50
+ candidates.sort((a, b) => (b.featured ? 1 : 0) - (a.featured ? 1 : 0) || b.year - a.year);
51
+ const top = candidates.slice(0, 5);
52
+
53
+ return {
54
+ messages: [{
55
+ role: 'user',
56
+ content: {
57
+ type: 'text',
58
+ text: `Recommend Ju Jingyi dramas${mood ? ` for mood: "${mood}"` : ''}${genre ? `, genre: "${genre}"` : ''}.\n\nHere are the best matches:\n\n${top.map((w, i) => `${i + 1}. **${w.titleEn}** (${w.titleCn}, ${w.year})\n Role: ${w.role}\n Platform: ${w.platform || 'TBA'}\n ${w.synopsis ? w.synopsis.slice(0, 150) + '...' : ''}\n`).join('\n')}\n\nBased on your preference${mood ? ` for "${mood}" content` : ''}, I recommend starting with the top suggestion.`,
59
+ },
60
+ }],
61
+ };
62
+ }
63
+
64
+ function characterSummary({ character_name }) {
65
+ const q = character_name.toLowerCase();
66
+ const matches = worksData.filter(w => w.role.toLowerCase().includes(q));
67
+ if (matches.length === 0) {
68
+ return {
69
+ messages: [{
70
+ role: 'user',
71
+ content: { type: 'text', text: `No character matching "${character_name}" found. Available characters: ${worksData.map(w => w.role.split(' (')[0]).join(', ')}` },
72
+ }],
73
+ };
74
+ }
75
+
76
+ const summaries = matches.map(w =>
77
+ `**${w.role}** in **${w.titleEn}** (${w.titleCn}, ${w.year})\nPlatform: ${w.platform || 'TBA'}\n${w.synopsis || 'No synopsis available.'}`
78
+ ).join('\n\n');
79
+
80
+ return {
81
+ messages: [{
82
+ role: 'user',
83
+ content: { type: 'text', text: `Character Summary: "${character_name}"\n\nJu Jingyi portrayed this role${matches.length > 1 ? ` (${matches.length} appearances)` : ''}:\n\n${summaries}` },
84
+ }],
85
+ };
86
+ }
87
+
88
+ function careerOverview() {
89
+ const eras = {};
90
+ for (const t of timelineData) {
91
+ if (!eras[t.era]) eras[t.era] = [];
92
+ eras[t.era].push(t);
93
+ }
94
+
95
+ let overview = `# Ju Jingyi Career Overview\n\n${profileData.bioSummary}\n\n`;
96
+ overview += `**Fandom:** ${profileData.details.fandomName} | **Colors:** ${profileData.details.fandomColors}\n\n`;
97
+ overview += `## Career Eras\n\n`;
98
+
99
+ for (const [era, events] of Object.entries(eras)) {
100
+ overview += `### ${era}\n`;
101
+ for (const e of events.sort((a, b) => a.year - b.year)) {
102
+ overview += `- **${e.year}**: ${e.title} (${e.titleCn}) — ${e.summary}\n`;
103
+ }
104
+ overview += '\n';
105
+ }
106
+
107
+ return { messages: [{ role: 'user', content: { type: 'text', text: overview } }] };
108
+ }
109
+
110
+ function brandPortfolio() {
111
+ const globalBrands = brandsData.filter(b => b.isGlobal);
112
+ const otherBrands = brandsData.filter(b => !b.isGlobal);
113
+
114
+ let text = `# Ju Jingyi Brand Endorsement Portfolio\n\n`;
115
+ text += `**Title:** ${brandsData.length}+ brand partnerships | "Commercial Harvest Machine" (商务收割机)\n\n`;
116
+
117
+ text += `## Global Brand Ambassadors\n`;
118
+ for (const b of globalBrands) {
119
+ text += `- **${b.brandNameEn}** (${b.brandNameCn}) — ${b.title} (${b.year})\n Category: ${b.category}\n Highlight: ${b.highlight}\n\n`;
120
+ }
121
+
122
+ text += `## Other Endorsements\n`;
123
+ for (const b of otherBrands) {
124
+ text += `- **${b.brandNameEn}** (${b.brandNameCn}) — ${b.title} (${b.year})\n ${b.highlight}\n`;
125
+ }
126
+
127
+ return { messages: [{ role: 'user', content: { type: 'text', text } }] };
128
+ }
129
+
130
+ // ── Dispatch ──
131
+
132
+ const promptHandlers = {
133
+ recommend_series: recommendSeries,
134
+ character_summary: characterSummary,
135
+ career_overview: careerOverview,
136
+ brand_portfolio: brandPortfolio,
137
+ };
138
+
139
+ export async function getPrompt(name, args = {}) {
140
+ const handler = promptHandlers[name];
141
+ if (!handler) throw new Error(`Unknown prompt: ${name}`);
142
+ return handler(args);
143
+ }
@@ -0,0 +1,54 @@
1
+ import { profileData, worksData, discographyData, awardsData, timelineData, brandsData } from './data/index.js';
2
+
3
+ export const resourceDefinitions = [
4
+ { uri: 'kiku://bio', name: 'Ju Jingyi Biography', description: 'Full biographical profile, talents, quotes, and social media links.', mimeType: 'application/json' },
5
+ { uri: 'kiku://awards', name: 'Awards & Honors', description: 'Complete list of awards, honors, and recognitions.', mimeType: 'application/json' },
6
+ { uri: 'kiku://catalog', name: 'Works Catalog Summary', description: 'Summary of all works, songs, awards, and milestones.', mimeType: 'application/json' },
7
+ { uri: 'kiku://timeline', name: 'Career Timeline', description: 'Full chronological career timeline from 2013 to present.', mimeType: 'application/json' },
8
+ { uri: 'kiku://discography', name: 'Discography', description: 'Complete discography with YouTube links.', mimeType: 'application/json' },
9
+ { uri: 'kiku://brands', name: 'Brand Endorsements', description: 'All brand endorsements and ambassadorships.', mimeType: 'application/json' },
10
+ { uri: 'kiku://social', name: 'Social Media Links', description: 'Official social media accounts and links.', mimeType: 'application/json' },
11
+ ];
12
+
13
+ export async function readResource(uri) {
14
+ switch (uri) {
15
+ case 'kiku://bio':
16
+ return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(profileData, null, 2) }] };
17
+
18
+ case 'kiku://awards':
19
+ return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(awardsData.sort((a, b) => b.year - a.year), null, 2) }] };
20
+
21
+ case 'kiku://catalog': {
22
+ const catalog = {
23
+ artist: profileData.nameEn,
24
+ artistCn: profileData.nameCn,
25
+ activeYears: '2013–2026',
26
+ totalWorks: worksData.length,
27
+ totalSongs: discographyData.length,
28
+ totalAwards: awardsData.length,
29
+ totalMilestones: timelineData.length,
30
+ totalBrandEndorsements: brandsData.length,
31
+ featuredWorks: worksData.filter(w => w.featured).map(w => ({ title: w.titleEn, year: w.year, role: w.role })),
32
+ recentSingles: discographyData.filter(s => s.category === 'single').slice(0, 5).map(s => ({ title: s.titleEn, year: s.year })),
33
+ recentAwards: awardsData.slice(0, 5).map(a => ({ title: a.title, year: a.year, ceremony: a.ceremony })),
34
+ globalBrands: brandsData.filter(b => b.isGlobal).map(b => ({ brand: b.brandNameEn, year: b.year })),
35
+ };
36
+ return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(catalog, null, 2) }] };
37
+ }
38
+
39
+ case 'kiku://timeline':
40
+ return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(timelineData.sort((a, b) => b.year - a.year), null, 2) }] };
41
+
42
+ case 'kiku://discography':
43
+ return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(discographyData, null, 2) }] };
44
+
45
+ case 'kiku://brands':
46
+ return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(brandsData.sort((a, b) => b.year - a.year), null, 2) }] };
47
+
48
+ case 'kiku://social':
49
+ return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(profileData.socialMedia || {}, null, 2) }] };
50
+
51
+ default:
52
+ throw new Error(`Unknown resource: ${uri}`);
53
+ }
54
+ }
@@ -0,0 +1,138 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+
3
+ const SECURITY_CONFIG = {
4
+ apiKeyRequired: !!process.env.MCP_API_KEY,
5
+
6
+ rateLimitWindowMs: 60_000,
7
+ rateLimitMaxRequests: 30,
8
+
9
+ maxConcurrentSessions: 100,
10
+ sessionTtlMs: 30 * 60_000,
11
+ maxMessagesPerSession: 500,
12
+
13
+ corsAllowedOrigins: (process.env.MCP_CORS_ORIGINS || '*').split(',').map(s => s.trim()),
14
+ maxRequestBodyBytes: 1_048_576,
15
+ };
16
+
17
+ export const MAX_REQUEST_BODY_SIZE = SECURITY_CONFIG.maxRequestBodyBytes;
18
+
19
+ export function validateApiKey(req) {
20
+ if (!SECURITY_CONFIG.apiKeyRequired) return { valid: true };
21
+
22
+ let key = req.headers['x-api-key'];
23
+ if (!key) {
24
+ try {
25
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
26
+ key = url.searchParams.get('key') || url.searchParams.get('apiKey');
27
+ } catch {
28
+ }
29
+ }
30
+ if (!key) return { valid: false, error: 'Missing API key. Set X-API-Key header or ?key= query parameter.' };
31
+ const expected = Buffer.from(process.env.MCP_API_KEY || '');
32
+ const received = Buffer.from(String(key));
33
+ if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
34
+ return { valid: false, error: 'Invalid API key.' };
35
+ }
36
+ return { valid: true };
37
+ }
38
+
39
+ const rateLimitStore = new Map();
40
+
41
+ export function checkRateLimit(sessionId) {
42
+ const now = Date.now();
43
+ const entry = rateLimitStore.get(sessionId);
44
+
45
+ if (!entry || now - entry.windowStart > SECURITY_CONFIG.rateLimitWindowMs) {
46
+ rateLimitStore.set(sessionId, { count: 1, windowStart: now });
47
+ return { allowed: true, remaining: SECURITY_CONFIG.rateLimitMaxRequests - 1 };
48
+ }
49
+
50
+ entry.count++;
51
+ const remaining = SECURITY_CONFIG.rateLimitMaxRequests - entry.count;
52
+ if (remaining < 0) {
53
+ return { allowed: false, error: `Rate limit exceeded. Max ${SECURITY_CONFIG.rateLimitMaxRequests} requests per minute.` };
54
+ }
55
+ return { allowed: true, remaining };
56
+ }
57
+
58
+ const rateLimitCleanupTimer = setInterval(() => {
59
+ const now = Date.now();
60
+ for (const [id, entry] of rateLimitStore) {
61
+ if (now - entry.windowStart > SECURITY_CONFIG.rateLimitWindowMs * 2) {
62
+ rateLimitStore.delete(id);
63
+ }
64
+ }
65
+ }, 60_000);
66
+ rateLimitCleanupTimer.unref?.();
67
+
68
+ export function sanitizeInput(input) {
69
+ if (typeof input === 'string') {
70
+ return input
71
+ .replace(/[<>]/g, '')
72
+ .replace(/\x00/g, '')
73
+ .slice(0, 1000);
74
+ }
75
+ if (typeof input === 'object' && input !== null) {
76
+ const sanitized = {};
77
+ for (const [key, value] of Object.entries(input)) {
78
+ if (typeof key === 'string' && key.length <= 100) {
79
+ sanitized[key] = sanitizeInput(value);
80
+ }
81
+ }
82
+ return sanitized;
83
+ }
84
+ return input;
85
+ }
86
+
87
+ export function validateSessionId(sessionId) {
88
+ if (!sessionId || typeof sessionId !== 'string') return false;
89
+ return /^[a-zA-Z0-9_-]{1,100}$/.test(sessionId);
90
+ }
91
+
92
+ export function setCorsHeaders(res, origin) {
93
+ const allowed = SECURITY_CONFIG.corsAllowedOrigins;
94
+ const isAllowed = allowed.includes('*') || allowed.includes(origin);
95
+
96
+ if (isAllowed) {
97
+ res.setHeader('Access-Control-Allow-Origin', origin || '*');
98
+ } else {
99
+ res.setHeader('Access-Control-Allow-Origin', '');
100
+ }
101
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
102
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key');
103
+ res.setHeader('Access-Control-Max-Age', '86400');
104
+
105
+ return isAllowed;
106
+ }
107
+
108
+ export function securityMiddleware(req, res) {
109
+ const origin = req.headers.origin || '';
110
+ const corsOk = setCorsHeaders(res, origin);
111
+
112
+ if (req.method === 'OPTIONS') {
113
+ res.statusCode = 204;
114
+ res.end();
115
+ return { blocked: true };
116
+ }
117
+
118
+ if (!corsOk) {
119
+ res.statusCode = 403;
120
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
121
+ res.end(JSON.stringify({ error: 'Origin not allowed by CORS policy.' }));
122
+ return { blocked: true };
123
+ }
124
+
125
+ const auth = validateApiKey(req);
126
+ if (!auth.valid) {
127
+ res.statusCode = 401;
128
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
129
+ res.end(JSON.stringify({ error: auth.error }));
130
+ return { blocked: true };
131
+ }
132
+
133
+ return { blocked: false };
134
+ }
135
+
136
+ export function getSecurityConfig() {
137
+ return { ...SECURITY_CONFIG, apiKeyRequired: SECURITY_CONFIG.apiKeyRequired };
138
+ }
@@ -0,0 +1,100 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { toolDefinitions, handleToolCall } from './tools.js';
4
+ import { resourceDefinitions, readResource } from './resources.js';
5
+ import { getPrompt } from './prompts.js';
6
+
7
+ export function createMcpServer() {
8
+ const server = new McpServer({
9
+ name: 'kiku-mcp-server',
10
+ version: '1.1.0',
11
+ }, {
12
+ capabilities: {
13
+ tools: {},
14
+ resources: {},
15
+ prompts: {},
16
+ },
17
+ });
18
+
19
+ for (const tool of toolDefinitions) {
20
+ server.registerTool(
21
+ tool.name,
22
+ {
23
+ description: tool.description,
24
+ inputSchema: tool.inputSchema,
25
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
26
+ },
27
+ async (args) => {
28
+ try {
29
+ const result = await handleToolCall(tool.name, args);
30
+ return {
31
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
32
+ structuredContent: result,
33
+ isError: result?.status === 'error',
34
+ };
35
+ } catch (err) {
36
+ return {
37
+ content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: err.message }) }],
38
+ isError: true,
39
+ };
40
+ }
41
+ }
42
+ );
43
+ }
44
+
45
+ const resourceMap = {
46
+ 'kiku://bio': 'kiku-bio',
47
+ 'kiku://awards': 'kiku-awards',
48
+ 'kiku://catalog': 'kiku-catalog',
49
+ 'kiku://timeline': 'kiku-timeline',
50
+ 'kiku://discography': 'kiku-discography',
51
+ 'kiku://brands': 'kiku-brands',
52
+ 'kiku://social': 'kiku-social',
53
+ };
54
+
55
+ for (const def of resourceDefinitions) {
56
+ server.registerResource(
57
+ resourceMap[def.uri],
58
+ def.uri,
59
+ { description: def.description, mimeType: def.mimeType },
60
+ async (uri) => readResource(uri.href)
61
+ );
62
+ }
63
+
64
+ server.registerPrompt(
65
+ 'recommend_series',
66
+ {
67
+ description: 'Recommend Ju Jingyi dramas based on mood or genre preference.',
68
+ argsSchema: {
69
+ mood: z.string().optional().describe('Current mood: romantic, action-packed, mysterious, emotional, or lighthearted'),
70
+ genre: z.string().optional().describe('Preferred genre: historical, fantasy, xianxia, romance, or suspense'),
71
+ },
72
+ },
73
+ async (args) => getPrompt('recommend_series', args)
74
+ );
75
+
76
+ server.registerPrompt(
77
+ 'brand_portfolio',
78
+ { description: 'Summarize Ju Jingyi brand endorsements and commercial influence.' },
79
+ async (args) => getPrompt('brand_portfolio', args)
80
+ );
81
+
82
+ server.registerPrompt(
83
+ 'character_summary',
84
+ {
85
+ description: 'Get a detailed summary of a specific character role by Ju Jingyi.',
86
+ argsSchema: {
87
+ character_name: z.string().min(1).describe('Character name, such as Yang Caiwei, Bai Suzhen, Lu Wuyi, or Han Yunxi'),
88
+ },
89
+ },
90
+ async (args) => getPrompt('character_summary', args)
91
+ );
92
+
93
+ server.registerPrompt(
94
+ 'career_overview',
95
+ { description: 'Get a comprehensive career overview organized by era.' },
96
+ async (args) => getPrompt('career_overview', args)
97
+ );
98
+
99
+ return server;
100
+ }
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { createMcpServer } from './server.js';
5
+
6
+ const server = createMcpServer();
7
+ const transport = new StdioServerTransport(process.stdin, process.stdout, { maxBufferSize: 2 * 1024 * 1024 });
8
+
9
+ let closing = false;
10
+ async function shutdown(exitCode = 0) {
11
+ if (closing) return;
12
+ closing = true;
13
+ await Promise.allSettled([transport.close(), server.close()]);
14
+ process.exitCode = exitCode;
15
+ }
16
+
17
+ process.once('SIGINT', () => { void shutdown(130); });
18
+ process.once('SIGTERM', () => { void shutdown(143); });
19
+ process.once('uncaughtException', error => {
20
+ process.stderr.write(`Kiku MCP fatal error: ${error.message}\n`);
21
+ void shutdown(1);
22
+ });
23
+ process.once('unhandledRejection', error => {
24
+ process.stderr.write(`Kiku MCP unhandled rejection: ${error?.message || String(error)}\n`);
25
+ void shutdown(1);
26
+ });
27
+
28
+ try {
29
+ await server.connect(transport);
30
+ } catch (error) {
31
+ process.stderr.write(`Unable to start Kiku MCP server: ${error.message}\n`);
32
+ await shutdown(1);
33
+ }