@sky.ui/mcp 0.0.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/LICENSE.md +139 -0
- package/README.md +182 -0
- package/data/chart-api-sections.json +4185 -0
- package/data/component-tier.json +25 -0
- package/data/design-guidelines/p0-guidelines.json +13381 -0
- package/data/reactivity-readme-snapshot.json +1 -0
- package/data/theme-authoring-contract.json +598 -0
- package/data/utils-suggestion-snapshot.json +1 -0
- package/dist/cache.d.ts +3 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +15 -0
- package/dist/cache.js.map +1 -0
- package/dist/catalog.d.ts +60 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +343 -0
- package/dist/catalog.js.map +1 -0
- package/dist/cem.d.ts +26 -0
- package/dist/cem.d.ts.map +1 -0
- package/dist/cem.js +348 -0
- package/dist/cem.js.map +1 -0
- package/dist/chart-usage-tool.d.ts +20 -0
- package/dist/chart-usage-tool.d.ts.map +1 -0
- package/dist/chart-usage-tool.js +153 -0
- package/dist/chart-usage-tool.js.map +1 -0
- package/dist/component-docs-tool.d.ts +45 -0
- package/dist/component-docs-tool.d.ts.map +1 -0
- package/dist/component-docs-tool.js +217 -0
- package/dist/component-docs-tool.js.map +1 -0
- package/dist/component-method-filter.d.ts +3 -0
- package/dist/component-method-filter.d.ts.map +1 -0
- package/dist/component-method-filter.js +32 -0
- package/dist/component-method-filter.js.map +1 -0
- package/dist/component-tier.d.ts +20 -0
- package/dist/component-tier.d.ts.map +1 -0
- package/dist/component-tier.js +59 -0
- package/dist/component-tier.js.map +1 -0
- package/dist/component-usage-tool.d.ts +20 -0
- package/dist/component-usage-tool.d.ts.map +1 -0
- package/dist/component-usage-tool.js +90 -0
- package/dist/component-usage-tool.js.map +1 -0
- package/dist/design-guidelines/a11y-engine.d.ts +22 -0
- package/dist/design-guidelines/a11y-engine.d.ts.map +1 -0
- package/dist/design-guidelines/a11y-engine.js +78 -0
- package/dist/design-guidelines/a11y-engine.js.map +1 -0
- package/dist/design-guidelines/compatibility-engine.d.ts +20 -0
- package/dist/design-guidelines/compatibility-engine.d.ts.map +1 -0
- package/dist/design-guidelines/compatibility-engine.js +57 -0
- package/dist/design-guidelines/compatibility-engine.js.map +1 -0
- package/dist/design-guidelines/component-guideline-engine.d.ts +19 -0
- package/dist/design-guidelines/component-guideline-engine.d.ts.map +1 -0
- package/dist/design-guidelines/component-guideline-engine.js +40 -0
- package/dist/design-guidelines/component-guideline-engine.js.map +1 -0
- package/dist/design-guidelines/composition-engine.d.ts +10 -0
- package/dist/design-guidelines/composition-engine.d.ts.map +1 -0
- package/dist/design-guidelines/composition-engine.js +29 -0
- package/dist/design-guidelines/composition-engine.js.map +1 -0
- package/dist/design-guidelines/pattern-engine.d.ts +20 -0
- package/dist/design-guidelines/pattern-engine.d.ts.map +1 -0
- package/dist/design-guidelines/pattern-engine.js +58 -0
- package/dist/design-guidelines/pattern-engine.js.map +1 -0
- package/dist/design-guidelines/recommendation-engine.d.ts +11 -0
- package/dist/design-guidelines/recommendation-engine.d.ts.map +1 -0
- package/dist/design-guidelines/recommendation-engine.js +88 -0
- package/dist/design-guidelines/recommendation-engine.js.map +1 -0
- package/dist/design-guidelines/repository.d.ts +19 -0
- package/dist/design-guidelines/repository.d.ts.map +1 -0
- package/dist/design-guidelines/repository.js +71 -0
- package/dist/design-guidelines/repository.js.map +1 -0
- package/dist/design-guidelines/review-engine.d.ts +20 -0
- package/dist/design-guidelines/review-engine.d.ts.map +1 -0
- package/dist/design-guidelines/review-engine.js +179 -0
- package/dist/design-guidelines/review-engine.js.map +1 -0
- package/dist/design-guidelines/schema.d.ts +256 -0
- package/dist/design-guidelines/schema.d.ts.map +1 -0
- package/dist/design-guidelines/schema.js +124 -0
- package/dist/design-guidelines/schema.js.map +1 -0
- package/dist/design-guidelines/token-engine.d.ts +23 -0
- package/dist/design-guidelines/token-engine.d.ts.map +1 -0
- package/dist/design-guidelines/token-engine.js +119 -0
- package/dist/design-guidelines/token-engine.js.map +1 -0
- package/dist/docs-read.d.ts +28 -0
- package/dist/docs-read.d.ts.map +1 -0
- package/dist/docs-read.js +102 -0
- package/dist/docs-read.js.map +1 -0
- package/dist/docs.d.ts +73 -0
- package/dist/docs.d.ts.map +1 -0
- package/dist/docs.js +323 -0
- package/dist/docs.js.map +1 -0
- package/dist/example-site-doc-enrichment.d.ts +27 -0
- package/dist/example-site-doc-enrichment.d.ts.map +1 -0
- package/dist/example-site-doc-enrichment.js +171 -0
- package/dist/example-site-doc-enrichment.js.map +1 -0
- package/dist/example-site-fetch.d.ts +44 -0
- package/dist/example-site-fetch.d.ts.map +1 -0
- package/dist/example-site-fetch.js +255 -0
- package/dist/example-site-fetch.js.map +1 -0
- package/dist/framework-wrappers.d.ts +46 -0
- package/dist/framework-wrappers.d.ts.map +1 -0
- package/dist/framework-wrappers.js +131 -0
- package/dist/framework-wrappers.js.map +1 -0
- package/dist/mcp-response-truth.d.ts +18 -0
- package/dist/mcp-response-truth.d.ts.map +1 -0
- package/dist/mcp-response-truth.js +45 -0
- package/dist/mcp-response-truth.js.map +1 -0
- package/dist/mcp-runtime-status.d.ts +37 -0
- package/dist/mcp-runtime-status.d.ts.map +1 -0
- package/dist/mcp-runtime-status.js +96 -0
- package/dist/mcp-runtime-status.js.map +1 -0
- package/dist/mcp-stdio-entry.d.ts +3 -0
- package/dist/mcp-stdio-entry.d.ts.map +1 -0
- package/dist/mcp-stdio-entry.js +35 -0
- package/dist/mcp-stdio-entry.js.map +1 -0
- package/dist/mcp-tool-errors.d.ts +35 -0
- package/dist/mcp-tool-errors.d.ts.map +1 -0
- package/dist/mcp-tool-errors.js +42 -0
- package/dist/mcp-tool-errors.js.map +1 -0
- package/dist/parse-component-ast.d.ts +112 -0
- package/dist/parse-component-ast.d.ts.map +1 -0
- package/dist/parse-component-ast.js +695 -0
- package/dist/parse-component-ast.js.map +1 -0
- package/dist/post-endpoint-server.d.ts +19 -0
- package/dist/post-endpoint-server.d.ts.map +1 -0
- package/dist/post-endpoint-server.js +777 -0
- package/dist/post-endpoint-server.js.map +1 -0
- package/dist/reactivity-info.d.ts +22 -0
- package/dist/reactivity-info.d.ts.map +1 -0
- package/dist/reactivity-info.js +99 -0
- package/dist/reactivity-info.js.map +1 -0
- package/dist/reactivity-layer-tool.d.ts +18 -0
- package/dist/reactivity-layer-tool.d.ts.map +1 -0
- package/dist/reactivity-layer-tool.js +58 -0
- package/dist/reactivity-layer-tool.js.map +1 -0
- package/dist/reactivity-readme-topics.d.ts +46 -0
- package/dist/reactivity-readme-topics.d.ts.map +1 -0
- package/dist/reactivity-readme-topics.js +206 -0
- package/dist/reactivity-readme-topics.js.map +1 -0
- package/dist/read-only-tool-allowlist.d.ts +7 -0
- package/dist/read-only-tool-allowlist.d.ts.map +1 -0
- package/dist/read-only-tool-allowlist.js +17 -0
- package/dist/read-only-tool-allowlist.js.map +1 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +786 -0
- package/dist/server.js.map +1 -0
- package/dist/source-read.d.ts +31 -0
- package/dist/source-read.d.ts.map +1 -0
- package/dist/source-read.js +213 -0
- package/dist/source-read.js.map +1 -0
- package/dist/theme-authoring/contract-schema.d.ts +191 -0
- package/dist/theme-authoring/contract-schema.d.ts.map +1 -0
- package/dist/theme-authoring/contract-schema.js +49 -0
- package/dist/theme-authoring/contract-schema.js.map +1 -0
- package/dist/theme-authoring/repository.d.ts +18 -0
- package/dist/theme-authoring/repository.d.ts.map +1 -0
- package/dist/theme-authoring/repository.js +55 -0
- package/dist/theme-authoring/repository.js.map +1 -0
- package/dist/theme-tool.d.ts +26 -0
- package/dist/theme-tool.d.ts.map +1 -0
- package/dist/theme-tool.js +312 -0
- package/dist/theme-tool.js.map +1 -0
- package/dist/utils-guide-topics.d.ts +39 -0
- package/dist/utils-guide-topics.d.ts.map +1 -0
- package/dist/utils-guide-topics.js +202 -0
- package/dist/utils-guide-topics.js.map +1 -0
- package/dist/utils-info.d.ts +75 -0
- package/dist/utils-info.d.ts.map +1 -0
- package/dist/utils-info.js +219 -0
- package/dist/utils-info.js.map +1 -0
- package/dist/utils-suggestion-grounding.d.ts +59 -0
- package/dist/utils-suggestion-grounding.d.ts.map +1 -0
- package/dist/utils-suggestion-grounding.js +267 -0
- package/dist/utils-suggestion-grounding.js.map +1 -0
- package/docs/agent-recipe-example-site.md +91 -0
- package/docs/ai-design-mcp-blueprint.md +75 -0
- package/docs/cross-model-mcp-playbook.md +127 -0
- package/docs/example-site-and-mcp-training.md +82 -0
- package/docs/mcp-capability-status.md +51 -0
- package/docs/mcp-tooling-roadmap.md +36 -0
- package/docs/sky-chart-option-api.md +47 -0
- package/docs/starter-prompt.md +17 -0
- package/docs/theme-config-guide.md +178 -0
- package/docs/utils-usage-guide.md +110 -0
- package/docs/vue-wrapper-v-model.md +36 -0
- package/package.json +63 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import Fastify from 'fastify';
|
|
5
|
+
import cors from '@fastify/cors';
|
|
6
|
+
import rateLimit from '@fastify/rate-limit';
|
|
7
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
9
|
+
import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js';
|
|
10
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { assertCoreDependencyAvailable, createSkyUiMcpServer } from './server.js';
|
|
15
|
+
import { READ_ONLY_TOOLS } from './read-only-tool-allowlist.js';
|
|
16
|
+
const PORT = Number(process.env.SKY_UI_MCP_POST_PORT || '3001');
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const repoRoot = resolve(__dirname, '..', '..', '..');
|
|
19
|
+
const REQUEST_TIMEOUT_MS = Number(process.env.SKY_UI_MCP_ENDPOINT_TIMEOUT_MS || '20000');
|
|
20
|
+
const API_KEY = process.env.SKY_UI_MCP_API_KEY || '';
|
|
21
|
+
const KEYS_JSON_INLINE = process.env.SKY_UI_MCP_KEYS_JSON || '';
|
|
22
|
+
const KEYS_FILE = process.env.SKY_UI_MCP_KEYS_FILE || '';
|
|
23
|
+
const RATE_LIMIT_MAX = Number(process.env.SKY_UI_MCP_RATE_LIMIT_MAX || '120');
|
|
24
|
+
const RATE_LIMIT_WINDOW = process.env.SKY_UI_MCP_RATE_LIMIT_WINDOW || '1 minute';
|
|
25
|
+
const LOG_REQUESTS = (process.env.SKY_UI_MCP_LOG_REQUESTS || 'true').toLowerCase() !== 'false';
|
|
26
|
+
const CORS_ORIGINS = (process.env.SKY_UI_MCP_CORS_ORIGINS || '')
|
|
27
|
+
.split(',')
|
|
28
|
+
.map((s) => s.trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
const ALLOW_IPS = (process.env.SKY_UI_MCP_ALLOW_IPS || '')
|
|
31
|
+
.split(',')
|
|
32
|
+
.map((s) => s.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
const AUDIT_LOG_PATH = process.env.SKY_UI_MCP_AUDIT_LOG || '';
|
|
35
|
+
const LICENSE_API_URL = (process.env.SKY_UI_LICENSE_API_URL || '').trim();
|
|
36
|
+
/** When `true`, missing/invalid API key yields 401 if keys are configured. Default `false` (no blocking). */
|
|
37
|
+
const REQUIRE_AUTH = (process.env.SKY_UI_MCP_REQUIRE_AUTH || 'false').toLowerCase() === 'true';
|
|
38
|
+
function isSkyAccessToken(value) {
|
|
39
|
+
return value.startsWith('sk_pt_') || value.startsWith('sk_ci_');
|
|
40
|
+
}
|
|
41
|
+
async function validateSkyAccessToken(token) {
|
|
42
|
+
if (!LICENSE_API_URL || !isSkyAccessToken(token))
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
const base = LICENSE_API_URL.replace(/\/$/, '');
|
|
46
|
+
const res = await fetch(`${base}/license/validate`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${token}`,
|
|
50
|
+
'Content-Type': 'application/json',
|
|
51
|
+
},
|
|
52
|
+
body: '{}',
|
|
53
|
+
});
|
|
54
|
+
const data = (await res.json());
|
|
55
|
+
if (data.active && data.userId)
|
|
56
|
+
return data.userId;
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
console.error('[sky-ui-mcp] License token validation failed:', e);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/** Headers MCP Streamable HTTP clients send on cross-origin browser requests (preflight must allow these). */
|
|
64
|
+
const MCP_CORS_ALLOW_HEADERS = [
|
|
65
|
+
'accept',
|
|
66
|
+
'accept-language',
|
|
67
|
+
'content-type',
|
|
68
|
+
'mcp-session-id',
|
|
69
|
+
'mcp-protocol-version',
|
|
70
|
+
'authorization',
|
|
71
|
+
'x-api-key',
|
|
72
|
+
'last-event-id'
|
|
73
|
+
].join(', ');
|
|
74
|
+
let clientPromise = null;
|
|
75
|
+
function currentProcessEnv() {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
78
|
+
if (typeof v === 'string')
|
|
79
|
+
out[k] = v;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function textFromContentBlocks(content) {
|
|
84
|
+
if (!Array.isArray(content))
|
|
85
|
+
return '';
|
|
86
|
+
return content
|
|
87
|
+
.filter((c) => c && typeof c === 'object' && c.type === 'text')
|
|
88
|
+
.map((c) => c.text ?? '')
|
|
89
|
+
.join('\n');
|
|
90
|
+
}
|
|
91
|
+
function resolveMcpServerCli() {
|
|
92
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
93
|
+
const candidates = [
|
|
94
|
+
join(here, 'mcp-stdio-entry.js'),
|
|
95
|
+
join(here, '..', 'dist', 'mcp-stdio-entry.js'),
|
|
96
|
+
join(here, 'server.js'),
|
|
97
|
+
join(here, '..', 'dist', 'server.js')
|
|
98
|
+
];
|
|
99
|
+
for (const entry of candidates) {
|
|
100
|
+
if (existsSync(entry)) {
|
|
101
|
+
return { command: 'node', args: [entry] };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { command: 'npm', args: ['run', 'dev', '-w', '@sky.ui/mcp'] };
|
|
105
|
+
}
|
|
106
|
+
function clientIp(req) {
|
|
107
|
+
const xff = req.headers['x-forwarded-for'];
|
|
108
|
+
if (typeof xff === 'string' && xff.trim()) {
|
|
109
|
+
return xff.split(',')[0].trim();
|
|
110
|
+
}
|
|
111
|
+
return req.ip;
|
|
112
|
+
}
|
|
113
|
+
function clientIpFromIncoming(req) {
|
|
114
|
+
const xff = req.headers['x-forwarded-for'];
|
|
115
|
+
if (typeof xff === 'string' && xff.trim()) {
|
|
116
|
+
return xff.split(',')[0].trim();
|
|
117
|
+
}
|
|
118
|
+
return req.socket?.remoteAddress ?? '';
|
|
119
|
+
}
|
|
120
|
+
/** Tab-separated: ISO time, IP, status, tool name or `-`, user id / `legacy-key` / `-`. */
|
|
121
|
+
function auditLog(ip, status, tool, userLabel) {
|
|
122
|
+
if (!AUDIT_LOG_PATH)
|
|
123
|
+
return;
|
|
124
|
+
try {
|
|
125
|
+
const who = userLabel === undefined || userLabel === null || userLabel === '' ? '-' : userLabel;
|
|
126
|
+
const line = `${new Date().toISOString()}\t${ip}\t${status}\t${tool ?? '-'}\t${who}\n`;
|
|
127
|
+
appendFileSync(AUDIT_LOG_PATH, line, { encoding: 'utf8' });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* ignore audit failures */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function timingSafeEqualStr(a, b) {
|
|
134
|
+
try {
|
|
135
|
+
const ba = Buffer.from(a, 'utf8');
|
|
136
|
+
const bb = Buffer.from(b, 'utf8');
|
|
137
|
+
if (ba.length !== bb.length)
|
|
138
|
+
return false;
|
|
139
|
+
return timingSafeEqual(ba, bb);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Load `apiKey -> userId` entries from `SKY_UI_MCP_KEYS_JSON` and/or `SKY_UI_MCP_KEYS_FILE` (merged). */
|
|
146
|
+
function loadUserApiKeyMap() {
|
|
147
|
+
const map = new Map();
|
|
148
|
+
function setPair(userId, apiKey, source) {
|
|
149
|
+
const uid = userId.trim();
|
|
150
|
+
const key = apiKey.trim();
|
|
151
|
+
if (!uid || !key)
|
|
152
|
+
return;
|
|
153
|
+
const existing = map.get(key);
|
|
154
|
+
if (existing !== undefined && existing !== uid) {
|
|
155
|
+
console.warn(`[sky-ui-mcp] Duplicate API key in ${source}; last user id wins (${uid}).`);
|
|
156
|
+
}
|
|
157
|
+
map.set(key, uid);
|
|
158
|
+
}
|
|
159
|
+
function consumeParsed(parsed, source) {
|
|
160
|
+
if (parsed === null || parsed === undefined)
|
|
161
|
+
return;
|
|
162
|
+
if (Array.isArray(parsed)) {
|
|
163
|
+
for (const row of parsed) {
|
|
164
|
+
if (row && typeof row === 'object') {
|
|
165
|
+
const r = row;
|
|
166
|
+
const id = r.id ?? r.userId ?? r.user;
|
|
167
|
+
const key = r.apiKey ?? r.api_key ?? r.key ?? r.token;
|
|
168
|
+
if (typeof id === 'string' && typeof key === 'string')
|
|
169
|
+
setPair(id, key, source);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (typeof parsed !== 'object')
|
|
175
|
+
return;
|
|
176
|
+
const o = parsed;
|
|
177
|
+
if (Array.isArray(o.users)) {
|
|
178
|
+
for (const row of o.users) {
|
|
179
|
+
if (row && typeof row === 'object') {
|
|
180
|
+
const r = row;
|
|
181
|
+
const id = r.id ?? r.userId ?? r.user;
|
|
182
|
+
const key = r.apiKey ?? r.api_key ?? r.key ?? r.token;
|
|
183
|
+
if (typeof id === 'string' && typeof key === 'string')
|
|
184
|
+
setPair(id, key, source);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const [k, v] of Object.entries(o)) {
|
|
189
|
+
if (k === 'users')
|
|
190
|
+
continue;
|
|
191
|
+
if (typeof v === 'string')
|
|
192
|
+
setPair(k, v, source);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (KEYS_JSON_INLINE.trim()) {
|
|
196
|
+
try {
|
|
197
|
+
consumeParsed(JSON.parse(KEYS_JSON_INLINE), 'SKY_UI_MCP_KEYS_JSON');
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
console.error('[sky-ui-mcp] Failed to parse SKY_UI_MCP_KEYS_JSON:', e);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const keysPath = KEYS_FILE.trim();
|
|
204
|
+
if (keysPath && existsSync(keysPath)) {
|
|
205
|
+
try {
|
|
206
|
+
consumeParsed(JSON.parse(readFileSync(keysPath, 'utf8')), keysPath);
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
console.error(`[sky-ui-mcp] Failed to read/parse SKY_UI_MCP_KEYS_FILE (${keysPath}):`, e);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return map;
|
|
213
|
+
}
|
|
214
|
+
const LEGACY_KEY = API_KEY.trim();
|
|
215
|
+
const USER_API_KEYS = loadUserApiKeyMap();
|
|
216
|
+
function authIsRequired() {
|
|
217
|
+
if (!REQUIRE_AUTH)
|
|
218
|
+
return false;
|
|
219
|
+
return LEGACY_KEY.length > 0 || USER_API_KEYS.size > 0 || LICENSE_API_URL.length > 0;
|
|
220
|
+
}
|
|
221
|
+
function resolveProvidedApiKey(provided) {
|
|
222
|
+
if (LEGACY_KEY.length > 0 && timingSafeEqualStr(provided, LEGACY_KEY)) {
|
|
223
|
+
return { kind: 'legacy' };
|
|
224
|
+
}
|
|
225
|
+
for (const [storedKey, userId] of USER_API_KEYS) {
|
|
226
|
+
if (timingSafeEqualStr(provided, storedKey)) {
|
|
227
|
+
return { kind: 'user', userId };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
function authModeLabel() {
|
|
233
|
+
const hasL = LEGACY_KEY.length > 0;
|
|
234
|
+
const hasU = USER_API_KEYS.size > 0;
|
|
235
|
+
if (!hasL && !hasU)
|
|
236
|
+
return 'none';
|
|
237
|
+
if (hasL && hasU)
|
|
238
|
+
return 'mixed';
|
|
239
|
+
if (hasL)
|
|
240
|
+
return 'legacy';
|
|
241
|
+
return 'users';
|
|
242
|
+
}
|
|
243
|
+
function userLabelForAudit(req) {
|
|
244
|
+
const a = req.skyMcpAuth;
|
|
245
|
+
if (a?.mode === 'user')
|
|
246
|
+
return a.userId;
|
|
247
|
+
if (a?.mode === 'legacy')
|
|
248
|
+
return 'legacy-key';
|
|
249
|
+
return '-';
|
|
250
|
+
}
|
|
251
|
+
function requestPath(url) {
|
|
252
|
+
const q = url.indexOf('?');
|
|
253
|
+
return q === -1 ? url : url.slice(0, q);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Value for Access-Control-Allow-Origin, or null if this request must not get CORS headers
|
|
257
|
+
* (browser cross-origin request with disallowed Origin when SKY_UI_MCP_CORS_ORIGINS is set).
|
|
258
|
+
*/
|
|
259
|
+
function resolveAllowedOrigin(req) {
|
|
260
|
+
const origin = req.headers.origin;
|
|
261
|
+
if (CORS_ORIGINS.length > 0) {
|
|
262
|
+
if (typeof origin === 'string' && CORS_ORIGINS.includes(origin))
|
|
263
|
+
return origin;
|
|
264
|
+
if (typeof origin !== 'string' || origin.length === 0)
|
|
265
|
+
return '*';
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
if (typeof origin === 'string' && origin.length > 0)
|
|
269
|
+
return origin;
|
|
270
|
+
return '*';
|
|
271
|
+
}
|
|
272
|
+
function corsHeadersForRequest(req) {
|
|
273
|
+
const allowed = resolveAllowedOrigin(req);
|
|
274
|
+
if (allowed === null)
|
|
275
|
+
return null;
|
|
276
|
+
const headers = {
|
|
277
|
+
'Access-Control-Allow-Origin': allowed,
|
|
278
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
279
|
+
'Access-Control-Allow-Headers': MCP_CORS_ALLOW_HEADERS,
|
|
280
|
+
'Access-Control-Expose-Headers': 'mcp-session-id, Mcp-Session-Id',
|
|
281
|
+
Vary: 'Origin',
|
|
282
|
+
'Access-Control-Max-Age': '86400'
|
|
283
|
+
};
|
|
284
|
+
if (allowed !== '*') {
|
|
285
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
286
|
+
}
|
|
287
|
+
return headers;
|
|
288
|
+
}
|
|
289
|
+
/** Required on `/` because `reply.hijack()` bypasses @fastify/cors for Streamable HTTP. */
|
|
290
|
+
function applyMcpCorsHeaders(req, res) {
|
|
291
|
+
const h = corsHeadersForRequest(req);
|
|
292
|
+
if (!h)
|
|
293
|
+
return;
|
|
294
|
+
for (const [key, value] of Object.entries(h)) {
|
|
295
|
+
res.setHeader(key, value);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function applyMcpCorsToFastifyReply(request, reply) {
|
|
299
|
+
const h = corsHeadersForRequest(request.raw);
|
|
300
|
+
if (!h)
|
|
301
|
+
return;
|
|
302
|
+
for (const [key, value] of Object.entries(h)) {
|
|
303
|
+
reply.header(key, value);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const streamableSessions = {};
|
|
307
|
+
function bodyLooksLikeInitialize(parsedBody) {
|
|
308
|
+
if (parsedBody === undefined)
|
|
309
|
+
return false;
|
|
310
|
+
if (isInitializeRequest(parsedBody))
|
|
311
|
+
return true;
|
|
312
|
+
if (Array.isArray(parsedBody)) {
|
|
313
|
+
return parsedBody.some((m) => isInitializeRequest(m));
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* JSON-RPC `tools/call` bodies (single or batch). Used when Streamable HTTP clients POST tool
|
|
319
|
+
* calls without a valid `mcp-session-id` (session expired, or client skips full handshake).
|
|
320
|
+
*/
|
|
321
|
+
function extractToolsCallRequests(parsedBody) {
|
|
322
|
+
const parseOne = (msg) => {
|
|
323
|
+
if (!msg || typeof msg !== 'object')
|
|
324
|
+
return null;
|
|
325
|
+
const m = msg;
|
|
326
|
+
if (m.jsonrpc !== '2.0')
|
|
327
|
+
return null;
|
|
328
|
+
if (m.method !== 'tools/call')
|
|
329
|
+
return null;
|
|
330
|
+
const params = m.params;
|
|
331
|
+
if (!params || typeof params !== 'object')
|
|
332
|
+
return null;
|
|
333
|
+
const p = params;
|
|
334
|
+
const name = p.name;
|
|
335
|
+
if (typeof name !== 'string' || !name.trim())
|
|
336
|
+
return null;
|
|
337
|
+
const rawArgs = p.arguments;
|
|
338
|
+
const args = typeof rawArgs === 'object' && rawArgs !== null && !Array.isArray(rawArgs)
|
|
339
|
+
? rawArgs
|
|
340
|
+
: {};
|
|
341
|
+
return { id: 'id' in m ? m.id : null, tool: name.trim(), args };
|
|
342
|
+
};
|
|
343
|
+
if (parsedBody === undefined || parsedBody === null)
|
|
344
|
+
return null;
|
|
345
|
+
if (Array.isArray(parsedBody)) {
|
|
346
|
+
if (parsedBody.length === 0)
|
|
347
|
+
return null;
|
|
348
|
+
const out = [];
|
|
349
|
+
for (const item of parsedBody) {
|
|
350
|
+
const one = parseOne(item);
|
|
351
|
+
if (!one)
|
|
352
|
+
return null;
|
|
353
|
+
out.push(one);
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
const single = parseOne(parsedBody);
|
|
358
|
+
return single ? [single] : null;
|
|
359
|
+
}
|
|
360
|
+
async function executeStatelessToolsCallResponses(req, res, toolsCalls) {
|
|
361
|
+
const ip = clientIpFromIncoming(req);
|
|
362
|
+
const responses = [];
|
|
363
|
+
for (const tc of toolsCalls) {
|
|
364
|
+
const { id, tool, args } = tc;
|
|
365
|
+
if (!READ_ONLY_TOOLS.has(tool)) {
|
|
366
|
+
auditLog(ip, 403, tool, '-');
|
|
367
|
+
responses.push({
|
|
368
|
+
jsonrpc: '2.0',
|
|
369
|
+
id,
|
|
370
|
+
error: {
|
|
371
|
+
code: -32602,
|
|
372
|
+
message: `Tool "${tool}" is not allowed on this endpoint.`,
|
|
373
|
+
data: { allowedTools: [...READ_ONLY_TOOLS].sort() }
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
const client = await getClient();
|
|
380
|
+
const result = await client.callTool({ name: tool, arguments: args });
|
|
381
|
+
auditLog(ip, 200, tool, '-');
|
|
382
|
+
responses.push({
|
|
383
|
+
jsonrpc: '2.0',
|
|
384
|
+
id,
|
|
385
|
+
result: {
|
|
386
|
+
content: result.content,
|
|
387
|
+
...(typeof result.isError === 'boolean' ? { isError: result.isError } : {})
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
393
|
+
auditLog(ip, 500, tool, '-');
|
|
394
|
+
responses.push({
|
|
395
|
+
jsonrpc: '2.0',
|
|
396
|
+
id,
|
|
397
|
+
error: { code: -32603, message: msg }
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
applyMcpCorsHeaders(req, res);
|
|
402
|
+
res.statusCode = 200;
|
|
403
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
404
|
+
const payload = toolsCalls.length === 1 ? responses[0] : responses;
|
|
405
|
+
res.end(JSON.stringify(payload));
|
|
406
|
+
}
|
|
407
|
+
async function getClient() {
|
|
408
|
+
if (!clientPromise) {
|
|
409
|
+
clientPromise = (async () => {
|
|
410
|
+
const { command, args } = resolveMcpServerCli();
|
|
411
|
+
const transport = new StdioClientTransport({
|
|
412
|
+
command,
|
|
413
|
+
args,
|
|
414
|
+
cwd: repoRoot,
|
|
415
|
+
env: currentProcessEnv(),
|
|
416
|
+
stderr: 'inherit'
|
|
417
|
+
});
|
|
418
|
+
const client = new Client({ name: 'sky-ui-post-endpoint', version: '1.0.0' }, { capabilities: {} });
|
|
419
|
+
await client.connect(transport);
|
|
420
|
+
return client;
|
|
421
|
+
})();
|
|
422
|
+
}
|
|
423
|
+
return clientPromise;
|
|
424
|
+
}
|
|
425
|
+
const app = Fastify({
|
|
426
|
+
logger: false,
|
|
427
|
+
requestTimeout: REQUEST_TIMEOUT_MS
|
|
428
|
+
});
|
|
429
|
+
function extractProvidedApiKey(headers) {
|
|
430
|
+
const explicit = headers['x-api-key'];
|
|
431
|
+
if (typeof explicit === 'string' && explicit.trim())
|
|
432
|
+
return explicit.trim();
|
|
433
|
+
const auth = headers.authorization;
|
|
434
|
+
if (typeof auth === 'string') {
|
|
435
|
+
const m = /^\s*Bearer\s+(.+?)\s*$/i.exec(auth);
|
|
436
|
+
if (m?.[1])
|
|
437
|
+
return m[1].trim();
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
442
|
+
req.skyMcpAuth = { mode: 'anonymous', userId: null };
|
|
443
|
+
if (LOG_REQUESTS) {
|
|
444
|
+
console.log(`[request:${req.id}] ${req.method} ${req.url}`);
|
|
445
|
+
}
|
|
446
|
+
// CORS preflight must not hit API key / IP allowlist (browser sends OPTIONS without your key).
|
|
447
|
+
if (req.method === 'OPTIONS') {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const pathOnly = requestPath(req.url);
|
|
451
|
+
if (ALLOW_IPS.length > 0 && pathOnly !== '/health') {
|
|
452
|
+
const ip = clientIp(req);
|
|
453
|
+
if (!ALLOW_IPS.includes(ip)) {
|
|
454
|
+
auditLog(ip, 403, null, '-');
|
|
455
|
+
return reply.code(403).send({
|
|
456
|
+
ok: false,
|
|
457
|
+
code: 'FORBIDDEN_IP',
|
|
458
|
+
message: 'Client IP not allowed.',
|
|
459
|
+
error: 'Client IP not allowed.'
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (!authIsRequired() || pathOnly === '/health') {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const provided = extractProvidedApiKey(req.headers);
|
|
467
|
+
if (!provided) {
|
|
468
|
+
auditLog(clientIp(req), 401, null, '-');
|
|
469
|
+
return reply.code(401).send({
|
|
470
|
+
ok: false,
|
|
471
|
+
code: 'UNAUTHORIZED',
|
|
472
|
+
message: 'Missing or invalid API key.',
|
|
473
|
+
error: 'Missing or invalid API key.'
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
const resolved = resolveProvidedApiKey(provided);
|
|
477
|
+
if (resolved) {
|
|
478
|
+
if (resolved.kind === 'legacy') {
|
|
479
|
+
req.skyMcpAuth = { mode: 'legacy', userId: null };
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
req.skyMcpAuth = { mode: 'user', userId: resolved.userId };
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const licenseUserId = await validateSkyAccessToken(provided);
|
|
487
|
+
if (licenseUserId) {
|
|
488
|
+
req.skyMcpAuth = { mode: 'user', userId: licenseUserId };
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
auditLog(clientIp(req), 401, null, '-');
|
|
492
|
+
return reply.code(401).send({
|
|
493
|
+
ok: false,
|
|
494
|
+
code: 'UNAUTHORIZED',
|
|
495
|
+
message: 'Missing or invalid API key.',
|
|
496
|
+
error: 'Missing or invalid API key.'
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
app.addHook('onResponse', async (req, reply) => {
|
|
500
|
+
if (LOG_REQUESTS) {
|
|
501
|
+
console.log(`[response:${req.id}] ${reply.statusCode} ${req.method} ${req.url}`);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
app.get('/health', async () => ({
|
|
505
|
+
ok: true,
|
|
506
|
+
mode: 'read-only',
|
|
507
|
+
endpoints: {
|
|
508
|
+
streamableHttp: '/',
|
|
509
|
+
legacyToolPost: '/tool'
|
|
510
|
+
},
|
|
511
|
+
transport: 'fastify',
|
|
512
|
+
authEnforced: REQUIRE_AUTH,
|
|
513
|
+
authRequired: authIsRequired(),
|
|
514
|
+
authMode: authModeLabel(),
|
|
515
|
+
userKeysLoaded: USER_API_KEYS.size,
|
|
516
|
+
ipAllowlistActive: ALLOW_IPS.length > 0,
|
|
517
|
+
corsAllowlistActive: CORS_ORIGINS.length > 0,
|
|
518
|
+
auditLogActive: AUDIT_LOG_PATH.length > 0
|
|
519
|
+
}));
|
|
520
|
+
app.post('/tool', {
|
|
521
|
+
schema: {
|
|
522
|
+
body: {
|
|
523
|
+
type: 'object',
|
|
524
|
+
additionalProperties: false,
|
|
525
|
+
required: ['tool'],
|
|
526
|
+
properties: {
|
|
527
|
+
tool: { type: 'string', minLength: 1 },
|
|
528
|
+
args: { type: 'object', additionalProperties: true }
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}, async (req, reply) => {
|
|
533
|
+
const ip = clientIp(req);
|
|
534
|
+
const tool = req.body.tool?.trim();
|
|
535
|
+
const args = req.body.args ?? {};
|
|
536
|
+
if (!tool) {
|
|
537
|
+
auditLog(ip, 400, null, userLabelForAudit(req));
|
|
538
|
+
return reply.code(400).send({
|
|
539
|
+
ok: false,
|
|
540
|
+
code: 'BAD_REQUEST',
|
|
541
|
+
message: 'Missing `tool` in request body.',
|
|
542
|
+
error: 'Missing `tool` in request body.'
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
if (!READ_ONLY_TOOLS.has(tool)) {
|
|
546
|
+
auditLog(ip, 403, tool, userLabelForAudit(req));
|
|
547
|
+
return reply.code(403).send({
|
|
548
|
+
ok: false,
|
|
549
|
+
code: 'TOOL_NOT_ALLOWED',
|
|
550
|
+
message: 'Tool is not allowed by read-only endpoint.',
|
|
551
|
+
error: 'Tool is not allowed by read-only endpoint.',
|
|
552
|
+
allowedTools: [...READ_ONLY_TOOLS].sort(),
|
|
553
|
+
hint: 'If you expected this tool to work, your POST server build is likely stale. Run `npm run build -w @sky.ui/mcp` (or `npm run build:mcp` from the monorepo root) and restart the POST process. Prefer `npm run serve:post -w @sky.ui/mcp` during development so the allowlist always matches source.'
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
const client = await getClient();
|
|
558
|
+
const result = await client.callTool({ name: tool, arguments: args });
|
|
559
|
+
const text = textFromContentBlocks(result.content);
|
|
560
|
+
let parsed = null;
|
|
561
|
+
if (text) {
|
|
562
|
+
try {
|
|
563
|
+
parsed = JSON.parse(text);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
parsed = text;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
auditLog(ip, 200, tool, userLabelForAudit(req));
|
|
570
|
+
// Tool JSON errors use { code, error, ... } from mcp-tool-errors.ts; `data` is the parsed body.
|
|
571
|
+
return reply.send({
|
|
572
|
+
ok: !result.isError,
|
|
573
|
+
tool,
|
|
574
|
+
data: parsed,
|
|
575
|
+
rawText: text,
|
|
576
|
+
isError: !!result.isError
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
581
|
+
auditLog(ip, 500, tool, userLabelForAudit(req));
|
|
582
|
+
return reply.code(500).send({
|
|
583
|
+
ok: false,
|
|
584
|
+
code: 'INTERNAL_ERROR',
|
|
585
|
+
message: msg,
|
|
586
|
+
error: msg
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
async function handleStreamablePost(req, res, parsedBody, sessionId) {
|
|
591
|
+
try {
|
|
592
|
+
let transport;
|
|
593
|
+
if (sessionId && streamableSessions[sessionId]) {
|
|
594
|
+
transport = streamableSessions[sessionId].transport;
|
|
595
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const toolsCalls = extractToolsCallRequests(parsedBody);
|
|
599
|
+
if (toolsCalls !== null && toolsCalls.length > 0) {
|
|
600
|
+
await executeStatelessToolsCallResponses(req, res, toolsCalls);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (!sessionId && bodyLooksLikeInitialize(parsedBody)) {
|
|
604
|
+
const eventStore = new InMemoryEventStore();
|
|
605
|
+
const mcpServer = createSkyUiMcpServer();
|
|
606
|
+
let boundSessionId;
|
|
607
|
+
transport = new StreamableHTTPServerTransport({
|
|
608
|
+
sessionIdGenerator: () => randomUUID(),
|
|
609
|
+
eventStore,
|
|
610
|
+
onsessioninitialized: (sid) => {
|
|
611
|
+
boundSessionId = sid;
|
|
612
|
+
if (transport) {
|
|
613
|
+
streamableSessions[sid] = { transport, server: mcpServer };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
// Drop session registry only — do not call mcpServer.close() here. Transport close
|
|
618
|
+
// already fired this handler; mcpServer.close() would call transport.close() again
|
|
619
|
+
// and recurse until stack overflow (see MCP SDK protocol.connect onclose wrapping).
|
|
620
|
+
transport.onclose = () => {
|
|
621
|
+
const sid = boundSessionId;
|
|
622
|
+
boundSessionId = undefined;
|
|
623
|
+
if (sid && streamableSessions[sid]) {
|
|
624
|
+
delete streamableSessions[sid];
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
await mcpServer.connect(transport);
|
|
628
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
applyMcpCorsHeaders(req, res);
|
|
632
|
+
res.statusCode = 400;
|
|
633
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
634
|
+
const hint = parsedBody === undefined
|
|
635
|
+
? 'Missing JSON body. First POST to / must be an MCP initialize JSON-RPC message, or send Mcp-Session-Id for an existing session.'
|
|
636
|
+
: 'No Mcp-Session-Id (or unknown session), and the body is not an MCP initialize request. Send initialize first, then reuse the session id from the response; or use POST /tool with { "tool", "args" } for simple tool calls without MCP session.';
|
|
637
|
+
res.end(JSON.stringify({
|
|
638
|
+
jsonrpc: '2.0',
|
|
639
|
+
error: {
|
|
640
|
+
code: -32000,
|
|
641
|
+
message: `Bad Request: No valid session ID provided. ${hint}`
|
|
642
|
+
},
|
|
643
|
+
id: null
|
|
644
|
+
}));
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
console.error('Streamable MCP POST error:', err);
|
|
648
|
+
if (!res.headersSent) {
|
|
649
|
+
applyMcpCorsHeaders(req, res);
|
|
650
|
+
res.statusCode = 500;
|
|
651
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
652
|
+
res.end(JSON.stringify({
|
|
653
|
+
jsonrpc: '2.0',
|
|
654
|
+
error: { code: -32603, message: 'Internal server error' },
|
|
655
|
+
id: null
|
|
656
|
+
}));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
async function handleStreamableGetDelete(req, res, sessionId, method) {
|
|
661
|
+
try {
|
|
662
|
+
if (!sessionId || !streamableSessions[sessionId]) {
|
|
663
|
+
applyMcpCorsHeaders(req, res);
|
|
664
|
+
res.statusCode = 400;
|
|
665
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
666
|
+
res.end('Invalid or missing session ID');
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const transport = streamableSessions[sessionId].transport;
|
|
670
|
+
await transport.handleRequest(req, res);
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
console.error(`Streamable MCP ${method} error:`, err);
|
|
674
|
+
if (!res.headersSent) {
|
|
675
|
+
applyMcpCorsHeaders(req, res);
|
|
676
|
+
res.statusCode = 500;
|
|
677
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
678
|
+
res.end(method === 'DELETE' ? 'Error processing session termination' : 'Internal server error');
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
app.options('/', async (request, reply) => {
|
|
683
|
+
const headers = corsHeadersForRequest(request.raw);
|
|
684
|
+
if (!headers) {
|
|
685
|
+
return reply.code(403).send({ ok: false, code: 'CORS_FORBIDDEN', message: 'Origin not allowed.' });
|
|
686
|
+
}
|
|
687
|
+
applyMcpCorsToFastifyReply(request, reply);
|
|
688
|
+
return reply.code(204).send();
|
|
689
|
+
});
|
|
690
|
+
app.route({
|
|
691
|
+
method: ['GET', 'POST', 'DELETE'],
|
|
692
|
+
url: '/',
|
|
693
|
+
handler: async (request, reply) => {
|
|
694
|
+
reply.hijack();
|
|
695
|
+
const rawReq = request.raw;
|
|
696
|
+
const rawRes = reply.raw;
|
|
697
|
+
applyMcpCorsHeaders(rawReq, rawRes);
|
|
698
|
+
const sessionHeader = request.headers['mcp-session-id'];
|
|
699
|
+
const sessionId = typeof sessionHeader === 'string' ? sessionHeader.trim() : undefined;
|
|
700
|
+
const method = request.method;
|
|
701
|
+
if (method === 'POST') {
|
|
702
|
+
await handleStreamablePost(rawReq, rawRes, request.body, sessionId);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (method === 'GET') {
|
|
706
|
+
await handleStreamableGetDelete(rawReq, rawRes, sessionId, 'GET');
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (method === 'DELETE') {
|
|
710
|
+
await handleStreamableGetDelete(rawReq, rawRes, sessionId, 'DELETE');
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
app.setNotFoundHandler(async (_req, reply) => {
|
|
715
|
+
return reply.code(404).send({
|
|
716
|
+
ok: false,
|
|
717
|
+
code: 'NOT_FOUND',
|
|
718
|
+
message: 'Not found',
|
|
719
|
+
error: 'Not found'
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
async function main() {
|
|
723
|
+
assertCoreDependencyAvailable();
|
|
724
|
+
await app.register(cors, {
|
|
725
|
+
origin: CORS_ORIGINS.length > 0 ? CORS_ORIGINS : true,
|
|
726
|
+
methods: ['GET', 'POST', 'DELETE', 'OPTIONS']
|
|
727
|
+
});
|
|
728
|
+
await app.register(rateLimit, {
|
|
729
|
+
max: RATE_LIMIT_MAX,
|
|
730
|
+
timeWindow: RATE_LIMIT_WINDOW,
|
|
731
|
+
allowList: (req) => req.method === 'OPTIONS',
|
|
732
|
+
keyGenerator: (req) => {
|
|
733
|
+
const a = req.skyMcpAuth;
|
|
734
|
+
if (a?.mode === 'user' && a.userId)
|
|
735
|
+
return `uid:${a.userId}`;
|
|
736
|
+
if (a?.mode === 'legacy')
|
|
737
|
+
return 'legacy-key';
|
|
738
|
+
return clientIp(req);
|
|
739
|
+
},
|
|
740
|
+
errorResponseBuilder: (_req, context) => ({
|
|
741
|
+
ok: false,
|
|
742
|
+
code: 'RATE_LIMITED',
|
|
743
|
+
message: 'Rate limit exceeded.',
|
|
744
|
+
error: 'Rate limit exceeded.',
|
|
745
|
+
details: {
|
|
746
|
+
max: context.max,
|
|
747
|
+
after: context.after
|
|
748
|
+
}
|
|
749
|
+
})
|
|
750
|
+
});
|
|
751
|
+
await app.listen({ port: PORT, host: '0.0.0.0' });
|
|
752
|
+
console.log(`Sky UI MCP POST endpoint listening on http://localhost:${PORT} (network: http://<your-lan-ip>:${PORT})`);
|
|
753
|
+
console.log('MCP Streamable HTTP: GET/POST/DELETE / (same origin as Cursor streamable_http)');
|
|
754
|
+
console.log('Browser UIs on another host/port: set SKY_UI_MCP_CORS_ORIGINS=http://YOUR_UI:PORT (comma-separated). Hijacked / uses manual CORS headers.');
|
|
755
|
+
console.log('Read-only tool endpoint: POST /tool (Fastify)');
|
|
756
|
+
if (REQUIRE_AUTH && authIsRequired()) {
|
|
757
|
+
console.log('Auth enforced: provide x-api-key or Authorization: Bearer <token>');
|
|
758
|
+
console.log(`Auth mode: ${authModeLabel()} (legacy key: ${LEGACY_KEY.length > 0 ? 'yes' : 'no'}, user keys: ${USER_API_KEYS.size})`);
|
|
759
|
+
}
|
|
760
|
+
else if (LEGACY_KEY.length > 0 || USER_API_KEYS.size > 0) {
|
|
761
|
+
console.log('API keys are configured but SKY_UI_MCP_REQUIRE_AUTH is not true — requests are not blocked. Set SKY_UI_MCP_REQUIRE_AUTH=true to enforce.');
|
|
762
|
+
}
|
|
763
|
+
if (CORS_ORIGINS.length) {
|
|
764
|
+
console.log(`CORS allowlist: ${CORS_ORIGINS.join(', ')}`);
|
|
765
|
+
}
|
|
766
|
+
if (ALLOW_IPS.length) {
|
|
767
|
+
console.log(`IP allowlist: ${ALLOW_IPS.join(', ')}`);
|
|
768
|
+
}
|
|
769
|
+
if (AUDIT_LOG_PATH) {
|
|
770
|
+
console.log(`Audit log: ${AUDIT_LOG_PATH}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
main().catch((err) => {
|
|
774
|
+
console.error(err);
|
|
775
|
+
process.exit(1);
|
|
776
|
+
});
|
|
777
|
+
//# sourceMappingURL=post-endpoint-server.js.map
|