@syndicai/stack-mcp 0.1.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.
- package/README.md +64 -0
- package/config.js +21 -0
- package/create-server.js +12 -0
- package/ingest-source.js +92 -0
- package/main.js +26 -0
- package/memory-client.js +69 -0
- package/package.json +22 -0
- package/tools.js +116 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# `@syndicai/stack-mcp`
|
|
2
|
+
|
|
3
|
+
Local stdio MCP server for SyndicAI Stack. Agents (OpenCode, Cursor, etc.) talk to this process; it calls satellite Memory REST with your API key. Prefer `path` for PDF/image ingest so file bytes never enter the model context.
|
|
4
|
+
|
|
5
|
+
## Configure
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
export SYNDICAI_BASE_URL=https://chat.syndicai.dev/<slug>/v1
|
|
9
|
+
export SYNDICAI_API_KEY=sk-...
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## OpenCode
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mcp": {
|
|
17
|
+
"syndicai-stack": {
|
|
18
|
+
"type": "local",
|
|
19
|
+
"command": ["npx", "-y", "@syndicai/stack-mcp"],
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"environment": {
|
|
22
|
+
"SYNDICAI_BASE_URL": "https://chat.syndicai.dev/<slug>/v1",
|
|
23
|
+
"SYNDICAI_API_KEY": "sk-..."
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Monorepo / unpublished:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
"command": ["pnpm", "exec", "tsx", "apps/stack-mcp/src/main.ts"]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
(run from the syndicai workspace root, or set absolute paths)
|
|
37
|
+
|
|
38
|
+
## Tools
|
|
39
|
+
|
|
40
|
+
| Tool | Purpose |
|
|
41
|
+
| ---------------------- | ---------------------------------------------------- |
|
|
42
|
+
| `memory_search` | Hybrid retrieve |
|
|
43
|
+
| `memory_ingest` | Create/resume ingest (`path` preferred for binaries) |
|
|
44
|
+
| `memory_ingest_status` | Poll job |
|
|
45
|
+
|
|
46
|
+
## Dev
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pnpm nx test stack-mcp --tui=false
|
|
50
|
+
pnpm nx run stack-mcp:typecheck --tui=false
|
|
51
|
+
pnpm nx run stack-mcp:build --tui=false
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Build output: `dist/apps/stack-mcp/` (workspace root), including copied `package.json` + `README.md`.
|
|
55
|
+
|
|
56
|
+
## Publish
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pnpm nx run stack-mcp:build --tui=false
|
|
60
|
+
cd dist/apps/stack-mcp
|
|
61
|
+
npm publish
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Do **not** publish from `apps/stack-mcp` — compiled JS lives under workspace `dist/`.
|
package/config.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export class StackMcpConfigError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'StackMcpConfigError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Load config from env. `SYNDICAI_BASE_URL` is the OpenAI-style base ending at `/v1`
|
|
9
|
+
* (e.g. `https://chat.syndicai.dev/mem-test/v1`).
|
|
10
|
+
*/
|
|
11
|
+
export function loadStackMcpConfig(env = process.env) {
|
|
12
|
+
const baseUrl = env['SYNDICAI_BASE_URL']?.trim().replace(/\/$/, '') ?? '';
|
|
13
|
+
const apiKey = env['SYNDICAI_API_KEY']?.trim() ?? '';
|
|
14
|
+
if (!baseUrl) {
|
|
15
|
+
throw new StackMcpConfigError('SYNDICAI_BASE_URL is required (e.g. https://chat.syndicai.dev/<slug>/v1)');
|
|
16
|
+
}
|
|
17
|
+
if (!apiKey) {
|
|
18
|
+
throw new StackMcpConfigError('SYNDICAI_API_KEY is required');
|
|
19
|
+
}
|
|
20
|
+
return { baseUrl, apiKey };
|
|
21
|
+
}
|
package/create-server.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { createMemoryClient } from './memory-client.js';
|
|
3
|
+
import { registerStackMcpTools } from './tools.js';
|
|
4
|
+
export function createStackMcpServer(config) {
|
|
5
|
+
const server = new McpServer({
|
|
6
|
+
name: 'syndicai-stack',
|
|
7
|
+
version: '0.1.0',
|
|
8
|
+
});
|
|
9
|
+
const client = createMemoryClient(config);
|
|
10
|
+
registerStackMcpTools(server, client);
|
|
11
|
+
return server;
|
|
12
|
+
}
|
package/ingest-source.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
const TEXT_CONTENT_TYPES = new Set([
|
|
4
|
+
'text/plain',
|
|
5
|
+
'text/markdown',
|
|
6
|
+
'text/x-markdown',
|
|
7
|
+
'application/json',
|
|
8
|
+
'text/csv',
|
|
9
|
+
]);
|
|
10
|
+
/**
|
|
11
|
+
* Build the Memory REST ingest JSON body. When `path` is set, reads the file
|
|
12
|
+
* locally so the LLM never needs to supply bytes.
|
|
13
|
+
*/
|
|
14
|
+
export async function buildIngestBody(args) {
|
|
15
|
+
if (!args.sourceKey?.trim() || !args.contentType?.trim()) {
|
|
16
|
+
throw new Error('sourceKey and contentType are required');
|
|
17
|
+
}
|
|
18
|
+
const base = {
|
|
19
|
+
sourceKey: args.sourceKey.trim(),
|
|
20
|
+
contentType: args.contentType.trim(),
|
|
21
|
+
uri: args.uri,
|
|
22
|
+
collectionId: args.collectionId,
|
|
23
|
+
path: args.path,
|
|
24
|
+
};
|
|
25
|
+
if (args.path?.trim()) {
|
|
26
|
+
const filePath = args.path.trim();
|
|
27
|
+
const buf = await readFile(filePath);
|
|
28
|
+
const contentType = base.contentType || guessContentType(filePath);
|
|
29
|
+
if (isTextContentType(contentType)) {
|
|
30
|
+
return {
|
|
31
|
+
...base,
|
|
32
|
+
contentType,
|
|
33
|
+
text: buf.toString('utf8'),
|
|
34
|
+
path: filePath,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
...base,
|
|
39
|
+
contentType,
|
|
40
|
+
binaryBase64: buf.toString('base64'),
|
|
41
|
+
path: filePath,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (args.text !== undefined || args.binaryBase64 !== undefined) {
|
|
45
|
+
return {
|
|
46
|
+
...base,
|
|
47
|
+
text: args.text,
|
|
48
|
+
binaryBase64: args.binaryBase64,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
throw new Error('Provide path, text, or binaryBase64 for memory_ingest');
|
|
52
|
+
}
|
|
53
|
+
function isTextContentType(contentType) {
|
|
54
|
+
const mime = (contentType.toLowerCase().split(';')[0] ?? contentType)
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase();
|
|
57
|
+
if (TEXT_CONTENT_TYPES.has(mime))
|
|
58
|
+
return true;
|
|
59
|
+
if (mime.startsWith('text/'))
|
|
60
|
+
return true;
|
|
61
|
+
if (mime.includes('typescript') || mime.includes('javascript'))
|
|
62
|
+
return true;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
function guessContentType(filePath) {
|
|
66
|
+
switch (extname(filePath).toLowerCase()) {
|
|
67
|
+
case '.pdf':
|
|
68
|
+
return 'application/pdf';
|
|
69
|
+
case '.png':
|
|
70
|
+
return 'image/png';
|
|
71
|
+
case '.jpg':
|
|
72
|
+
case '.jpeg':
|
|
73
|
+
return 'image/jpeg';
|
|
74
|
+
case '.webp':
|
|
75
|
+
return 'image/webp';
|
|
76
|
+
case '.gif':
|
|
77
|
+
return 'image/gif';
|
|
78
|
+
case '.md':
|
|
79
|
+
case '.markdown':
|
|
80
|
+
return 'text/markdown';
|
|
81
|
+
case '.json':
|
|
82
|
+
return 'application/json';
|
|
83
|
+
case '.ts':
|
|
84
|
+
case '.tsx':
|
|
85
|
+
return 'text/x-typescript';
|
|
86
|
+
case '.js':
|
|
87
|
+
case '.jsx':
|
|
88
|
+
return 'text/javascript';
|
|
89
|
+
default:
|
|
90
|
+
return 'application/octet-stream';
|
|
91
|
+
}
|
|
92
|
+
}
|
package/main.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { loadStackMcpConfig, StackMcpConfigError } from './config.js';
|
|
4
|
+
import { createStackMcpServer } from './create-server.js';
|
|
5
|
+
async function main() {
|
|
6
|
+
let config;
|
|
7
|
+
try {
|
|
8
|
+
config = loadStackMcpConfig();
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
const message = err instanceof StackMcpConfigError
|
|
12
|
+
? err.message
|
|
13
|
+
: err instanceof Error
|
|
14
|
+
? err.message
|
|
15
|
+
: String(err);
|
|
16
|
+
console.error(`@syndicai/stack-mcp: ${message}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const server = createStackMcpServer(config);
|
|
20
|
+
const transport = new StdioServerTransport();
|
|
21
|
+
await server.connect(transport);
|
|
22
|
+
}
|
|
23
|
+
void main().catch((err) => {
|
|
24
|
+
console.error('@syndicai/stack-mcp failed:', err instanceof Error ? err.message : err);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
package/memory-client.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export class MemoryHttpError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
body;
|
|
4
|
+
constructor(message, status, body) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.body = body;
|
|
8
|
+
this.name = 'MemoryHttpError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function createMemoryClient(config) {
|
|
12
|
+
const headers = {
|
|
13
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
14
|
+
Accept: 'application/json',
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
};
|
|
17
|
+
async function request(method, path, body) {
|
|
18
|
+
const url = `${config.baseUrl}${path}`;
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
method,
|
|
21
|
+
headers,
|
|
22
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
23
|
+
});
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
let parsed;
|
|
26
|
+
if (!text) {
|
|
27
|
+
parsed = null;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(text);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
parsed = text;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const message = extractErrorMessage(parsed) ?? `HTTP ${res.status}`;
|
|
39
|
+
throw new MemoryHttpError(message, res.status, parsed);
|
|
40
|
+
}
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
retrieve(params) {
|
|
45
|
+
return request('POST', '/memory/retrieve', params);
|
|
46
|
+
},
|
|
47
|
+
ingest(body) {
|
|
48
|
+
return request('POST', '/memory/ingest', body);
|
|
49
|
+
},
|
|
50
|
+
getIngest(jobId) {
|
|
51
|
+
return request('GET', `/memory/ingest/${encodeURIComponent(jobId)}`);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function extractErrorMessage(body) {
|
|
56
|
+
if (!body || typeof body !== 'object') {
|
|
57
|
+
return typeof body === 'string' && body.trim() ? body : null;
|
|
58
|
+
}
|
|
59
|
+
const record = body;
|
|
60
|
+
if (typeof record['message'] === 'string')
|
|
61
|
+
return record['message'];
|
|
62
|
+
const err = record['error'];
|
|
63
|
+
if (err && typeof err === 'object') {
|
|
64
|
+
const msg = err['message'];
|
|
65
|
+
if (typeof msg === 'string')
|
|
66
|
+
return msg;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@syndicai/stack-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local stdio MCP server for SyndicAI Stack (Memory and future tools)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"syndicai-stack-mcp": "./main.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"*.js"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
20
|
+
"zod": "^4.3.6"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/tools.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildIngestBody } from './ingest-source.js';
|
|
3
|
+
import { MemoryHttpError } from './memory-client.js';
|
|
4
|
+
export const MEMORY_MCP_TOOL_NAMES = [
|
|
5
|
+
'memory_search',
|
|
6
|
+
'memory_ingest',
|
|
7
|
+
'memory_ingest_status',
|
|
8
|
+
];
|
|
9
|
+
function textResult(data) {
|
|
10
|
+
return {
|
|
11
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function toolErrorResult(err) {
|
|
15
|
+
let message = err instanceof Error ? err.message : String(err);
|
|
16
|
+
if (err instanceof MemoryHttpError && err.body) {
|
|
17
|
+
const extracted = extractNestedMessage(err.body);
|
|
18
|
+
if (extracted)
|
|
19
|
+
message = extracted;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
isError: true,
|
|
23
|
+
content: [{ type: 'text', text: message }],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function extractNestedMessage(body) {
|
|
27
|
+
if (!body || typeof body !== 'object')
|
|
28
|
+
return null;
|
|
29
|
+
const record = body;
|
|
30
|
+
if (typeof record['message'] === 'string')
|
|
31
|
+
return record['message'];
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
export async function runMemorySearch(client, args) {
|
|
35
|
+
try {
|
|
36
|
+
const data = await client.retrieve(args);
|
|
37
|
+
return textResult(data);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return toolErrorResult(err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function runMemoryIngest(client, args) {
|
|
44
|
+
try {
|
|
45
|
+
const body = await buildIngestBody(args);
|
|
46
|
+
const data = await client.ingest(body);
|
|
47
|
+
return textResult(data);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
return toolErrorResult(err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function runMemoryIngestStatus(client, args) {
|
|
54
|
+
try {
|
|
55
|
+
if (!args.jobId?.trim()) {
|
|
56
|
+
return toolErrorResult(new Error('jobId is required'));
|
|
57
|
+
}
|
|
58
|
+
const data = await client.getIngest(args.jobId.trim());
|
|
59
|
+
return textResult(data);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return toolErrorResult(err);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Register Memory tools. Future Stack tools can be added here. */
|
|
66
|
+
export function registerStackMcpTools(server, client) {
|
|
67
|
+
server.registerTool('memory_search', {
|
|
68
|
+
title: 'Search Memory',
|
|
69
|
+
description: 'Hybrid search over the squad Memory corpus (vector + lexical). Returns ranked chunk hits with content and scores.',
|
|
70
|
+
inputSchema: {
|
|
71
|
+
query: z.string().describe('Natural-language search query'),
|
|
72
|
+
limit: z
|
|
73
|
+
.number()
|
|
74
|
+
.int()
|
|
75
|
+
.positive()
|
|
76
|
+
.optional()
|
|
77
|
+
.describe('Maximum number of hits to return'),
|
|
78
|
+
collectionId: z
|
|
79
|
+
.string()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('Optional collection filter; omit to search all'),
|
|
82
|
+
},
|
|
83
|
+
}, async (args) => runMemorySearch(client, args));
|
|
84
|
+
server.registerTool('memory_ingest', {
|
|
85
|
+
title: 'Ingest into Memory',
|
|
86
|
+
description: 'Start (or resume) a Memory ingest job. Prefer local `path` for PDFs/images so bytes stay out of the model context; ' +
|
|
87
|
+
'the MCP process reads the file and POSTs JSON (text or binaryBase64) to Memory REST. ' +
|
|
88
|
+
'If status is "paused", poll memory_ingest_status — do not re-ingest the same sourceKey while open.',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
sourceKey: z
|
|
91
|
+
.string()
|
|
92
|
+
.describe('Stable source identity (e.g. repo path or doc id)'),
|
|
93
|
+
contentType: z
|
|
94
|
+
.string()
|
|
95
|
+
.describe('MIME type (e.g. text/plain, application/pdf, image/png). Used with path or inline payload.'),
|
|
96
|
+
path: z
|
|
97
|
+
.string()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe('Absolute or workspace-relative filesystem path; preferred for PDF/image ingest'),
|
|
100
|
+
text: z.string().optional().describe('Plain text / source content'),
|
|
101
|
+
binaryBase64: z
|
|
102
|
+
.string()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe('Base64 binary (small fixtures only; prefer path for real PDFs/images)'),
|
|
105
|
+
uri: z.string().optional().describe('Optional source URI metadata'),
|
|
106
|
+
collectionId: z.string().optional().describe('Optional collection id'),
|
|
107
|
+
},
|
|
108
|
+
}, async (args) => runMemoryIngest(client, args));
|
|
109
|
+
server.registerTool('memory_ingest_status', {
|
|
110
|
+
title: 'Ingest job status',
|
|
111
|
+
description: 'Fetch status and checkpoint for a Memory ingest job. When paused, wait and poll again.',
|
|
112
|
+
inputSchema: {
|
|
113
|
+
jobId: z.string().describe('Ingest job id from memory_ingest'),
|
|
114
|
+
},
|
|
115
|
+
}, async (args) => runMemoryIngestStatus(client, args));
|
|
116
|
+
}
|