filamental-mcp 0.2.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 +135 -0
- package/dist/index.js +93 -0
- package/dist/server.js +913 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# filamental-mcp
|
|
2
|
+
|
|
3
|
+
A local [Model Context Protocol](https://modelcontextprotocol.io/) server that connects AI assistants (Claude Desktop, Claude Code, etc.) directly to your [Filamental](https://filamental.app) knowledge graph.
|
|
4
|
+
|
|
5
|
+
The server reads and writes your vault -- searching nodes, following connections, creating and updating content -- while Filamental is running or closed. It talks to the same SQLite index the app uses, so changes are immediately visible when you open Filamental.
|
|
6
|
+
|
|
7
|
+
**Requires Node.js 22+ and Filamental desktop app.**
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Prerequisites
|
|
12
|
+
|
|
13
|
+
- [Filamental](https://filamental.app) installed and at least one vault opened (this initialises the SQLite index)
|
|
14
|
+
- Node.js 22 or later
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Setup via Filamental
|
|
19
|
+
|
|
20
|
+
The easiest way to connect is through the app:
|
|
21
|
+
|
|
22
|
+
1. Open Filamental and go to **Settings > AI Integrations**
|
|
23
|
+
2. Click **Connect to Claude Desktop**
|
|
24
|
+
3. Restart Claude Desktop
|
|
25
|
+
|
|
26
|
+
Filamental resolves all paths automatically and writes the correct entry to `claude_desktop_config.json`.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Manual setup
|
|
31
|
+
|
|
32
|
+
Install globally:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install -g filamental-mcp
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then add to your `claude_desktop_config.json`:
|
|
39
|
+
|
|
40
|
+
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
41
|
+
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"filamental": {
|
|
47
|
+
"command": "node",
|
|
48
|
+
"args": [
|
|
49
|
+
"--no-warnings",
|
|
50
|
+
"/absolute/path/to/node_modules/filamental-mcp/dist/index.js",
|
|
51
|
+
"--vault",
|
|
52
|
+
"/absolute/path/to/your/vault"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> Vault path must be absolute. Claude Desktop launches the server from a varying working directory, so relative paths are not reliable.
|
|
60
|
+
|
|
61
|
+
### Claude Code
|
|
62
|
+
|
|
63
|
+
Add a `.mcp.json` at your project root:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"filamental": {
|
|
69
|
+
"command": "npx",
|
|
70
|
+
"args": [
|
|
71
|
+
"filamental-mcp",
|
|
72
|
+
"--vault",
|
|
73
|
+
"/absolute/path/to/your/vault"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Tools
|
|
83
|
+
|
|
84
|
+
### Read
|
|
85
|
+
|
|
86
|
+
| Tool | Description |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `get_vault_info` | Node and edge counts plus entity and connector type names |
|
|
89
|
+
| `list_node_types` | Full entity type configuration for this vault |
|
|
90
|
+
| `list_connector_types` | Full connector type configuration for this vault |
|
|
91
|
+
| `search_nodes` | Full-text search across node names, note bodies and property values |
|
|
92
|
+
| `get_node` | Full node record by UUID |
|
|
93
|
+
| `get_connections` | All edges connected to a node, with source and target names resolved |
|
|
94
|
+
| `get_subgraph` | BFS traversal from a root node up to N hops (max depth 3) |
|
|
95
|
+
|
|
96
|
+
### Write
|
|
97
|
+
|
|
98
|
+
| Tool | Description |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `create_node` | Create a new node -- writes a markdown file and updates the SQLite index |
|
|
101
|
+
| `update_node` | Update an existing node; omitted fields are unchanged |
|
|
102
|
+
| `delete_node` | Delete a node and remove it from the index |
|
|
103
|
+
| `create_edge` | Add a relationship between two nodes |
|
|
104
|
+
| `delete_edge` | Remove a relationship between two nodes |
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## CLI options
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
filamental-mcp --vault <path> Use vault at <path>
|
|
112
|
+
filamental-mcp --vault <path> --db <path> Override the SQLite database path (for testing)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## How it works
|
|
118
|
+
|
|
119
|
+
Filamental stores all node data as Markdown files with YAML frontmatter inside your vault folder. It also maintains a SQLite index (stored in your OS app-config directory, not inside the vault) for fast full-text search and graph traversal.
|
|
120
|
+
|
|
121
|
+
This server opens that SQLite index read-write. Read tools query it directly. Write tools update both the Markdown file on disk and the SQLite index so the Filamental app sees changes immediately on next load.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Known limitations
|
|
126
|
+
|
|
127
|
+
- The pre-built binary (`better-sqlite3`) is Windows x64 only. Other platforms require building from source.
|
|
128
|
+
- The server must be restarted if you change the active vault in Filamental.
|
|
129
|
+
- Auto-config via Filamental Settings has been tested on Windows. macOS path resolution is included but untested.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
[MIT](https://opensource.org/licenses/MIT) — Copyright Blackcat Marketing LLC
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Filamental MCP Server — index.ts
|
|
3
|
+
// Entry point: parse CLI args, resolve vault DB path, open DB, connect transport.
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { join, resolve } from 'path';
|
|
7
|
+
import Database from 'better-sqlite3';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import { createServer } from './server.js';
|
|
10
|
+
// ── CLI argument parser ───────────────────────────────────────────────────────
|
|
11
|
+
function parseArgs(argv) {
|
|
12
|
+
const result = {};
|
|
13
|
+
for (let i = 0; i < argv.length - 1; i++) {
|
|
14
|
+
if (argv[i].startsWith('--')) {
|
|
15
|
+
result[argv[i].slice(2)] = argv[i + 1];
|
|
16
|
+
i++; // consume the value
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
// ── Vault hash (mirrors Rust vault_hash fn — FNV-1a 32-bit) ──────────────────
|
|
22
|
+
function vaultHash(vaultPath) {
|
|
23
|
+
let hash = 2_166_136_261;
|
|
24
|
+
const bytes = Buffer.from(vaultPath, 'utf-8');
|
|
25
|
+
for (const byte of bytes) {
|
|
26
|
+
hash ^= byte;
|
|
27
|
+
hash = Math.imul(hash, 16_777_619) >>> 0;
|
|
28
|
+
}
|
|
29
|
+
return `vault-${hash.toString(16).padStart(8, '0')}`;
|
|
30
|
+
}
|
|
31
|
+
// ── DB path resolution ────────────────────────────────────────────────────────
|
|
32
|
+
//
|
|
33
|
+
// Filamental stores its SQLite index in the OS app-config directory, NOT inside
|
|
34
|
+
// the vault folder (so cloud-synced vaults don't re-index on every machine).
|
|
35
|
+
// Path: <app_config_dir>/vaults/<vault-hash>/filamental.db
|
|
36
|
+
//
|
|
37
|
+
// If --db is supplied it overrides everything (useful for testing).
|
|
38
|
+
function resolveDbPath(vaultPath, explicitDb) {
|
|
39
|
+
if (explicitDb)
|
|
40
|
+
return resolve(explicitDb);
|
|
41
|
+
// Tauri's app_config_dir for "com.filamental.app"
|
|
42
|
+
let appConfigDir;
|
|
43
|
+
if (process.platform === 'win32') {
|
|
44
|
+
appConfigDir = join(process.env['APPDATA'] ?? homedir(), 'com.filamental.app');
|
|
45
|
+
}
|
|
46
|
+
else if (process.platform === 'darwin') {
|
|
47
|
+
appConfigDir = join(homedir(), 'Library', 'Application Support', 'com.filamental.app');
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Linux / XDG
|
|
51
|
+
appConfigDir = join(process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config'), 'com.filamental.app');
|
|
52
|
+
}
|
|
53
|
+
const appDbPath = join(appConfigDir, 'vaults', vaultHash(vaultPath), 'filamental.db');
|
|
54
|
+
if (existsSync(appDbPath))
|
|
55
|
+
return appDbPath;
|
|
56
|
+
// Fallback: vault-local path (dev builds or future embedded mode)
|
|
57
|
+
return join(vaultPath, '.filamental', 'filamental.db');
|
|
58
|
+
}
|
|
59
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
60
|
+
async function main() {
|
|
61
|
+
const args = parseArgs(process.argv.slice(2));
|
|
62
|
+
const rawVaultArg = args['vault'];
|
|
63
|
+
if (!rawVaultArg) {
|
|
64
|
+
console.error('Error: --vault <path> is required');
|
|
65
|
+
console.error('');
|
|
66
|
+
console.error('Usage:');
|
|
67
|
+
console.error(' node --no-warnings dist/index.js --vault /path/to/your/vault');
|
|
68
|
+
console.error(' node --no-warnings dist/index.js --vault /path/to/vault --db /explicit/db/path');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
const vaultPath = resolve(rawVaultArg);
|
|
72
|
+
if (!existsSync(vaultPath)) {
|
|
73
|
+
console.error(`Error: vault path does not exist: ${vaultPath}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
const dbPath = resolveDbPath(vaultPath, args['db']);
|
|
77
|
+
if (!existsSync(dbPath)) {
|
|
78
|
+
console.error(`Error: Filamental database not found at:`);
|
|
79
|
+
console.error(` ${dbPath}`);
|
|
80
|
+
console.error('');
|
|
81
|
+
console.error('Make sure this vault has been opened in Filamental at least once');
|
|
82
|
+
console.error('so that the SQLite index is initialised.');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const db = new Database(dbPath);
|
|
86
|
+
const server = createServer(db, vaultPath);
|
|
87
|
+
const transport = new StdioServerTransport();
|
|
88
|
+
await server.connect(transport);
|
|
89
|
+
}
|
|
90
|
+
main().catch(err => {
|
|
91
|
+
console.error('Fatal error:', err);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
});
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
// Filamental MCP Server — server.ts
|
|
2
|
+
// Tool definitions and implementations. Entry point wires this to a transport.
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { readFileSync, statSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
|
|
5
|
+
import { join, resolve, sep } from 'path';
|
|
6
|
+
import * as yaml from 'js-yaml';
|
|
7
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
// ── FTS query builder ─────────────────────────────────────────────────────────
|
|
10
|
+
function buildFtsQuery(q) {
|
|
11
|
+
return q
|
|
12
|
+
.trim()
|
|
13
|
+
.split(/\s+/)
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.map(word => `"${word.replace(/"/g, '""')}"*`)
|
|
16
|
+
.join(' ');
|
|
17
|
+
}
|
|
18
|
+
function str(v) {
|
|
19
|
+
return v == null ? '' : String(v);
|
|
20
|
+
}
|
|
21
|
+
// ── File helpers ──────────────────────────────────────────────────────────────
|
|
22
|
+
function sanitiseFilename(name) {
|
|
23
|
+
const cleaned = name.replace(/[^a-zA-Z0-9\-_]/g, '_').replace(/^_+|_+$/g, '');
|
|
24
|
+
return cleaned.length === 0 ? 'node' : cleaned;
|
|
25
|
+
}
|
|
26
|
+
function findAvailablePath(basePath) {
|
|
27
|
+
if (!existsSync(basePath))
|
|
28
|
+
return basePath;
|
|
29
|
+
const withoutExt = basePath.slice(0, -3); // strip .md
|
|
30
|
+
for (let i = 1; i <= 9999; i++) {
|
|
31
|
+
const candidate = `${withoutExt}_${i}.md`;
|
|
32
|
+
if (!existsSync(candidate))
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
return basePath;
|
|
36
|
+
}
|
|
37
|
+
function fileMtimeSecs(filePath) {
|
|
38
|
+
try {
|
|
39
|
+
return Math.floor(statSync(filePath).mtimeMs / 1000);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// ── Markdown serialisation / parsing ──────────────────────────────────────────
|
|
46
|
+
function serialiseMarkdown(node, body) {
|
|
47
|
+
const frontmatter = {
|
|
48
|
+
id: node.id,
|
|
49
|
+
name: node.name,
|
|
50
|
+
type: node.entity_type,
|
|
51
|
+
status: node.status,
|
|
52
|
+
created: node.created,
|
|
53
|
+
modified: node.modified,
|
|
54
|
+
modified_by: node.modified_by,
|
|
55
|
+
version: node.version,
|
|
56
|
+
properties: node.properties,
|
|
57
|
+
relationships: node.relationships.map(r => {
|
|
58
|
+
const rel = {
|
|
59
|
+
target: r.target,
|
|
60
|
+
type: r.rel_type,
|
|
61
|
+
direction: r.direction,
|
|
62
|
+
properties: r.properties ?? {},
|
|
63
|
+
};
|
|
64
|
+
if (r.label != null)
|
|
65
|
+
rel['label'] = r.label;
|
|
66
|
+
if (r.influence != null)
|
|
67
|
+
rel['influence'] = r.influence;
|
|
68
|
+
return rel;
|
|
69
|
+
}),
|
|
70
|
+
attachments: node.attachments,
|
|
71
|
+
composition_mode: node.composition_mode,
|
|
72
|
+
child_view_id: node.child_view_id,
|
|
73
|
+
has_notes: node.has_notes,
|
|
74
|
+
};
|
|
75
|
+
if (node.display_name != null)
|
|
76
|
+
frontmatter['display_name'] = node.display_name;
|
|
77
|
+
if (node.category != null)
|
|
78
|
+
frontmatter['category'] = node.category;
|
|
79
|
+
const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 });
|
|
80
|
+
return `---\n${yamlStr}---\n\n${body.trim()}`;
|
|
81
|
+
}
|
|
82
|
+
function parseMarkdownFile(filePath) {
|
|
83
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
84
|
+
// Split on opening --- and closing ---
|
|
85
|
+
const openIdx = raw.indexOf('---\n');
|
|
86
|
+
if (openIdx !== 0)
|
|
87
|
+
throw new Error('No YAML frontmatter found');
|
|
88
|
+
const closeIdx = raw.indexOf('\n---\n', 4);
|
|
89
|
+
if (closeIdx === -1)
|
|
90
|
+
throw new Error('Frontmatter closing delimiter not found');
|
|
91
|
+
const yamlPart = raw.slice(4, closeIdx);
|
|
92
|
+
const body = raw.slice(closeIdx + 5); // skip \n---\n
|
|
93
|
+
const fm = yaml.load(yamlPart);
|
|
94
|
+
const relationships = (fm['relationships'] ?? []).map(r => {
|
|
95
|
+
const rel = r;
|
|
96
|
+
const out = {
|
|
97
|
+
target: String(rel['target'] ?? ''),
|
|
98
|
+
rel_type: String(rel['type'] ?? ''),
|
|
99
|
+
direction: String(rel['direction'] ?? 'none'),
|
|
100
|
+
properties: rel['properties'] ?? {},
|
|
101
|
+
};
|
|
102
|
+
if (rel['label'] != null)
|
|
103
|
+
out.label = String(rel['label']);
|
|
104
|
+
if (rel['influence'] != null)
|
|
105
|
+
out.influence = String(rel['influence']);
|
|
106
|
+
return out;
|
|
107
|
+
});
|
|
108
|
+
const node = {
|
|
109
|
+
id: String(fm['id'] ?? ''),
|
|
110
|
+
name: String(fm['name'] ?? ''),
|
|
111
|
+
entity_type: String(fm['type'] ?? 'unclassified'),
|
|
112
|
+
status: String(fm['status'] ?? 'active'),
|
|
113
|
+
created: String(fm['created'] ?? new Date().toISOString()),
|
|
114
|
+
modified: String(fm['modified'] ?? new Date().toISOString()),
|
|
115
|
+
modified_by: String(fm['modified_by'] ?? 'unknown'),
|
|
116
|
+
version: typeof fm['version'] === 'number' ? fm['version'] : 1,
|
|
117
|
+
properties: fm['properties'] ?? {},
|
|
118
|
+
relationships,
|
|
119
|
+
attachments: fm['attachments'] ?? [],
|
|
120
|
+
composition_mode: fm['composition_mode'] ?? null,
|
|
121
|
+
child_view_id: fm['child_view_id'] ?? null,
|
|
122
|
+
has_notes: Boolean(fm['has_notes']),
|
|
123
|
+
};
|
|
124
|
+
if (fm['display_name'] != null)
|
|
125
|
+
node.display_name = String(fm['display_name']);
|
|
126
|
+
if (fm['category'] != null)
|
|
127
|
+
node.category = String(fm['category']);
|
|
128
|
+
return { node, body };
|
|
129
|
+
}
|
|
130
|
+
// ── SQLite upsert / delete ────────────────────────────────────────────────────
|
|
131
|
+
function upsertEntity(db, node, filePath, body) {
|
|
132
|
+
const now = node.modified;
|
|
133
|
+
const mtime = fileMtimeSecs(filePath);
|
|
134
|
+
const dataJson = JSON.stringify({
|
|
135
|
+
...node,
|
|
136
|
+
has_notes: body.trim().length > 0,
|
|
137
|
+
});
|
|
138
|
+
const propertiesText = Object.values(node.properties).join(' ');
|
|
139
|
+
db.prepare(`INSERT INTO entities
|
|
140
|
+
(id, file_path, name, entity_type, status, version, modified, file_mtime_secs, data_json)
|
|
141
|
+
VALUES
|
|
142
|
+
(@id, @file_path, @name, @entity_type, @status, @version, @modified, @file_mtime_secs, @data_json)
|
|
143
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
144
|
+
file_path = excluded.file_path,
|
|
145
|
+
name = excluded.name,
|
|
146
|
+
entity_type = excluded.entity_type,
|
|
147
|
+
status = excluded.status,
|
|
148
|
+
version = excluded.version,
|
|
149
|
+
modified = excluded.modified,
|
|
150
|
+
file_mtime_secs = excluded.file_mtime_secs,
|
|
151
|
+
data_json = excluded.data_json`).run({
|
|
152
|
+
id: node.id,
|
|
153
|
+
file_path: filePath,
|
|
154
|
+
name: node.name,
|
|
155
|
+
entity_type: node.entity_type,
|
|
156
|
+
status: node.status,
|
|
157
|
+
version: node.version,
|
|
158
|
+
modified: now,
|
|
159
|
+
file_mtime_secs: mtime,
|
|
160
|
+
data_json: dataJson,
|
|
161
|
+
});
|
|
162
|
+
db.prepare('DELETE FROM entities_fts WHERE entity_id = ?').run(node.id);
|
|
163
|
+
db.prepare(`INSERT INTO entities_fts(entity_id, name, body, properties_text) VALUES(?, ?, ?, ?)`).run(node.id, node.name, body.trim(), propertiesText);
|
|
164
|
+
db.prepare('DELETE FROM relationships WHERE source_id = ?').run(node.id);
|
|
165
|
+
for (const rel of node.relationships) {
|
|
166
|
+
const edgeId = `${node.id}__${rel.target}__${rel.rel_type}`;
|
|
167
|
+
db.prepare(`INSERT OR IGNORE INTO relationships
|
|
168
|
+
(edge_id, source_id, target_id, rel_type, direction, label, influence, properties_json)
|
|
169
|
+
VALUES
|
|
170
|
+
(@edge_id, @source_id, @target_id, @rel_type, @direction, @label, @influence, @properties_json)`).run({
|
|
171
|
+
edge_id: edgeId,
|
|
172
|
+
source_id: node.id,
|
|
173
|
+
target_id: rel.target,
|
|
174
|
+
rel_type: rel.rel_type,
|
|
175
|
+
direction: rel.direction,
|
|
176
|
+
label: rel.label ?? null,
|
|
177
|
+
influence: rel.influence ?? null,
|
|
178
|
+
properties_json: JSON.stringify(rel.properties ?? {}),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function deleteEntity(db, id) {
|
|
183
|
+
db.prepare('DELETE FROM entities WHERE id = ?').run(id);
|
|
184
|
+
db.prepare('DELETE FROM entities_fts WHERE entity_id = ?').run(id);
|
|
185
|
+
db.prepare('DELETE FROM relationships WHERE source_id = ? OR target_id = ?').run(id, id);
|
|
186
|
+
}
|
|
187
|
+
// ── Input validation ──────────────────────────────────────────────────────────
|
|
188
|
+
function validateName(name) {
|
|
189
|
+
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
190
|
+
throw new McpError(ErrorCode.InvalidParams, 'name must be a non-empty string');
|
|
191
|
+
}
|
|
192
|
+
if (name.length > 200) {
|
|
193
|
+
throw new McpError(ErrorCode.InvalidParams, 'name must be 200 characters or fewer');
|
|
194
|
+
}
|
|
195
|
+
if (name.includes('\0')) {
|
|
196
|
+
throw new McpError(ErrorCode.InvalidParams, 'name must not contain null bytes');
|
|
197
|
+
}
|
|
198
|
+
if (name.includes('..')) {
|
|
199
|
+
throw new McpError(ErrorCode.InvalidParams, 'name must not contain ".." sequences');
|
|
200
|
+
}
|
|
201
|
+
return name.trim();
|
|
202
|
+
}
|
|
203
|
+
// ── Tool schemas ──────────────────────────────────────────────────────────────
|
|
204
|
+
const TOOLS = [
|
|
205
|
+
{
|
|
206
|
+
name: 'search_nodes',
|
|
207
|
+
description: 'Full-text search across entity names, note bodies and property values. ' +
|
|
208
|
+
'Returns matching nodes with a contextual snippet.',
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
query: { type: 'string', description: 'Search terms' },
|
|
213
|
+
limit: { type: 'number', description: 'Max results (default 20, max 100)' },
|
|
214
|
+
},
|
|
215
|
+
required: ['query'],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: 'get_node',
|
|
220
|
+
description: 'Retrieve full node data for a given entity UUID.',
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
properties: {
|
|
224
|
+
id: { type: 'string', description: 'Entity UUID' },
|
|
225
|
+
},
|
|
226
|
+
required: ['id'],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'get_connections',
|
|
231
|
+
description: 'Get all edges connected to a node. Each result includes the resolved ' +
|
|
232
|
+
'name and type of both the source and target.',
|
|
233
|
+
inputSchema: {
|
|
234
|
+
type: 'object',
|
|
235
|
+
properties: {
|
|
236
|
+
id: { type: 'string', description: 'Entity UUID' },
|
|
237
|
+
direction: {
|
|
238
|
+
type: 'string',
|
|
239
|
+
enum: ['all', 'outgoing', 'incoming'],
|
|
240
|
+
description: 'Filter by whether the node is source or target (default "all")',
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
required: ['id'],
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'get_subgraph',
|
|
248
|
+
description: 'BFS traversal from a root node collecting all reachable nodes and edges ' +
|
|
249
|
+
'up to the given hop depth. Results are deduplicated.',
|
|
250
|
+
inputSchema: {
|
|
251
|
+
type: 'object',
|
|
252
|
+
properties: {
|
|
253
|
+
id: { type: 'string', description: 'Root entity UUID' },
|
|
254
|
+
depth: { type: 'number', description: 'Hop depth (default 1, max 3)' },
|
|
255
|
+
},
|
|
256
|
+
required: ['id'],
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: 'list_node_types',
|
|
261
|
+
description: 'Return the full entity type configuration for this vault ' +
|
|
262
|
+
'(from .filamental/entity_types.json).',
|
|
263
|
+
inputSchema: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'list_connector_types',
|
|
270
|
+
description: 'Return the full connector type configuration for this vault ' +
|
|
271
|
+
'(from .filamental/connector_types.json).',
|
|
272
|
+
inputSchema: {
|
|
273
|
+
type: 'object',
|
|
274
|
+
properties: {},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: 'get_vault_info',
|
|
279
|
+
description: 'Return summary counts (nodes, edges) and the top-level entity and ' +
|
|
280
|
+
'connector type names configured for this vault.',
|
|
281
|
+
inputSchema: {
|
|
282
|
+
type: 'object',
|
|
283
|
+
properties: {},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'create_node',
|
|
288
|
+
description: 'Create a new node in the vault. Writes a markdown file and updates the SQLite index. ' +
|
|
289
|
+
'Returns { id, file_path }. Call get_node(id) to retrieve the full record.',
|
|
290
|
+
inputSchema: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
properties: {
|
|
293
|
+
name: { type: 'string', description: 'Display name (max 200 chars, required)' },
|
|
294
|
+
entity_type: { type: 'string', description: 'Entity type key (default "unclassified")' },
|
|
295
|
+
status: { type: 'string', enum: ['active', 'archived'], description: 'Node status (default "active")' },
|
|
296
|
+
properties: {
|
|
297
|
+
type: 'object',
|
|
298
|
+
description: 'Key/value string pairs',
|
|
299
|
+
additionalProperties: { type: 'string' },
|
|
300
|
+
},
|
|
301
|
+
relationships: {
|
|
302
|
+
type: 'array',
|
|
303
|
+
description: 'Edges to other nodes',
|
|
304
|
+
items: {
|
|
305
|
+
type: 'object',
|
|
306
|
+
properties: {
|
|
307
|
+
target: { type: 'string', description: 'Target node UUID' },
|
|
308
|
+
rel_type: { type: 'string', description: 'Connector type key' },
|
|
309
|
+
direction: { type: 'string', enum: ['none', 'source', 'target'] },
|
|
310
|
+
label: { type: 'string' },
|
|
311
|
+
influence: { type: 'string', enum: ['normal', 'weak', 'none'] },
|
|
312
|
+
properties: { type: 'object', additionalProperties: { type: 'string' } },
|
|
313
|
+
},
|
|
314
|
+
required: ['target', 'rel_type', 'direction'],
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
notes: { type: 'string', description: 'Markdown body text' },
|
|
318
|
+
folder: { type: 'string', description: 'Subfolder path relative to vault root' },
|
|
319
|
+
},
|
|
320
|
+
required: ['name'],
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: 'update_node',
|
|
325
|
+
description: 'Update an existing node. Only supplied fields are changed; omitted fields retain their current values. ' +
|
|
326
|
+
'Providing relationships or properties replaces the entire array/map. ' +
|
|
327
|
+
'Returns { id, file_path }.',
|
|
328
|
+
inputSchema: {
|
|
329
|
+
type: 'object',
|
|
330
|
+
properties: {
|
|
331
|
+
id: { type: 'string', description: 'UUID of the node to update' },
|
|
332
|
+
name: { type: 'string', description: 'New display name (max 200 chars)' },
|
|
333
|
+
entity_type: { type: 'string' },
|
|
334
|
+
status: { type: 'string', enum: ['active', 'archived'] },
|
|
335
|
+
properties: { type: 'object', additionalProperties: { type: 'string' } },
|
|
336
|
+
relationships: {
|
|
337
|
+
type: 'array',
|
|
338
|
+
items: {
|
|
339
|
+
type: 'object',
|
|
340
|
+
properties: {
|
|
341
|
+
target: { type: 'string' },
|
|
342
|
+
rel_type: { type: 'string' },
|
|
343
|
+
direction: { type: 'string', enum: ['none', 'source', 'target'] },
|
|
344
|
+
label: { type: 'string' },
|
|
345
|
+
influence: { type: 'string', enum: ['normal', 'weak', 'none'] },
|
|
346
|
+
properties: { type: 'object', additionalProperties: { type: 'string' } },
|
|
347
|
+
},
|
|
348
|
+
required: ['target', 'rel_type', 'direction'],
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
notes: { type: 'string', description: 'Replaces the full markdown body if provided' },
|
|
352
|
+
display_name: { type: 'string', description: 'Pass empty string to clear' },
|
|
353
|
+
},
|
|
354
|
+
required: ['id'],
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
name: 'delete_node',
|
|
359
|
+
description: 'Delete a node from the vault. Removes the markdown file and all references ' +
|
|
360
|
+
'from the SQLite index (entities, FTS, relationships). Returns { deleted: true, file_path }.',
|
|
361
|
+
inputSchema: {
|
|
362
|
+
type: 'object',
|
|
363
|
+
properties: {
|
|
364
|
+
id: { type: 'string', description: 'UUID of the node to delete' },
|
|
365
|
+
},
|
|
366
|
+
required: ['id'],
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: 'create_edge',
|
|
371
|
+
description: 'Add a relationship from one node to another. Reads the source node file, appends the ' +
|
|
372
|
+
'relationship, writes it back, and updates the SQLite index. ' +
|
|
373
|
+
'Returns { edge_id, source_id, target_id }.',
|
|
374
|
+
inputSchema: {
|
|
375
|
+
type: 'object',
|
|
376
|
+
properties: {
|
|
377
|
+
source_id: { type: 'string', description: 'UUID of the source node' },
|
|
378
|
+
target_id: { type: 'string', description: 'UUID of the target node' },
|
|
379
|
+
rel_type: { type: 'string', description: 'Connector type key' },
|
|
380
|
+
direction: {
|
|
381
|
+
type: 'string',
|
|
382
|
+
enum: ['none', 'source', 'target'],
|
|
383
|
+
description: 'Arrow direction (default "none")',
|
|
384
|
+
},
|
|
385
|
+
label: { type: 'string', description: 'Optional edge label text' },
|
|
386
|
+
influence: {
|
|
387
|
+
type: 'string',
|
|
388
|
+
enum: ['normal', 'weak', 'none'],
|
|
389
|
+
description: 'Optional physics influence override',
|
|
390
|
+
},
|
|
391
|
+
properties: {
|
|
392
|
+
type: 'object',
|
|
393
|
+
description: 'Optional key/value string pairs',
|
|
394
|
+
additionalProperties: { type: 'string' },
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
required: ['source_id', 'target_id', 'rel_type'],
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: 'delete_edge',
|
|
402
|
+
description: 'Remove a relationship from the vault. Reads the source node file, strips the matching ' +
|
|
403
|
+
'relationship, writes it back, and updates the SQLite index. ' +
|
|
404
|
+
'Returns { deleted: true, edge_id }.',
|
|
405
|
+
inputSchema: {
|
|
406
|
+
type: 'object',
|
|
407
|
+
properties: {
|
|
408
|
+
source_id: { type: 'string', description: 'UUID of the source node' },
|
|
409
|
+
target_id: { type: 'string', description: 'UUID of the target node' },
|
|
410
|
+
rel_type: { type: 'string', description: 'Connector type key' },
|
|
411
|
+
},
|
|
412
|
+
required: ['source_id', 'target_id', 'rel_type'],
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
];
|
|
416
|
+
// ── Read tool implementations ─────────────────────────────────────────────────
|
|
417
|
+
function toolSearchNodes(db, args) {
|
|
418
|
+
const query = String(args.query ?? '').trim();
|
|
419
|
+
const limit = typeof args.limit === 'number' ? Math.min(Math.max(1, args.limit), 100) : 20;
|
|
420
|
+
if (!query)
|
|
421
|
+
return [];
|
|
422
|
+
const ftsQuery = buildFtsQuery(query);
|
|
423
|
+
const rows = db
|
|
424
|
+
.prepare(`SELECT
|
|
425
|
+
f.entity_id AS id,
|
|
426
|
+
e.name,
|
|
427
|
+
e.entity_type AS type,
|
|
428
|
+
e.status,
|
|
429
|
+
CASE
|
|
430
|
+
WHEN instr(lower(e.name), lower(@query)) > 0 THEN 'name'
|
|
431
|
+
WHEN instr(lower(f.properties_text), lower(@query)) > 0 THEN 'property'
|
|
432
|
+
ELSE 'body'
|
|
433
|
+
END AS match_field,
|
|
434
|
+
snippet(entities_fts, 2, '[', ']', '...', 15) AS snippet,
|
|
435
|
+
rank
|
|
436
|
+
FROM entities_fts f
|
|
437
|
+
JOIN entities e ON e.id = f.entity_id
|
|
438
|
+
WHERE entities_fts MATCH @fts_query
|
|
439
|
+
ORDER BY rank
|
|
440
|
+
LIMIT @limit`)
|
|
441
|
+
.all({ query, fts_query: ftsQuery, limit });
|
|
442
|
+
return rows.map(r => ({
|
|
443
|
+
id: str(r['id']),
|
|
444
|
+
name: str(r['name']),
|
|
445
|
+
type: str(r['type']),
|
|
446
|
+
status: str(r['status']),
|
|
447
|
+
match_field: str(r['match_field']),
|
|
448
|
+
snippet: str(r['snippet']),
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
function toolGetNode(db, args) {
|
|
452
|
+
const id = String(args.id ?? '');
|
|
453
|
+
const row = db
|
|
454
|
+
.prepare('SELECT data_json FROM entities WHERE id = ?')
|
|
455
|
+
.get(id);
|
|
456
|
+
if (!row) {
|
|
457
|
+
throw new McpError(ErrorCode.InvalidParams, `Node not found: ${id}`);
|
|
458
|
+
}
|
|
459
|
+
return JSON.parse(str(row['data_json']));
|
|
460
|
+
}
|
|
461
|
+
function toolGetConnections(db, args) {
|
|
462
|
+
const id = String(args.id ?? '');
|
|
463
|
+
const dir = String(args.direction ?? 'all');
|
|
464
|
+
const exists = db.prepare('SELECT 1 FROM entities WHERE id = ?').get(id);
|
|
465
|
+
if (!exists)
|
|
466
|
+
throw new McpError(ErrorCode.InvalidParams, `Node not found: ${id}`);
|
|
467
|
+
const base = `
|
|
468
|
+
SELECT r.edge_id, r.source_id, r.target_id, r.rel_type, r.direction,
|
|
469
|
+
r.label, r.properties_json,
|
|
470
|
+
se.name AS source_name, se.entity_type AS source_type,
|
|
471
|
+
te.name AS target_name, te.entity_type AS target_type
|
|
472
|
+
FROM relationships r
|
|
473
|
+
JOIN entities se ON se.id = r.source_id
|
|
474
|
+
JOIN entities te ON te.id = r.target_id`;
|
|
475
|
+
let rows;
|
|
476
|
+
if (dir === 'outgoing') {
|
|
477
|
+
rows = db.prepare(`${base} WHERE r.source_id = ?`).all(id);
|
|
478
|
+
}
|
|
479
|
+
else if (dir === 'incoming') {
|
|
480
|
+
rows = db.prepare(`${base} WHERE r.target_id = ?`).all(id);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
rows = db.prepare(`${base} WHERE r.source_id = ? OR r.target_id = ?`).all(id, id);
|
|
484
|
+
}
|
|
485
|
+
return rows.map(r => ({
|
|
486
|
+
edge_id: str(r['edge_id']),
|
|
487
|
+
source: { id: str(r['source_id']), name: str(r['source_name']), type: str(r['source_type']) },
|
|
488
|
+
target: { id: str(r['target_id']), name: str(r['target_name']), type: str(r['target_type']) },
|
|
489
|
+
rel_type: str(r['rel_type']),
|
|
490
|
+
direction: str(r['direction']),
|
|
491
|
+
label: r['label'] != null ? str(r['label']) : null,
|
|
492
|
+
properties: JSON.parse(str(r['properties_json']) || '{}'),
|
|
493
|
+
}));
|
|
494
|
+
}
|
|
495
|
+
function toolGetSubgraph(db, args) {
|
|
496
|
+
const rootId = String(args.id ?? '');
|
|
497
|
+
const exists = db.prepare('SELECT 1 FROM entities WHERE id = ?').get(rootId);
|
|
498
|
+
if (!exists)
|
|
499
|
+
throw new McpError(ErrorCode.InvalidParams, `Node not found: ${rootId}`);
|
|
500
|
+
const depth = Math.min(typeof args.depth === 'number' ? Math.max(1, Math.floor(args.depth)) : 1, 3);
|
|
501
|
+
const visitedNodes = new Set([rootId]);
|
|
502
|
+
const visitedEdges = new Set();
|
|
503
|
+
const collectedEdges = [];
|
|
504
|
+
const relStmt = db.prepare(`SELECT edge_id, source_id, target_id, rel_type, direction, label, properties_json
|
|
505
|
+
FROM relationships
|
|
506
|
+
WHERE source_id = ? OR target_id = ?`);
|
|
507
|
+
const nodeStmt = db.prepare('SELECT data_json FROM entities WHERE id = ?');
|
|
508
|
+
let frontier = [rootId];
|
|
509
|
+
for (let d = 0; d < depth; d++) {
|
|
510
|
+
if (frontier.length === 0)
|
|
511
|
+
break;
|
|
512
|
+
const nextFrontier = [];
|
|
513
|
+
for (const nodeId of frontier) {
|
|
514
|
+
const rows = relStmt.all(nodeId, nodeId);
|
|
515
|
+
for (const row of rows) {
|
|
516
|
+
const edgeId = str(row['edge_id']);
|
|
517
|
+
if (visitedEdges.has(edgeId))
|
|
518
|
+
continue;
|
|
519
|
+
visitedEdges.add(edgeId);
|
|
520
|
+
collectedEdges.push(row);
|
|
521
|
+
const srcId = str(row['source_id']);
|
|
522
|
+
const otherId = srcId === nodeId ? str(row['target_id']) : srcId;
|
|
523
|
+
if (!visitedNodes.has(otherId)) {
|
|
524
|
+
visitedNodes.add(otherId);
|
|
525
|
+
nextFrontier.push(otherId);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
frontier = nextFrontier;
|
|
530
|
+
}
|
|
531
|
+
const nodes = [...visitedNodes].flatMap(nodeId => {
|
|
532
|
+
const row = nodeStmt.get(nodeId);
|
|
533
|
+
return row ? [JSON.parse(str(row['data_json']))] : [];
|
|
534
|
+
});
|
|
535
|
+
const edges = collectedEdges.map(r => ({
|
|
536
|
+
edge_id: str(r['edge_id']),
|
|
537
|
+
source_id: str(r['source_id']),
|
|
538
|
+
target_id: str(r['target_id']),
|
|
539
|
+
rel_type: str(r['rel_type']),
|
|
540
|
+
direction: str(r['direction']),
|
|
541
|
+
label: r['label'] != null ? str(r['label']) : null,
|
|
542
|
+
properties: JSON.parse(str(r['properties_json']) || '{}'),
|
|
543
|
+
}));
|
|
544
|
+
return { nodes, edges };
|
|
545
|
+
}
|
|
546
|
+
function toolListNodeTypes(vaultPath) {
|
|
547
|
+
try {
|
|
548
|
+
const raw = readFileSync(join(vaultPath, '.filamental', 'entity_types.json'), 'utf-8');
|
|
549
|
+
return JSON.parse(raw);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
return {};
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function toolListConnectorTypes(vaultPath) {
|
|
556
|
+
try {
|
|
557
|
+
const raw = readFileSync(join(vaultPath, '.filamental', 'connector_types.json'), 'utf-8');
|
|
558
|
+
return JSON.parse(raw);
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return {};
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function toolGetVaultInfo(db, vaultPath) {
|
|
565
|
+
const nodeRow = db
|
|
566
|
+
.prepare('SELECT COUNT(*) AS node_count FROM entities')
|
|
567
|
+
.get();
|
|
568
|
+
const edgeRow = db
|
|
569
|
+
.prepare('SELECT COUNT(*) AS edge_count FROM relationships')
|
|
570
|
+
.get();
|
|
571
|
+
const nodeCount = Number(nodeRow['node_count'] ?? 0);
|
|
572
|
+
const edgeCount = Number(edgeRow['edge_count'] ?? 0);
|
|
573
|
+
let entityTypes = [];
|
|
574
|
+
let connectorTypes = [];
|
|
575
|
+
try {
|
|
576
|
+
const raw = readFileSync(join(vaultPath, '.filamental', 'entity_types.json'), 'utf-8');
|
|
577
|
+
entityTypes = Object.keys(JSON.parse(raw));
|
|
578
|
+
}
|
|
579
|
+
catch { /* non-fatal */ }
|
|
580
|
+
try {
|
|
581
|
+
const raw = readFileSync(join(vaultPath, '.filamental', 'connector_types.json'), 'utf-8');
|
|
582
|
+
connectorTypes = Object.keys(JSON.parse(raw));
|
|
583
|
+
}
|
|
584
|
+
catch { /* non-fatal */ }
|
|
585
|
+
return {
|
|
586
|
+
node_count: nodeCount,
|
|
587
|
+
edge_count: edgeCount,
|
|
588
|
+
entity_types: entityTypes,
|
|
589
|
+
connector_types: connectorTypes,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
// ── Write tool implementations ────────────────────────────────────────────────
|
|
593
|
+
function toolCreateNode(db, vaultPath, args) {
|
|
594
|
+
const name = validateName(args.name);
|
|
595
|
+
const entityType = typeof args.entity_type === 'string' ? args.entity_type : 'unclassified';
|
|
596
|
+
const status = args.status === 'archived' ? 'archived' : 'active';
|
|
597
|
+
const properties = args.properties ?? {};
|
|
598
|
+
const notes = typeof args.notes === 'string' ? args.notes : '';
|
|
599
|
+
const folder = typeof args.folder === 'string' ? args.folder : '';
|
|
600
|
+
const rawRels = args.relationships ?? [];
|
|
601
|
+
const relationships = rawRels.map(r => {
|
|
602
|
+
const rel = r;
|
|
603
|
+
const out = {
|
|
604
|
+
target: String(rel['target'] ?? ''),
|
|
605
|
+
rel_type: String(rel['rel_type'] ?? ''),
|
|
606
|
+
direction: String(rel['direction'] ?? 'none'),
|
|
607
|
+
properties: rel['properties'] ?? {},
|
|
608
|
+
};
|
|
609
|
+
if (rel['label'] != null)
|
|
610
|
+
out.label = String(rel['label']);
|
|
611
|
+
if (rel['influence'] != null)
|
|
612
|
+
out.influence = String(rel['influence']);
|
|
613
|
+
return out;
|
|
614
|
+
});
|
|
615
|
+
const id = randomUUID();
|
|
616
|
+
const now = new Date().toISOString();
|
|
617
|
+
const node = {
|
|
618
|
+
id,
|
|
619
|
+
name,
|
|
620
|
+
entity_type: entityType,
|
|
621
|
+
status,
|
|
622
|
+
created: now,
|
|
623
|
+
modified: now,
|
|
624
|
+
modified_by: 'filamental-mcp',
|
|
625
|
+
version: 1,
|
|
626
|
+
properties,
|
|
627
|
+
relationships,
|
|
628
|
+
attachments: [],
|
|
629
|
+
composition_mode: null,
|
|
630
|
+
child_view_id: null,
|
|
631
|
+
has_notes: notes.trim().length > 0,
|
|
632
|
+
};
|
|
633
|
+
const safeFilename = sanitiseFilename(name);
|
|
634
|
+
const resolvedVault = resolve(vaultPath);
|
|
635
|
+
const folderPath = folder ? resolve(vaultPath, folder) : resolvedVault;
|
|
636
|
+
if (folder) {
|
|
637
|
+
if (folderPath !== resolvedVault && !folderPath.startsWith(resolvedVault + sep)) {
|
|
638
|
+
throw new McpError(ErrorCode.InvalidParams, 'folder must not escape the vault root');
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
mkdirSync(folderPath, { recursive: true });
|
|
642
|
+
const basePath = join(folderPath, `${safeFilename}.md`);
|
|
643
|
+
const filePath = findAvailablePath(basePath);
|
|
644
|
+
const markdown = serialiseMarkdown(node, notes);
|
|
645
|
+
writeFileSync(filePath, markdown, 'utf-8');
|
|
646
|
+
upsertEntity(db, node, filePath, notes);
|
|
647
|
+
return { id, file_path: filePath };
|
|
648
|
+
}
|
|
649
|
+
function toolUpdateNode(db, vaultPath, args) {
|
|
650
|
+
const id = String(args.id ?? '').trim();
|
|
651
|
+
if (!id)
|
|
652
|
+
throw new McpError(ErrorCode.InvalidParams, 'id is required');
|
|
653
|
+
const entityRow = db
|
|
654
|
+
.prepare('SELECT file_path, data_json FROM entities WHERE id = ?')
|
|
655
|
+
.get(id);
|
|
656
|
+
if (!entityRow)
|
|
657
|
+
throw new McpError(ErrorCode.InvalidParams, `Node not found: ${id}`);
|
|
658
|
+
const filePath = str(entityRow['file_path']);
|
|
659
|
+
let existingNode;
|
|
660
|
+
let existingBody;
|
|
661
|
+
try {
|
|
662
|
+
const parsed = parseMarkdownFile(filePath);
|
|
663
|
+
existingNode = parsed.node;
|
|
664
|
+
existingBody = parsed.body;
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
// Fall back to data_json if file is unreadable
|
|
668
|
+
existingNode = JSON.parse(str(entityRow['data_json']));
|
|
669
|
+
existingBody = '';
|
|
670
|
+
}
|
|
671
|
+
// Snapshot before applying changes so we can skip the write if nothing changed
|
|
672
|
+
const beforeSnapshot = JSON.stringify({
|
|
673
|
+
name: existingNode.name,
|
|
674
|
+
entity_type: existingNode.entity_type,
|
|
675
|
+
status: existingNode.status,
|
|
676
|
+
properties: existingNode.properties,
|
|
677
|
+
relationships: existingNode.relationships,
|
|
678
|
+
display_name: existingNode.display_name ?? null,
|
|
679
|
+
});
|
|
680
|
+
const beforeBody = existingBody;
|
|
681
|
+
// Apply updates — only mutate supplied fields
|
|
682
|
+
if (typeof args.name === 'string') {
|
|
683
|
+
existingNode.name = validateName(args.name);
|
|
684
|
+
}
|
|
685
|
+
if (typeof args.entity_type === 'string') {
|
|
686
|
+
existingNode.entity_type = args.entity_type;
|
|
687
|
+
}
|
|
688
|
+
if (args.status === 'active' || args.status === 'archived') {
|
|
689
|
+
existingNode.status = args.status;
|
|
690
|
+
}
|
|
691
|
+
if (args.properties != null) {
|
|
692
|
+
existingNode.properties = args.properties;
|
|
693
|
+
}
|
|
694
|
+
if (Array.isArray(args.relationships)) {
|
|
695
|
+
existingNode.relationships = args.relationships.map(r => {
|
|
696
|
+
const rel = r;
|
|
697
|
+
const out = {
|
|
698
|
+
target: String(rel['target'] ?? ''),
|
|
699
|
+
rel_type: String(rel['rel_type'] ?? ''),
|
|
700
|
+
direction: String(rel['direction'] ?? 'none'),
|
|
701
|
+
properties: rel['properties'] ?? {},
|
|
702
|
+
};
|
|
703
|
+
if (rel['label'] != null)
|
|
704
|
+
out.label = String(rel['label']);
|
|
705
|
+
if (rel['influence'] != null)
|
|
706
|
+
out.influence = String(rel['influence']);
|
|
707
|
+
return out;
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
if (typeof args.notes === 'string') {
|
|
711
|
+
existingBody = args.notes;
|
|
712
|
+
}
|
|
713
|
+
if (typeof args.display_name === 'string') {
|
|
714
|
+
existingNode.display_name = args.display_name === '' ? null : args.display_name;
|
|
715
|
+
}
|
|
716
|
+
const afterSnapshot = JSON.stringify({
|
|
717
|
+
name: existingNode.name,
|
|
718
|
+
entity_type: existingNode.entity_type,
|
|
719
|
+
status: existingNode.status,
|
|
720
|
+
properties: existingNode.properties,
|
|
721
|
+
relationships: existingNode.relationships,
|
|
722
|
+
display_name: existingNode.display_name ?? null,
|
|
723
|
+
});
|
|
724
|
+
const changed = afterSnapshot !== beforeSnapshot || existingBody !== beforeBody;
|
|
725
|
+
if (changed) {
|
|
726
|
+
existingNode.modified = new Date().toISOString();
|
|
727
|
+
existingNode.modified_by = 'filamental-mcp';
|
|
728
|
+
existingNode.version += 1;
|
|
729
|
+
}
|
|
730
|
+
existingNode.has_notes = existingBody.trim().length > 0;
|
|
731
|
+
const markdown = serialiseMarkdown(existingNode, existingBody);
|
|
732
|
+
writeFileSync(filePath, markdown, 'utf-8');
|
|
733
|
+
upsertEntity(db, existingNode, filePath, existingBody);
|
|
734
|
+
return { id, file_path: filePath };
|
|
735
|
+
}
|
|
736
|
+
function toolDeleteNode(db, args) {
|
|
737
|
+
const id = String(args.id ?? '').trim();
|
|
738
|
+
if (!id)
|
|
739
|
+
throw new McpError(ErrorCode.InvalidParams, 'id is required');
|
|
740
|
+
const entityRow = db
|
|
741
|
+
.prepare('SELECT file_path FROM entities WHERE id = ?')
|
|
742
|
+
.get(id);
|
|
743
|
+
if (!entityRow)
|
|
744
|
+
throw new McpError(ErrorCode.InvalidParams, `Node not found: ${id}`);
|
|
745
|
+
const filePath = str(entityRow['file_path']);
|
|
746
|
+
try {
|
|
747
|
+
if (existsSync(filePath)) {
|
|
748
|
+
unlinkSync(filePath);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
throw new McpError(ErrorCode.InternalError, `Failed to delete file: ${err}`);
|
|
753
|
+
}
|
|
754
|
+
deleteEntity(db, id);
|
|
755
|
+
return { deleted: true, file_path: filePath };
|
|
756
|
+
}
|
|
757
|
+
// ── Edge tool implementations ─────────────────────────────────────────────────
|
|
758
|
+
function toolCreateEdge(db, args) {
|
|
759
|
+
const sourceId = String(args.source_id ?? '').trim();
|
|
760
|
+
const targetId = String(args.target_id ?? '').trim();
|
|
761
|
+
const relType = String(args.rel_type ?? '').trim();
|
|
762
|
+
if (!sourceId)
|
|
763
|
+
throw new McpError(ErrorCode.InvalidParams, 'source_id is required');
|
|
764
|
+
if (!targetId)
|
|
765
|
+
throw new McpError(ErrorCode.InvalidParams, 'target_id is required');
|
|
766
|
+
if (!relType)
|
|
767
|
+
throw new McpError(ErrorCode.InvalidParams, 'rel_type is required');
|
|
768
|
+
// Verify both nodes exist
|
|
769
|
+
const srcRow = db.prepare('SELECT file_path, data_json FROM entities WHERE id = ?').get(sourceId);
|
|
770
|
+
if (!srcRow)
|
|
771
|
+
throw new McpError(ErrorCode.InvalidParams, `Source node not found: ${sourceId}`);
|
|
772
|
+
const tgtExists = db.prepare('SELECT 1 FROM entities WHERE id = ?').get(targetId);
|
|
773
|
+
if (!tgtExists)
|
|
774
|
+
throw new McpError(ErrorCode.InvalidParams, `Target node not found: ${targetId}`);
|
|
775
|
+
// Check for duplicate
|
|
776
|
+
const edgeId = `${sourceId}__${targetId}__${relType}`;
|
|
777
|
+
const existing = db.prepare('SELECT 1 FROM relationships WHERE edge_id = ?').get(edgeId);
|
|
778
|
+
if (existing)
|
|
779
|
+
throw new McpError(ErrorCode.InvalidParams, `Edge already exists: ${edgeId}`);
|
|
780
|
+
const filePath = str(srcRow['file_path']);
|
|
781
|
+
let node;
|
|
782
|
+
let body;
|
|
783
|
+
try {
|
|
784
|
+
const parsed = parseMarkdownFile(filePath);
|
|
785
|
+
node = parsed.node;
|
|
786
|
+
body = parsed.body;
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
node = JSON.parse(str(srcRow['data_json']));
|
|
790
|
+
body = '';
|
|
791
|
+
}
|
|
792
|
+
const newRel = {
|
|
793
|
+
target: targetId,
|
|
794
|
+
rel_type: relType,
|
|
795
|
+
direction: String(args.direction ?? 'none'),
|
|
796
|
+
properties: args.properties ?? {},
|
|
797
|
+
};
|
|
798
|
+
if (args.label != null)
|
|
799
|
+
newRel.label = String(args.label);
|
|
800
|
+
if (args.influence != null)
|
|
801
|
+
newRel.influence = String(args.influence);
|
|
802
|
+
node.relationships.push(newRel);
|
|
803
|
+
node.modified = new Date().toISOString();
|
|
804
|
+
node.modified_by = 'filamental-mcp';
|
|
805
|
+
node.version += 1;
|
|
806
|
+
writeFileSync(filePath, serialiseMarkdown(node, body), 'utf-8');
|
|
807
|
+
upsertEntity(db, node, filePath, body);
|
|
808
|
+
return { edge_id: edgeId, source_id: sourceId, target_id: targetId };
|
|
809
|
+
}
|
|
810
|
+
function toolDeleteEdge(db, args) {
|
|
811
|
+
const sourceId = String(args.source_id ?? '').trim();
|
|
812
|
+
const targetId = String(args.target_id ?? '').trim();
|
|
813
|
+
const relType = String(args.rel_type ?? '').trim();
|
|
814
|
+
if (!sourceId)
|
|
815
|
+
throw new McpError(ErrorCode.InvalidParams, 'source_id is required');
|
|
816
|
+
if (!targetId)
|
|
817
|
+
throw new McpError(ErrorCode.InvalidParams, 'target_id is required');
|
|
818
|
+
if (!relType)
|
|
819
|
+
throw new McpError(ErrorCode.InvalidParams, 'rel_type is required');
|
|
820
|
+
const edgeId = `${sourceId}__${targetId}__${relType}`;
|
|
821
|
+
const edgeRow = db.prepare('SELECT 1 FROM relationships WHERE edge_id = ?').get(edgeId);
|
|
822
|
+
if (!edgeRow)
|
|
823
|
+
throw new McpError(ErrorCode.InvalidParams, `Edge not found: ${edgeId}`);
|
|
824
|
+
const srcRow = db.prepare('SELECT file_path, data_json FROM entities WHERE id = ?').get(sourceId);
|
|
825
|
+
if (!srcRow)
|
|
826
|
+
throw new McpError(ErrorCode.InvalidParams, `Source node not found: ${sourceId}`);
|
|
827
|
+
const filePath = str(srcRow['file_path']);
|
|
828
|
+
let node;
|
|
829
|
+
let body;
|
|
830
|
+
try {
|
|
831
|
+
const parsed = parseMarkdownFile(filePath);
|
|
832
|
+
node = parsed.node;
|
|
833
|
+
body = parsed.body;
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
node = JSON.parse(str(srcRow['data_json']));
|
|
837
|
+
body = '';
|
|
838
|
+
}
|
|
839
|
+
const before = node.relationships.length;
|
|
840
|
+
node.relationships = node.relationships.filter(r => !(r.target === targetId && r.rel_type === relType));
|
|
841
|
+
if (node.relationships.length === before) {
|
|
842
|
+
// Edge was in SQLite but not in file — remove from DB only (repair path)
|
|
843
|
+
db.prepare('DELETE FROM relationships WHERE edge_id = ?').run(edgeId);
|
|
844
|
+
return { deleted: true, edge_id: edgeId };
|
|
845
|
+
}
|
|
846
|
+
node.modified = new Date().toISOString();
|
|
847
|
+
node.modified_by = 'filamental-mcp';
|
|
848
|
+
node.version += 1;
|
|
849
|
+
writeFileSync(filePath, serialiseMarkdown(node, body), 'utf-8');
|
|
850
|
+
upsertEntity(db, node, filePath, body);
|
|
851
|
+
return { deleted: true, edge_id: edgeId };
|
|
852
|
+
}
|
|
853
|
+
// ── Server factory ────────────────────────────────────────────────────────────
|
|
854
|
+
export function createServer(db, vaultPath) {
|
|
855
|
+
const server = new Server({ name: 'filamental', version: '0.2.0' }, { capabilities: { tools: {} } });
|
|
856
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
857
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
858
|
+
const { name, arguments: args = {} } = request.params;
|
|
859
|
+
const a = args;
|
|
860
|
+
try {
|
|
861
|
+
let result;
|
|
862
|
+
switch (name) {
|
|
863
|
+
case 'search_nodes':
|
|
864
|
+
result = toolSearchNodes(db, a);
|
|
865
|
+
break;
|
|
866
|
+
case 'get_node':
|
|
867
|
+
result = toolGetNode(db, a);
|
|
868
|
+
break;
|
|
869
|
+
case 'get_connections':
|
|
870
|
+
result = toolGetConnections(db, a);
|
|
871
|
+
break;
|
|
872
|
+
case 'get_subgraph':
|
|
873
|
+
result = toolGetSubgraph(db, a);
|
|
874
|
+
break;
|
|
875
|
+
case 'list_node_types':
|
|
876
|
+
result = toolListNodeTypes(vaultPath);
|
|
877
|
+
break;
|
|
878
|
+
case 'list_connector_types':
|
|
879
|
+
result = toolListConnectorTypes(vaultPath);
|
|
880
|
+
break;
|
|
881
|
+
case 'get_vault_info':
|
|
882
|
+
result = toolGetVaultInfo(db, vaultPath);
|
|
883
|
+
break;
|
|
884
|
+
case 'create_node':
|
|
885
|
+
result = toolCreateNode(db, vaultPath, a);
|
|
886
|
+
break;
|
|
887
|
+
case 'update_node':
|
|
888
|
+
result = toolUpdateNode(db, vaultPath, a);
|
|
889
|
+
break;
|
|
890
|
+
case 'delete_node':
|
|
891
|
+
result = toolDeleteNode(db, a);
|
|
892
|
+
break;
|
|
893
|
+
case 'create_edge':
|
|
894
|
+
result = toolCreateEdge(db, a);
|
|
895
|
+
break;
|
|
896
|
+
case 'delete_edge':
|
|
897
|
+
result = toolDeleteEdge(db, a);
|
|
898
|
+
break;
|
|
899
|
+
default:
|
|
900
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
901
|
+
}
|
|
902
|
+
return {
|
|
903
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
catch (err) {
|
|
907
|
+
if (err instanceof McpError)
|
|
908
|
+
throw err;
|
|
909
|
+
throw new McpError(ErrorCode.InternalError, String(err));
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
return server;
|
|
913
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "filamental-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server exposing your Filamental vault to AI assistants (Claude, Claude Code, etc.)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"filamental-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"filamental",
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"knowledge-graph",
|
|
19
|
+
"ai"
|
|
20
|
+
],
|
|
21
|
+
"author": "BlackCat Marketing LLC",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/Scottnine/filamental.git"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.0.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc",
|
|
35
|
+
"bundle": "tsc && npx @vercel/ncc build dist/index.js -o bundle --no-source-map-register && node scripts/patch-bundle.cjs",
|
|
36
|
+
"dev": "tsx src/index.ts"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "latest",
|
|
40
|
+
"better-sqlite3": "^12.10.0",
|
|
41
|
+
"js-yaml": "^4.2.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
45
|
+
"@types/js-yaml": "^4.0.9",
|
|
46
|
+
"@types/node": "^22.10.0",
|
|
47
|
+
"@vercel/ncc": "^0.38.4",
|
|
48
|
+
"tsx": "^4.0.0",
|
|
49
|
+
"typescript": "^5.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|