suemo 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ import type { Surreal } from 'surrealdb'
2
+ import { z } from 'zod'
3
+ import { consolidate } from '../cognitive/consolidate.ts'
4
+ import { healthReport, vitals } from '../cognitive/health.ts'
5
+ import type { SuemoConfig } from '../config.ts'
6
+ import { goalList, goalResolve, goalSet } from '../goal.ts'
7
+ import { getLogger } from '../logger.ts'
8
+ import { episodeEnd, episodeStart } from '../memory/episode.ts'
9
+ import { query, recall, timeline, wander } from '../memory/read.ts'
10
+ import { believe, invalidate, observe } from '../memory/write.ts'
11
+ import { ObserveInputSchema, QueryInputSchema } from '../types.ts'
12
+
13
+ const log = getLogger(['suemo', 'mcp', 'dispatch'])
14
+
15
+ export async function handleToolCall(
16
+ db: Surreal,
17
+ config: SuemoConfig,
18
+ method: string,
19
+ params: Record<string, unknown>,
20
+ ): Promise<unknown> {
21
+ log.debug('Dispatching MCP tool call', { method, paramKeys: Object.keys(params) })
22
+
23
+ switch (method) {
24
+ case 'observe':
25
+ return observe(db, ObserveInputSchema.parse(params), config)
26
+
27
+ case 'believe': {
28
+ const parsed = z
29
+ .object({
30
+ content: z.string(),
31
+ scope: z.string().optional(),
32
+ confidence: z.number().optional(),
33
+ })
34
+ .parse(params)
35
+ return believe(db, parsed, config)
36
+ }
37
+
38
+ case 'invalidate': {
39
+ const parsed = z.object({ nodeId: z.string(), reason: z.string().optional() }).parse(params)
40
+ await invalidate(db, parsed.nodeId, parsed.reason)
41
+ return { ok: true }
42
+ }
43
+
44
+ case 'query':
45
+ return query(db, QueryInputSchema.parse(params), config)
46
+
47
+ case 'recall': {
48
+ const parsed = z.object({ nodeId: z.string() }).parse(params)
49
+ return recall(db, parsed.nodeId)
50
+ }
51
+
52
+ case 'wander': {
53
+ const parsed = z
54
+ .object({ anchor: z.string().optional(), hops: z.number().optional(), scope: z.string().optional() })
55
+ .parse(params)
56
+ return wander(db, {
57
+ ...(parsed.anchor ? { anchor: parsed.anchor } : {}),
58
+ ...(parsed.hops ? { hops: parsed.hops } : {}),
59
+ ...(parsed.scope ? { scope: parsed.scope } : {}),
60
+ })
61
+ }
62
+
63
+ case 'timeline': {
64
+ const parsed = z
65
+ .object({
66
+ scope: z.string().optional(),
67
+ from: z.string().optional(),
68
+ until: z.string().optional(),
69
+ limit: z.number().optional(),
70
+ })
71
+ .parse(params)
72
+ return timeline(db, {
73
+ ...(parsed.scope ? { scope: parsed.scope } : {}),
74
+ ...(parsed.from ? { from: parsed.from } : {}),
75
+ ...(parsed.until ? { until: parsed.until } : {}),
76
+ ...(parsed.limit ? { limit: parsed.limit } : {}),
77
+ })
78
+ }
79
+
80
+ case 'episode_start': {
81
+ const parsed = z.object({ sessionId: z.string() }).parse(params)
82
+ return episodeStart(db, parsed.sessionId)
83
+ }
84
+
85
+ case 'episode_end': {
86
+ const parsed = z.object({ sessionId: z.string(), summary: z.string().optional() }).parse(params)
87
+ return episodeEnd(db, parsed.sessionId, parsed.summary)
88
+ }
89
+
90
+ case 'goal_set': {
91
+ const parsed = z
92
+ .object({ content: z.string(), scope: z.string().optional(), tags: z.array(z.string()).optional() })
93
+ .parse(params)
94
+ return goalSet(db, parsed.content, config, {
95
+ ...(parsed.scope ? { scope: parsed.scope } : {}),
96
+ tags: parsed.tags ?? [],
97
+ })
98
+ }
99
+
100
+ case 'goal_resolve': {
101
+ const parsed = z.object({ goalId: z.string() }).parse(params)
102
+ await goalResolve(db, parsed.goalId)
103
+ return { ok: true }
104
+ }
105
+
106
+ case 'goal_list': {
107
+ const parsed = z.object({ scope: z.string().optional(), includeResolved: z.boolean().optional() }).parse(params)
108
+ return goalList(db, {
109
+ ...(parsed.scope ? { scope: parsed.scope } : {}),
110
+ includeResolved: parsed.includeResolved ?? false,
111
+ })
112
+ }
113
+
114
+ case 'health':
115
+ return healthReport(db)
116
+
117
+ case 'vitals':
118
+ return vitals(db)
119
+
120
+ case 'consolidate': {
121
+ const parsed = z.object({ nremOnly: z.boolean().optional() }).parse(params)
122
+ return consolidate(db, {
123
+ nremOnly: parsed.nremOnly ?? false,
124
+ nremSimilarityThreshold: config.consolidation.nremSimilarityThreshold,
125
+ remRelationThreshold: config.consolidation.remRelationThreshold,
126
+ llm: config.consolidation.llm,
127
+ embedding: config.embedding,
128
+ })
129
+ }
130
+
131
+ default:
132
+ throw new Error(`Unknown MCP tool: ${method}`)
133
+ }
134
+ }
package/src/mcp/server.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // src/mcp/server.ts
2
2
  import { Elysia } from 'elysia'
