@ty_krystal/sei-ai 0.1.4 → 0.1.5

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/dist/server.js ADDED
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
6
+ import { JSONRPCMessageSchema, isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
7
+ import { SERVER_NAME, SERVER_VERSION } from './constants.js';
8
+ import { createSeiClient } from './sei-client.js';
9
+ import { buildToolDefinitions } from './tools.js';
10
+ import { stringifySafe } from './utils.js';
11
+ export function createMcpServer(options) {
12
+ const client = options.client ?? createSeiClient(options.config, options.logger);
13
+ const runtime = {
14
+ config: options.config,
15
+ client,
16
+ logger: options.logger,
17
+ };
18
+ const server = new McpServer({
19
+ name: SERVER_NAME,
20
+ version: SERVER_VERSION,
21
+ });
22
+ for (const tool of buildToolDefinitions(options.config)) {
23
+ server.registerTool(tool.name, {
24
+ title: tool.title,
25
+ description: tool.description,
26
+ inputSchema: tool.inputSchema,
27
+ outputSchema: tool.outputSchema,
28
+ annotations: tool.annotations,
29
+ }, async (input) => {
30
+ options.logger.debug('MCP tool call', { toolName: tool.name });
31
+ const result = await tool.execute(input, runtime);
32
+ return {
33
+ content: [
34
+ {
35
+ type: 'text',
36
+ text: String(result.content_text ?? stringifySafe(result)),
37
+ },
38
+ ],
39
+ structuredContent: result,
40
+ };
41
+ });
42
+ }
43
+ return server;
44
+ }
45
+ export async function startStdioServer(server) {
46
+ const transport = new StdioServerTransport();
47
+ await server.connect(transport);
48
+ }
49
+ export async function startHttpServer(serverFactory, options, logger) {
50
+ const sessions = new Map();
51
+ const httpServer = createServer(async (req, res) => {
52
+ try {
53
+ if (!req.url) {
54
+ sendPlainText(res, 400, 'Bad Request');
55
+ return;
56
+ }
57
+ const requestUrl = new URL(req.url, `http://${req.headers.host ?? `${options.host}:${options.port}`}`);
58
+ if (normalizePath(requestUrl.pathname) !== normalizePath(options.path)) {
59
+ sendPlainText(res, 404, 'Not Found');
60
+ return;
61
+ }
62
+ if (req.method && !['GET', 'POST', 'DELETE'].includes(req.method.toUpperCase())) {
63
+ res.statusCode = 405;
64
+ res.setHeader('Allow', 'GET, POST, DELETE');
65
+ sendPlainText(res, 405, 'Method Not Allowed');
66
+ return;
67
+ }
68
+ const method = req.method?.toUpperCase();
69
+ const parsedBody = method === 'POST' ? await readJsonBody(req) : undefined;
70
+ const transport = await resolveTransport({
71
+ req,
72
+ parsedBody,
73
+ sessions,
74
+ logger,
75
+ enableJsonResponse: options.enableJsonResponse,
76
+ serverFactory,
77
+ });
78
+ if (!transport) {
79
+ sendJsonRpcError(res, 400, -32000, 'Bad Request: Mcp-Session-Id header is required');
80
+ return;
81
+ }
82
+ await transport.handleRequest(req, res, parsedBody);
83
+ if (method === 'POST' && isInitializePayload(parsedBody) && transport.sessionId === undefined) {
84
+ await transport.close();
85
+ }
86
+ }
87
+ catch (error) {
88
+ if (error instanceof SyntaxError) {
89
+ sendPlainText(res, 400, 'Invalid JSON body');
90
+ return;
91
+ }
92
+ if (error instanceof MissingSessionError) {
93
+ sendJsonRpcError(res, 404, -32001, error.message);
94
+ return;
95
+ }
96
+ logger.error('HTTP transport request failed', { error: stringifySafe(error) });
97
+ if (!res.headersSent) {
98
+ sendPlainText(res, 500, 'Internal Server Error');
99
+ }
100
+ }
101
+ });
102
+ await new Promise((resolve, reject) => {
103
+ httpServer.once('error', reject);
104
+ httpServer.listen(options.port, options.host, () => {
105
+ const address = httpServer.address();
106
+ const port = typeof address === 'object' && address ? address.port : options.port;
107
+ logger.info('SEI MCP HTTP server started', {
108
+ transport: 'streamable-http',
109
+ url: `http://${options.host}:${port}${options.path}`,
110
+ });
111
+ resolve();
112
+ });
113
+ });
114
+ let closed = false;
115
+ const shutdown = async () => {
116
+ if (closed) {
117
+ return;
118
+ }
119
+ closed = true;
120
+ await Promise.all(Array.from(sessions.values(), (transport) => transport.close()));
121
+ sessions.clear();
122
+ await closeHttpServer(httpServer);
123
+ };
124
+ process.once('SIGINT', () => {
125
+ void shutdown().catch((error) => logger.error('HTTP transport shutdown failed', { error: stringifySafe(error) }));
126
+ });
127
+ process.once('SIGTERM', () => {
128
+ void shutdown().catch((error) => logger.error('HTTP transport shutdown failed', { error: stringifySafe(error) }));
129
+ });
130
+ const address = httpServer.address();
131
+ const port = typeof address === 'object' && address ? address.port : options.port;
132
+ return {
133
+ url: new URL(`http://${options.host}:${port}${options.path}`),
134
+ close: shutdown,
135
+ };
136
+ }
137
+ async function resolveTransport(options) {
138
+ if (options.req.method?.toUpperCase() === 'POST' && isInitializePayload(options.parsedBody)) {
139
+ return createSessionTransport(options);
140
+ }
141
+ const sessionIdHeader = options.req.headers['mcp-session-id'];
142
+ const sessionId = typeof sessionIdHeader === 'string' ? sessionIdHeader : undefined;
143
+ if (options.req.method?.toUpperCase() === 'POST' && !sessionId) {
144
+ return createSessionTransport(options);
145
+ }
146
+ if (!sessionId) {
147
+ return undefined;
148
+ }
149
+ const transport = options.sessions.get(sessionId);
150
+ if (!transport) {
151
+ throw new MissingSessionError(sessionId);
152
+ }
153
+ return transport;
154
+ }
155
+ async function createSessionTransport(options) {
156
+ const transport = new StreamableHTTPServerTransport({
157
+ sessionIdGenerator: () => randomUUID(),
158
+ enableJsonResponse: options.enableJsonResponse,
159
+ onsessioninitialized: (sessionId) => {
160
+ options.sessions.set(sessionId, transport);
161
+ },
162
+ onsessionclosed: (sessionId) => {
163
+ if (sessionId) {
164
+ options.sessions.delete(sessionId);
165
+ }
166
+ },
167
+ });
168
+ transport.onerror = (error) => {
169
+ options.logger.error('HTTP transport request failed', { error: stringifySafe(error) });
170
+ };
171
+ transport.onclose = () => {
172
+ if (transport.sessionId) {
173
+ options.sessions.delete(transport.sessionId);
174
+ }
175
+ };
176
+ const server = options.serverFactory();
177
+ await server.connect(transport);
178
+ return transport;
179
+ }
180
+ function isInitializePayload(payload) {
181
+ if (payload === undefined) {
182
+ return false;
183
+ }
184
+ const messages = Array.isArray(payload) ? payload : [payload];
185
+ return messages.some((message) => {
186
+ const parsed = JSONRPCMessageSchema.safeParse(message);
187
+ return parsed.success && isInitializeRequest(parsed.data);
188
+ });
189
+ }
190
+ async function readJsonBody(req) {
191
+ const chunks = [];
192
+ for await (const chunk of req) {
193
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
194
+ }
195
+ if (chunks.length === 0) {
196
+ return undefined;
197
+ }
198
+ const text = Buffer.concat(chunks).toString('utf8').trim();
199
+ if (!text) {
200
+ return undefined;
201
+ }
202
+ return JSON.parse(text);
203
+ }
204
+ function sendPlainText(res, statusCode, text) {
205
+ res.statusCode = statusCode;
206
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
207
+ res.end(text);
208
+ }
209
+ function sendJsonRpcError(res, statusCode, code, message) {
210
+ res.statusCode = statusCode;
211
+ res.setHeader('Content-Type', 'application/json');
212
+ res.end(JSON.stringify({
213
+ jsonrpc: '2.0',
214
+ error: { code, message },
215
+ id: null,
216
+ }));
217
+ }
218
+ function normalizePath(path) {
219
+ const raw = path.trim();
220
+ if (!raw) {
221
+ return '/mcp';
222
+ }
223
+ const normalized = raw.startsWith('/') ? raw : `/${raw}`;
224
+ return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
225
+ }
226
+ async function closeHttpServer(server) {
227
+ await new Promise((resolve) => {
228
+ server.close(() => resolve());
229
+ });
230
+ }
231
+ class MissingSessionError extends Error {
232
+ sessionId;
233
+ constructor(sessionId) {
234
+ super('Session not found');
235
+ this.sessionId = sessionId;
236
+ }
237
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,175 @@
1
+ import {} from 'zod';
2
+ import { MAX_RESPONSE_TEXT } from './constants.js';
3
+ import { SeiMcpError } from './errors.js';
4
+ import { authorizeQuery, authorizeQuerySql, authorizeSave, resolveTarget } from './permissions.js';
5
+ import { stringifySafe, truncateText } from './utils.js';
6
+ import { QueryInputSchema, QueryOutputSchema, QuerySqlInputSchema, QuerySqlOutputSchema, SaveInputSchema, SaveOutputSchema, SchemaInputSchema, SchemaOutputSchema, } from './schema.js';
7
+ export function buildToolDefinitions(config) {
8
+ const definitions = [];
9
+ if (config.tools.query) {
10
+ definitions.push({
11
+ name: 'sei_query',
12
+ title: 'SEI Query',
13
+ description: 'Run an approved SEI low-code query within the local whitelist.',
14
+ inputSchema: QueryInputSchema,
15
+ outputSchema: QueryOutputSchema,
16
+ annotations: toolAnnotations(true, false, true),
17
+ execute: async (input, runtime) => runQueryTool(input, runtime),
18
+ });
19
+ }
20
+ if (config.tools.save) {
21
+ definitions.push({
22
+ name: 'sei_save',
23
+ title: 'SEI Save',
24
+ description: 'Run an approved SEI low-code save action within the local whitelist.',
25
+ inputSchema: SaveInputSchema,
26
+ outputSchema: SaveOutputSchema,
27
+ annotations: toolAnnotations(false, true, false),
28
+ execute: async (input, runtime) => runSaveTool(input, runtime),
29
+ });
30
+ }
31
+ if (config.tools.schema) {
32
+ definitions.push({
33
+ name: 'sei_schema',
34
+ title: 'SEI Schema',
35
+ description: 'Inspect approved table or module fields without exposing the full database.',
36
+ inputSchema: SchemaInputSchema,
37
+ outputSchema: SchemaOutputSchema,
38
+ annotations: toolAnnotations(true, false, true),
39
+ execute: async (input, runtime) => runSchemaTool(input, runtime),
40
+ });
41
+ }
42
+ if (config.tools.querySql) {
43
+ definitions.push({
44
+ name: 'sei_query_sql',
45
+ title: 'SEI Query SQL',
46
+ description: 'Run a restricted SELECT query against approved targets only.',
47
+ inputSchema: QuerySqlInputSchema,
48
+ outputSchema: QuerySqlOutputSchema,
49
+ annotations: toolAnnotations(true, false, true),
50
+ execute: async (input, runtime) => runQuerySqlTool(input, runtime),
51
+ });
52
+ }
53
+ return definitions;
54
+ }
55
+ export function buildToolCatalog(config) {
56
+ return buildToolDefinitions(config).map((tool) => ({
57
+ name: tool.name,
58
+ title: tool.title,
59
+ description: tool.description,
60
+ inputSchema: tool.inputSchema,
61
+ outputSchema: tool.outputSchema,
62
+ annotations: tool.annotations,
63
+ }));
64
+ }
65
+ export async function runQueryTool(input, runtime) {
66
+ const parsed = QueryInputSchema.parse(input);
67
+ const payload = authorizeQuery(runtime.config, parsed);
68
+ const target = getTarget(payload);
69
+ const response = await runtime.client.query(payload);
70
+ const result = makeResultEnvelope('sei_query', parsed.option.response_format, 'Query completed.', payload, response, target);
71
+ return QueryOutputSchema.parse(result);
72
+ }
73
+ export async function runSaveTool(input, runtime) {
74
+ const parsed = SaveInputSchema.parse(input);
75
+ const payload = authorizeSave(runtime.config, parsed);
76
+ const target = getTarget(payload);
77
+ const response = await runtime.client.save(payload);
78
+ const result = makeResultEnvelope('sei_save', parsed.response_format, 'Save completed.', payload, response, target);
79
+ return SaveOutputSchema.parse(result);
80
+ }
81
+ export async function runSchemaTool(input, runtime) {
82
+ const parsed = SchemaInputSchema.parse(input);
83
+ const head = parsed.head;
84
+ const target = resolveTarget(runtime.config, head);
85
+ const tableName = resolvePhysicalTableName(head, target.target);
86
+ const response = await runtime.client.describeTable(tableName);
87
+ const result = makeResultEnvelope('sei_schema', parsed.response_format, 'Schema lookup completed.', {
88
+ head,
89
+ tableName,
90
+ target,
91
+ }, response, {
92
+ kind: target.kind,
93
+ name: target.name,
94
+ });
95
+ return SchemaOutputSchema.parse(result);
96
+ }
97
+ export async function runQuerySqlTool(input, runtime) {
98
+ const parsed = QuerySqlInputSchema.parse(input);
99
+ const payload = authorizeQuerySql(runtime.config, parsed);
100
+ const response = await runtime.client.querySql(payload);
101
+ const result = makeResultEnvelope('sei_query_sql', parsed.response_format, 'SQL query completed.', payload, response);
102
+ return QuerySqlOutputSchema.parse(result);
103
+ }
104
+ export function resolvePhysicalTableName(head, target) {
105
+ if (typeof head.table === 'string' && head.table.trim()) {
106
+ return head.table.trim();
107
+ }
108
+ if (typeof head.view === 'string' && head.view.trim()) {
109
+ return head.view.trim();
110
+ }
111
+ if (typeof target.mainTable === 'string' && target.mainTable.trim()) {
112
+ return target.mainTable.trim();
113
+ }
114
+ throw new SeiMcpError('schema 工具需要 mainTable 或显式 table/view', 'INVALID_PARAMS');
115
+ }
116
+ function toolAnnotations(readOnlyHint, destructiveHint, idempotentHint) {
117
+ return {
118
+ readOnlyHint,
119
+ destructiveHint,
120
+ idempotentHint,
121
+ openWorldHint: true,
122
+ };
123
+ }
124
+ function makeResultEnvelope(tool, responseFormat, summary, request, response, target) {
125
+ const payload = {
126
+ tool,
127
+ response_format: responseFormat,
128
+ summary,
129
+ request,
130
+ response,
131
+ generated_at: new Date().toISOString(),
132
+ ...(target ? { target } : {}),
133
+ };
134
+ return {
135
+ ...payload,
136
+ content_text: responseFormat === 'json' ? stringifySafe(payload) : formatMarkdownPayload(payload),
137
+ };
138
+ }
139
+ function getTarget(payload) {
140
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
141
+ throw new SeiMcpError('目标信息缺失', 'INVALID_PARAMS');
142
+ }
143
+ const target = payload._target;
144
+ if (!target || typeof target !== 'object' || Array.isArray(target)) {
145
+ throw new SeiMcpError('目标信息缺失', 'INVALID_PARAMS');
146
+ }
147
+ const kind = target.kind;
148
+ const name = target.name;
149
+ if ((kind === 'module' || kind === 'source' || kind === 'table' || kind === 'view') &&
150
+ typeof name === 'string' &&
151
+ name.trim()) {
152
+ return { kind, name: name.trim() };
153
+ }
154
+ throw new SeiMcpError('目标信息缺失', 'INVALID_PARAMS');
155
+ }
156
+ function formatMarkdownPayload(payload) {
157
+ const lines = [
158
+ `# ${String(payload.tool).toUpperCase()}`,
159
+ '',
160
+ `- Summary: ${String(payload.summary)}`,
161
+ `- Format: ${String(payload.response_format)}`,
162
+ `- Generated: ${String(payload.generated_at)}`,
163
+ '',
164
+ '## Request',
165
+ '```json',
166
+ truncateText(stringifySafe(payload.request), MAX_RESPONSE_TEXT),
167
+ '```',
168
+ '',
169
+ '## Response',
170
+ '```json',
171
+ truncateText(stringifySafe(payload.response), MAX_RESPONSE_TEXT),
172
+ '```',
173
+ ];
174
+ return lines.join('\n');
175
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/utils.js ADDED
@@ -0,0 +1,53 @@
1
+ export function isObject(value) {
2
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+ export function toTrimmedString(value) {
5
+ return typeof value === 'string' ? value.trim() : '';
6
+ }
7
+ export function stringifySafe(value) {
8
+ try {
9
+ return JSON.stringify(value, null, 2);
10
+ }
11
+ catch {
12
+ return String(value);
13
+ }
14
+ }
15
+ export function truncateText(value, limit) {
16
+ const text = String(value ?? '');
17
+ return text.length > limit ? `${text.slice(0, limit)}...` : text;
18
+ }
19
+ export function uniqueStrings(values) {
20
+ return [...new Set([...values].map((value) => value.trim()).filter(Boolean))];
21
+ }
22
+ export function normalizeBaseUrl(value) {
23
+ const text = toTrimmedString(value);
24
+ if (!text) {
25
+ throw new Error('base URL 不能为空');
26
+ }
27
+ return text.replace(/\/+$/, '');
28
+ }
29
+ export function normalizePath(value) {
30
+ const text = toTrimmedString(value);
31
+ if (!text) {
32
+ return '/';
33
+ }
34
+ if (text.startsWith('http://') || text.startsWith('https://')) {
35
+ try {
36
+ return new URL(text).pathname || '/';
37
+ }
38
+ catch {
39
+ return '/';
40
+ }
41
+ }
42
+ return text.startsWith('/') ? text : `/${text}`;
43
+ }
44
+ export function makeUrl(baseUrl, path) {
45
+ if (path.startsWith('http://') || path.startsWith('https://')) {
46
+ return path;
47
+ }
48
+ return `${normalizeBaseUrl(baseUrl)}${normalizePath(path)}`;
49
+ }
50
+ export function toPositiveInteger(value, fallback) {
51
+ const parsed = Number(value);
52
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ty_krystal/sei-ai",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "SEI MCP server and developer CLI for query, save, query-sql, and api-docs workflows",
5
5
  "keywords": [
6
6
  "sei",
@@ -26,6 +26,17 @@
26
26
  "bin": {
27
27
  "sei-ai": "dist/index.js"
28
28
  },
29
+ "oclif": {
30
+ "bin": "sei-ai",
31
+ "dirname": "sei-ai",
32
+ "commands": {
33
+ "strategy": "pattern",
34
+ "target": "./dist/commands",
35
+ "globPatterns": [
36
+ "**/*.js"
37
+ ]
38
+ }
39
+ },
29
40
  "main": "./dist/index.js",
30
41
  "exports": {
31
42
  ".": {
@@ -37,7 +48,7 @@
37
48
  "start": "node dist/index.js",
38
49
  "start:stdio": "node dist/index.js stdio --config ./config.example.json",
39
50
  "start:http": "node dist/index.js streamable-http --host 0.0.0.0 --port 3000 --path /mcp --config ./config.example.json",
40
- "build": "pnpm vite build",
51
+ "build": "tsc -p tsconfig.build.json && node ./scripts/copy-build-assets.mjs",
41
52
  "test": "node --import tsx --test test/**/*.test.ts",
42
53
  "check": "pnpm run typecheck && pnpm run test",
43
54
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -51,6 +62,7 @@
51
62
  },
52
63
  "dependencies": {
53
64
  "@modelcontextprotocol/sdk": "^1.29.0",
65
+ "@oclif/core": "^4.11.10",
54
66
  "tsx": "^4.22.4",
55
67
  "zod": "^3.25.76"
56
68
  }