3
3
  import type { SuemoConfig } from '../config.ts'
4
- import { connect } from '../db/client.ts'
5
- import { requireCompatibility } from '../db/preflight.ts'
4
+ import { connect, disconnect } from '../db/client.ts'
5
+ import { checkCompatibility, requireCompatibility } from '../db/preflight.ts'
6
6
  import { runSchema } from '../db/schema.ts'
7
7
  import { getLogger } from '../logger.ts'
8
+ import { runStdioServer } from './stdio.ts'
8
9
  import { buildMcpRouter } from './tools.ts'
9
10
 
10
11
  const log = getLogger(['suemo', 'mcp'])
@@ -21,3 +22,25 @@ export async function startMcpServer(config: SuemoConfig): Promise<void> {
21
22
 
22
23
  log.info('MCP server listening', { port: config.mcp.port, host: config.mcp.host })
23
24
  }
25
+
26
+ export async function startMcpStdioServer(config: SuemoConfig): Promise<void> {
27
+ const db = await connect(config.surreal)
28
+ try {
29
+ const compat = await checkCompatibility(db, {
30
+ requireEmbedding: config.embedding.provider === 'surreal',
31
+ context: 'mcp:stdio-startup',
32
+ })
33
+ if (!compat.ok) {
34
+ console.error('\n[suemo] Compatibility check failed:\n')
35
+ for (const err of compat.errors) {
36
+ console.error(` ✗ ${err}`)
37
+ }
38
+ console.error('\nFix the issues above and retry.\n')
39
+ process.exit(1)
40
+ }
41
+ await runSchema(db)
42
+ await runStdioServer(db, config)
43
+ } finally {
44
+ await disconnect()
45
+ }
46
+ }
@@ -0,0 +1,314 @@
1
+ import { createInterface } from 'node:readline'
2
+ import type { Surreal } from 'surrealdb'
3
+ import type { SuemoConfig } from '../config.ts'
4
+ import { getLogger } from '../logger.ts'
5
+ import { handleToolCall } from './dispatch.ts'
6
+
7
+ const log = getLogger(['suemo', 'mcp', 'stdio'])
8
+
9
+ interface JsonRpcRequest {
10
+ jsonrpc?: string
11
+ id?: unknown
12
+ method?: unknown
13
+ params?: unknown
14
+ }
15
+
16
+ interface McpToolDefinition {
17
+ name: string
18
+ description: string
19
+ inputSchema: Record<string, unknown>
20
+ }
21
+
22
+ const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2024-11-05', '2025-03-26'])
23
+ const DEFAULT_PROTOCOL_VERSION = '2024-11-05'
24
+
25
+ const MCP_TOOLS: McpToolDefinition[] = [
26
+ {
27
+ name: 'observe',
28
+ description: 'Store an observation/belief/question/hypothesis/goal memory node',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ content: { type: 'string' },
33
+ kind: { type: 'string' },
34
+ tags: { type: 'array', items: { type: 'string' } },
35
+ scope: { type: 'string' },
36
+ source: { type: 'string' },
37
+ confidence: { type: 'number' },
38
+ },
39
+ required: ['content'],
40
+ },
41
+ },
42
+ {
43
+ name: 'believe',
44
+ description: 'Store a belief and run contradiction detection',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ content: { type: 'string' },
49
+ scope: { type: 'string' },
50
+ confidence: { type: 'number' },
51
+ },
52
+ required: ['content'],
53
+ },
54
+ },
55
+ {
56
+ name: 'invalidate',
57
+ description: 'Invalidate a memory node (soft-delete via valid_until)',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ nodeId: { type: 'string' },
62
+ reason: { type: 'string' },
63
+ },
64
+ required: ['nodeId'],
65
+ },
66
+ },
67
+ {
68
+ name: 'query',
69
+ description: 'Hybrid semantic retrieval over memory nodes',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ input: { type: 'string' },
74
+ scope: { type: 'string' },
75
+ kind: { type: 'array', items: { type: 'string' } },
76
+ topK: { type: 'number' },
77
+ activeOnly: { type: 'boolean' },
78
+ strategies: { type: 'array', items: { type: 'string' } },
79
+ },
80
+ required: ['input'],
81
+ },
82
+ },
83
+ {
84
+ name: 'recall',
85
+ description: 'Fetch one node and neighbors; tick review schedule',
86
+ inputSchema: {
87
+ type: 'object',
88
+ properties: { nodeId: { type: 'string' } },
89
+ required: ['nodeId'],
90
+ },
91
+ },
92
+ {
93
+ name: 'wander',
94
+ description: 'Traverse graph via spreading activation',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ anchor: { type: 'string' },
99
+ hops: { type: 'number' },
100
+ scope: { type: 'string' },
101
+ },
102
+ },
103
+ },
104
+ {
105
+ name: 'timeline',
106
+ description: 'Get chronological memory slice',
107
+ inputSchema: {
108
+ type: 'object',
109
+ properties: {
110
+ scope: { type: 'string' },
111
+ from: { type: 'string' },
112
+ until: { type: 'string' },
113
+ limit: { type: 'number' },
114
+ },
115
+ },
116
+ },
117
+ {
118
+ name: 'episode_start',
119
+ description: 'Start an episode for a session ID',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: { sessionId: { type: 'string' } },
123
+ required: ['sessionId'],
124
+ },
125
+ },
126
+ {
127
+ name: 'episode_end',
128
+ description: 'End an episode for a session ID',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ sessionId: { type: 'string' },
133
+ summary: { type: 'string' },
134
+ },
135
+ required: ['sessionId'],
136
+ },
137
+ },
138
+ {
139
+ name: 'goal_set',
140
+ description: 'Create goal node',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ content: { type: 'string' },
145
+ scope: { type: 'string' },
146
+ tags: { type: 'array', items: { type: 'string' } },
147
+ },
148
+ required: ['content'],
149
+ },
150
+ },
151
+ {
152
+ name: 'goal_resolve',
153
+ description: 'Resolve a goal by ID',
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: { goalId: { type: 'string' } },
157
+ required: ['goalId'],
158
+ },
159
+ },
160
+ {
161
+ name: 'goal_list',
162
+ description: 'List goals',
163
+ inputSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ scope: { type: 'string' },
167
+ includeResolved: { type: 'boolean' },
168
+ },
169
+ },
170
+ },
171
+ {
172
+ name: 'health',
173
+ description: 'Get health report',
174
+ inputSchema: { type: 'object', properties: {} },
175
+ },
176
+ {
177
+ name: 'vitals',
178
+ description: 'Get health vitals summary',
179
+ inputSchema: { type: 'object', properties: {} },
180
+ },
181
+ {
182
+ name: 'consolidate',
183
+ description: 'Trigger memory consolidation',
184
+ inputSchema: {
185
+ type: 'object',
186
+ properties: { nremOnly: { type: 'boolean' } },
187
+ },
188
+ },
189
+ ]
190
+
191
+ export async function runStdioServer(db: Surreal, config: SuemoConfig): Promise<void> {
192
+ log.info('MCP stdio transport started')
193
+
194
+ const rl = createInterface({ input: process.stdin, terminal: false })
195
+
196
+ try {
197
+ for await (const line of rl) {
198
+ if (!line.trim()) continue
199
+
200
+ let request: JsonRpcRequest
201
+ try {
202
+ request = JSON.parse(line) as JsonRpcRequest
203
+ } catch {
204
+ writeJsonRpc({
205
+ jsonrpc: '2.0',
206
+ id: null,
207
+ error: { code: -32700, message: 'Parse error' },
208
+ })
209
+ continue
210
+ }
211
+
212
+ const id = request.id ?? null
213
+ const method = typeof request.method === 'string' ? request.method : null
214
+ const params = request.params && typeof request.params === 'object' && !Array.isArray(request.params)
215
+ ? request.params as Record<string, unknown>
216
+ : {}
217
+
218
+ if (!method) {
219
+ writeJsonRpc({
220
+ jsonrpc: '2.0',
221
+ id,
222
+ error: { code: -32600, message: 'Invalid Request: method must be a string' },
223
+ })
224
+ continue
225
+ }
226
+
227
+ log.debug('Received stdio MCP request', { method })
228
+
229
+ try {
230
+ const result = await handleRpcMethod(db, config, method, params)
231
+ if (id === null) continue
232
+ writeJsonRpc({ jsonrpc: '2.0', id, result })
233
+ } catch (error) {
234
+ if (id === null) continue
235
+ writeJsonRpc({
236
+ jsonrpc: '2.0',
237
+ id,
238
+ error: { code: -32603, message: String(error) },
239
+ })
240
+ }
241
+ }
242
+ } finally {
243
+ rl.close()
244
+ }
245
+
246
+ log.info('stdin closed — stdio server exiting')
247
+ }
248
+
249
+ async function handleRpcMethod(
250
+ db: Surreal,
251
+ config: SuemoConfig,
252
+ method: string,
253
+ params: Record<string, unknown>,
254
+ ): Promise<unknown> {
255
+ if (method === 'initialize' || method === 'initiaize') {
256
+ const requestedVersion = typeof params.protocolVersion === 'string'
257
+ ? params.protocolVersion
258
+ : DEFAULT_PROTOCOL_VERSION
259
+ const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(requestedVersion)
260
+ ? requestedVersion
261
+ : DEFAULT_PROTOCOL_VERSION
262
+
263
+ return {
264
+ protocolVersion,
265
+ capabilities: {
266
+ tools: { listChanged: false },
267
+ },
268
+ serverInfo: {
269
+ name: 'suemo',
270
+ version: '0.0.1',
271
+ },
272
+ }
273
+ }
274
+
275
+ if (method === 'notifications/initialized') {
276
+ return {}
277
+ }
278
+
279
+ if (method === 'ping') {
280
+ return {}
281
+ }
282
+
283
+ if (method === 'tools/list') {
284
+ return { tools: MCP_TOOLS }
285
+ }
286
+
287
+ if (method === 'tools/call') {
288
+ const name = typeof params.name === 'string' ? params.name : null
289
+ if (!name) {
290
+ throw new Error('Invalid tools/call params: missing string field `name`')
291
+ }
292
+
293
+ const toolArgs = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)
294
+ ? params.arguments as Record<string, unknown>
295
+ : {}
296
+
297
+ const toolResult = await handleToolCall(db, config, name, toolArgs)
298
+ return {
299
+ content: [{ type: 'text', text: JSON.stringify(toolResult, null, 2) }],
300
+ structuredContent: toolResult,
301
+ }
302
+ }
303
+
304
+ // Backward-compatible direct tool method invocation.
305
+ if (MCP_TOOLS.some((t) => t.name === method)) {
306
+ return handleToolCall(db, config, method, params)
307
+ }
308
+
309
+ throw new Error(`Unknown MCP method: ${method}`)
310
+ }
311
+
312
+ function writeJsonRpc(payload: unknown): void {
313
+ process.stdout.write(`${JSON.stringify(payload)}\n`)
314
+ }
package/src/mcp/tools.ts CHANGED
@@ -1,100 +1,19 @@
1
1
  // src/mcp/tools.ts
2
- // Each tool: parse input with Zod → call domain fn → return result.
3
- // No business logic here. SSoT lives in domain modules.
2
+ // HTTP transport adapter for MCP calls.
4
3
 
5
4
  import type { Elysia } from 'elysia'
6
5
  import type { Surreal } from 'surrealdb'
7
- import { z } from 'zod'
8
- import { consolidate } from '../cognitive/consolidate.ts'
9
- import { healthReport, vitals } from '../cognitive/health.ts'
10
6
  import type { SuemoConfig } from '../config.ts'
11
- import { goalList, goalResolve, goalSet } from '../goal.ts'
12
- import { episodeEnd, episodeStart } from '../memory/episode.ts'
13
- import { query, recall, timeline, wander } from '../memory/read.ts'
14
- import { believe, invalidate, observe } from '../memory/write.ts'
15
- import { ObserveInputSchema, QueryInputSchema } from '../types.ts'
7
+ import { handleToolCall } from './dispatch.ts'
16
8
 
17
9
  export function buildMcpRouter(db: Surreal, config: SuemoConfig) {
18
- // Returns an Elysia plugin that adds all /mcp/* routes.
19
- // Each route maps 1:1 to a domain function.
10
+ // Elysia plugin: route-level transport, shared dispatch handles behavior.
20
11
  return (app: Elysia) =>
21
12
  app
22
- .post('/mcp/observe', async ({ body }) => observe(db, ObserveInputSchema.parse(body)))
23
- .post('/mcp/believe', async ({ body }) => {
24
- const p = z.object({ content: z.string(), scope: z.string().optional(), confidence: z.number().optional() })
25
- .parse(body)
26
- return believe(db, p)
27
- })
28
- .post('/mcp/invalidate', async ({ body }) => {
29
- const p = z.object({ nodeId: z.string(), reason: z.string().optional() }).parse(body)
30
- await invalidate(db, p.nodeId, p.reason)
31
- return { ok: true }
32
- })
33
- .post('/mcp/query', async ({ body }) => query(db, QueryInputSchema.parse(body)))
34
- .post('/mcp/recall', async ({ body }) => {
35
- const p = z.object({ nodeId: z.string() }).parse(body)
36
- return recall(db, p.nodeId)
37
- })
38
- .post('/mcp/wander', async ({ body }) => {
39
- const p = z.object({ anchor: z.string().optional(), hops: z.number().optional(), scope: z.string().optional() })
40
- .parse(body)
41
- return wander(db, {
42
- ...(p.anchor ? { anchor: p.anchor } : {}),
43
- ...(p.hops ? { hops: p.hops } : {}),
44
- ...(p.scope ? { scope: p.scope } : {}),
45
- })
46
- })
47
- .post('/mcp/timeline', async ({ body }) => {
48
- const p = z.object({
49
- scope: z.string().optional(),
50
- from: z.string().optional(),
51
- until: z.string().optional(),
52
- limit: z.number().optional(),
53
- }).parse(body)
54
- return timeline(db, {
55
- ...(p.scope ? { scope: p.scope } : {}),
56
- ...(p.from ? { from: p.from } : {}),
57
- ...(p.until ? { until: p.until } : {}),
58
- ...(p.limit ? { limit: p.limit } : {}),
59
- })
60
- })
61
- .post('/mcp/episode_start', async ({ body }) => {
62
- const p = z.object({ sessionId: z.string() }).parse(body)
63
- return episodeStart(db, p.sessionId)
64
- })
65
- .post('/mcp/episode_end', async ({ body }) => {
66
- const p = z.object({ sessionId: z.string(), summary: z.string().optional() }).parse(body)
67
- return episodeEnd(db, p.sessionId, p.summary)
68
- })
69
- .post('/mcp/goal_set', async ({ body }) => {
70
- const p = z.object({ content: z.string(), scope: z.string().optional(), tags: z.array(z.string()).optional() })
71
- .parse(body)
72
- return goalSet(db, p.content, {
73
- ...(p.scope ? { scope: p.scope } : {}),
74
- tags: p.tags ?? [],
75
- })
76
- })
77
- .post('/mcp/goal_resolve', async ({ body }) => {
78
- const p = z.object({ goalId: z.string() }).parse(body)
79
- await goalResolve(db, p.goalId)
80
- return { ok: true }
81
- })
82
- .post('/mcp/goal_list', async ({ body }) => {
83
- const p = z.object({ scope: z.string().optional(), includeResolved: z.boolean().optional() }).parse(body)
84
- return goalList(db, {
85
- ...(p.scope ? { scope: p.scope } : {}),
86
- includeResolved: p.includeResolved ?? false,
87
- })
88
- })
89
- .get('/mcp/health', async () => healthReport(db))
90
- .get('/mcp/vitals', async () => vitals(db))
91
- .post('/mcp/consolidate', async ({ body }) => {
92
- const p = z.object({ nremOnly: z.boolean().optional() }).parse(body ?? {})
93
- return consolidate(db, {
94
- nremOnly: p.nremOnly ?? false,
95
- nremSimilarityThreshold: config.consolidation.nremSimilarityThreshold,
96
- remRelationThreshold: config.consolidation.remRelationThreshold,
97
- llm: config.consolidation.llm,
98
- })
13
+ .post('/mcp/:tool', async ({ params, body }) => {
14
+ const payload = body && typeof body === 'object' && !Array.isArray(body)
15
+ ? body as Record<string, unknown>
16
+ : {}
17
+ return handleToolCall(db, config, params.tool, payload)
99
18
  })
100
19
  }