Skip to content

Commit b56aab1

Browse files
committed
fix: sanitize inputs, add length limits, and annotate destructive tools
Address security findings from #106: mitigate prompt injection via observations by sanitizing all inputs and enforcing length constraints at both schema and DB layers. Add MCP destructiveHint annotations to delete_entity and delete_relation so hosts can gate these calls. Also fix tsconfig for TypeScript 6 compatibility.
1 parent 84fe6c7 commit b56aab1

4 files changed

Lines changed: 163 additions & 47 deletions

File tree

.changeset/grumpy-mirrors-bathe.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'mcp-memory-libsql': patch
3+
---
4+
5+
Security: sanitize inputs, add length limits, annotate destructive
6+
tools, fix tsconfig for TS6

src/db/client.ts

Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
import { createClient } from '@libsql/client';
22
import { Entity, Relation } from '../types/index.js';
33

4+
// Input limits
5+
const MAX_ENTITY_NAME_LENGTH = 256;
6+
const MAX_ENTITY_TYPE_LENGTH = 256;
7+
const MAX_OBSERVATION_LENGTH = 4096;
8+
const MAX_OBSERVATIONS_PER_ENTITY = 100;
9+
const MAX_RELATION_TYPE_LENGTH = 256;
10+
411
// Types for configuration
512
interface DatabaseConfig {
613
url: string;
714
authToken?: string;
815
}
916

17+
/**
18+
* Sanitize a string to mitigate prompt injection.
19+
* Strips control characters and common injection markers
20+
* while preserving normal content.
21+
*/
22+
function sanitize_input(input: string): string {
23+
return (
24+
input
25+
// Strip non-printable control chars (except newline, tab)
26+
.replace(/[^\P{C}\n\t]/gu, '')
27+
// Collapse excessive whitespace/newlines
28+
.replace(/\n{3,}/g, '\n\n')
29+
.trim()
30+
);
31+
}
32+
1033
export class DatabaseManager {
1134
private static instance: DatabaseManager;
1235
private client;
@@ -41,23 +64,39 @@ export class DatabaseManager {
4164
): Promise<void> {
4265
try {
4366
for (const entity of entities) {
44-
// Validate entity name
67+
// Validate and sanitize entity name
4568
if (
4669
!entity.name ||
4770
typeof entity.name !== 'string' ||
4871
entity.name.trim() === ''
4972
) {
5073
throw new Error('Entity name must be a non-empty string');
5174
}
75+
const safe_name = sanitize_input(entity.name).slice(
76+
0,
77+
MAX_ENTITY_NAME_LENGTH,
78+
);
79+
if (safe_name === '') {
80+
throw new Error('Entity name is empty after sanitization');
81+
}
5282

53-
// Validate entity type
83+
// Validate and sanitize entity type
5484
if (
5585
!entity.entityType ||
5686
typeof entity.entityType !== 'string' ||
5787
entity.entityType.trim() === ''
5888
) {
5989
throw new Error(
60-
`Invalid entity type for entity "${entity.name}"`,
90+
`Invalid entity type for entity "${safe_name}"`,
91+
);
92+
}
93+
const safe_type = sanitize_input(entity.entityType).slice(
94+
0,
95+
MAX_ENTITY_TYPE_LENGTH,
96+
);
97+
if (safe_type === '') {
98+
throw new Error(
99+
`Entity type is empty after sanitization for entity "${safe_name}"`,
61100
);
62101
}
63102

@@ -67,49 +106,66 @@ export class DatabaseManager {
67106
entity.observations.length === 0
68107
) {
69108
throw new Error(
70-
`Entity "${entity.name}" must have at least one observation`,
109+
`Entity "${safe_name}" must have at least one observation`,
71110
);
72111
}
73112

74113
if (
75-
!entity.observations.every(
76-
(obs) => typeof obs === 'string' && obs.trim() !== '',
77-
)
114+
entity.observations.length > MAX_OBSERVATIONS_PER_ENTITY
78115
) {
79116
throw new Error(
80-
`Entity "${entity.name}" has invalid observations. All observations must be non-empty strings`,
117+
`Entity "${safe_name}" exceeds maximum of ${MAX_OBSERVATIONS_PER_ENTITY} observations`,
81118
);
82119
}
83120

121+
// Sanitize observations and validate
122+
const safe_observations = entity.observations.map((obs) => {
123+
if (typeof obs !== 'string' || obs.trim() === '') {
124+
throw new Error(
125+
`Entity "${safe_name}" has invalid observations. All observations must be non-empty strings`,
126+
);
127+
}
128+
const sanitized = sanitize_input(obs).slice(
129+
0,
130+
MAX_OBSERVATION_LENGTH,
131+
);
132+
if (sanitized === '') {
133+
throw new Error(
134+
`Entity "${safe_name}" has an observation that is empty after sanitization`,
135+
);
136+
}
137+
return sanitized;
138+
});
139+
84140
// Start a transaction
85141
const txn = await this.client.transaction('write');
86142

87143
try {
88144
// First try to update
89145
const result = await txn.execute({
90146
sql: 'UPDATE entities SET entity_type = ? WHERE name = ?',
91-
args: [entity.entityType, entity.name],
147+
args: [safe_type, safe_name],
92148
});
93149

94150
// If no rows affected, do insert
95151
if (result.rowsAffected === 0) {
96152
await txn.execute({
97153
sql: 'INSERT INTO entities (name, entity_type) VALUES (?, ?)',
98-
args: [entity.name, entity.entityType],
154+
args: [safe_name, safe_type],
99155
});
100156
}
101157

102158
// Clear old observations
103159
await txn.execute({
104160
sql: 'DELETE FROM observations WHERE entity_name = ?',
105-
args: [entity.name],
161+
args: [safe_name],
106162
});
107163

108164
// Add new observations
109-
for (const observation of entity.observations) {
165+
for (const observation of safe_observations) {
110166
await txn.execute({
111167
sql: 'INSERT INTO observations (entity_name, content) VALUES (?, ?)',
112-
args: [entity.name, observation],
168+
args: [safe_name, observation],
113169
});
114170
}
115171

@@ -243,11 +299,32 @@ export class DatabaseManager {
243299
try {
244300
if (relations.length === 0) return;
245301

246-
// Prepare batch statements for all relations
247-
const batch_statements = relations.map((relation) => ({
248-
sql: 'INSERT INTO relations (source, target, relation_type) VALUES (?, ?, ?)',
249-
args: [relation.from, relation.to, relation.relationType],
250-
}));
302+
// Sanitize and validate relation inputs
303+
const batch_statements = relations.map((relation) => {
304+
const safe_from = sanitize_input(relation.from).slice(
305+
0,
306+
MAX_ENTITY_NAME_LENGTH,
307+
);
308+
const safe_to = sanitize_input(relation.to).slice(
309+
0,
310+
MAX_ENTITY_NAME_LENGTH,
311+
);
312+
const safe_type = sanitize_input(relation.relationType).slice(
313+
0,
314+
MAX_RELATION_TYPE_LENGTH,
315+
);
316+
317+
if (!safe_from || !safe_to || !safe_type) {
318+
throw new Error(
319+
'Relation source, target, and type must be non-empty strings',
320+
);
321+
}
322+
323+
return {
324+
sql: 'INSERT INTO relations (source, target, relation_type) VALUES (?, ?, ?)',
325+
args: [safe_from, safe_to, safe_type],
326+
};
327+
});
251328

252329
// Execute all inserts in a single batch transaction
253330
await this.client.batch(batch_statements, 'write');
@@ -358,9 +435,8 @@ export class DatabaseManager {
358435
relations: Relation[];
359436
}> {
360437
const recent_entities = await this.get_recent_entities();
361-
const relations = await this.get_relations_for_entities(
362-
recent_entities,
363-
);
438+
const relations =
439+
await this.get_relations_for_entities(recent_entities);
364440
return { entities: recent_entities, relations };
365441
}
366442

@@ -385,9 +461,8 @@ export class DatabaseManager {
385461
return { entities: [], relations: [] };
386462
}
387463

388-
const relations = await this.get_relations_for_entities(
389-
entities,
390-
);
464+
const relations =
465+
await this.get_relations_for_entities(entities);
391466
return { entities, relations };
392467
} catch (error) {
393468
throw new Error(

src/index.ts

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,40 +19,49 @@ const package_json = JSON.parse(
1919
);
2020
const { name, version } = package_json;
2121

22-
// Define schemas
22+
// Define schemas with length constraints
2323
const CreateEntitiesSchema = v.object({
24-
entities: v.array(
25-
v.object({
26-
name: v.string(),
27-
entityType: v.string(),
28-
observations: v.array(v.string()),
29-
}),
24+
entities: v.pipe(
25+
v.array(
26+
v.object({
27+
name: v.pipe(v.string(), v.maxLength(256)),
28+
entityType: v.pipe(v.string(), v.maxLength(256)),
29+
observations: v.pipe(
30+
v.array(v.pipe(v.string(), v.maxLength(4096))),
31+
v.maxLength(100),
32+
),
33+
}),
34+
),
35+
v.maxLength(50),
3036
),
3137
});
3238

3339
const SearchNodesSchema = v.object({
34-
query: v.string(),
35-
limit: v.optional(v.number()),
40+
query: v.pipe(v.string(), v.maxLength(512)),
41+
limit: v.optional(v.pipe(v.number(), v.maxValue(50))),
3642
});
3743

3844
const CreateRelationsSchema = v.object({
39-
relations: v.array(
40-
v.object({
41-
source: v.string(),
42-
target: v.string(),
43-
type: v.string(),
44-
}),
45+
relations: v.pipe(
46+
v.array(
47+
v.object({
48+
source: v.pipe(v.string(), v.maxLength(256)),
49+
target: v.pipe(v.string(), v.maxLength(256)),
50+
type: v.pipe(v.string(), v.maxLength(256)),
51+
}),
52+
),
53+
v.maxLength(100),
4554
),
4655
});
4756

4857
const DeleteEntitySchema = v.object({
49-
name: v.string(),
58+
name: v.pipe(v.string(), v.maxLength(256)),
5059
});
5160

5261
const DeleteRelationSchema = v.object({
53-
source: v.string(),
54-
target: v.string(),
55-
type: v.string(),
62+
source: v.pipe(v.string(), v.maxLength(256)),
63+
target: v.pipe(v.string(), v.maxLength(256)),
64+
type: v.pipe(v.string(), v.maxLength(256)),
5665
});
5766

5867
function setupTools(server: McpServer<any>, db: DatabaseManager) {
@@ -62,6 +71,10 @@ function setupTools(server: McpServer<any>, db: DatabaseManager) {
6271
name: 'create_entities',
6372
description: 'Create new entities with observations',
6473
schema: CreateEntitiesSchema,
74+
annotations: {
75+
readOnlyHint: false,
76+
idempotentHint: true,
77+
},
6578
},
6679
async ({ entities }) => {
6780
try {
@@ -105,6 +118,9 @@ function setupTools(server: McpServer<any>, db: DatabaseManager) {
105118
description:
106119
'Search for entities and their relations using text search with relevance ranking',
107120
schema: SearchNodesSchema,
121+
annotations: {
122+
readOnlyHint: true,
123+
},
108124
},
109125
async ({ query, limit }) => {
110126
try {
@@ -146,6 +162,9 @@ function setupTools(server: McpServer<any>, db: DatabaseManager) {
146162
{
147163
name: 'read_graph',
148164
description: 'Get recent entities and their relations',
165+
annotations: {
166+
readOnlyHint: true,
167+
},
149168
},
150169
async () => {
151170
try {
@@ -188,6 +207,10 @@ function setupTools(server: McpServer<any>, db: DatabaseManager) {
188207
name: 'create_relations',
189208
description: 'Create relations between entities',
190209
schema: CreateRelationsSchema,
210+
annotations: {
211+
readOnlyHint: false,
212+
idempotentHint: false,
213+
},
191214
},
192215
async ({ relations }) => {
193216
try {
@@ -235,8 +258,13 @@ function setupTools(server: McpServer<any>, db: DatabaseManager) {
235258
{
236259
name: 'delete_entity',
237260
description:
238-
'Delete an entity and all its associated data (observations and relations)',
261+
'Delete an entity and all its associated data (observations and relations). This is a destructive operation that cannot be undone.',
239262
schema: DeleteEntitySchema,
263+
annotations: {
264+
destructiveHint: true,
265+
readOnlyHint: false,
266+
idempotentHint: true,
267+
},
240268
},
241269
async ({ name }) => {
242270
try {
@@ -277,8 +305,14 @@ function setupTools(server: McpServer<any>, db: DatabaseManager) {
277305
server.tool<typeof DeleteRelationSchema>(
278306
{
279307
name: 'delete_relation',
280-
description: 'Delete a specific relation between entities',
308+
description:
309+
'Delete a specific relation between entities. This is a destructive operation that cannot be undone.',
281310
schema: DeleteRelationSchema,
311+
annotations: {
312+
destructiveHint: true,
313+
readOnlyHint: false,
314+
idempotentHint: true,
315+
},
282316
},
283317
async ({ source, target, type }) => {
284318
try {

tsconfig.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
{
22
"compilerOptions": {
33
"target": "ES2020",
4-
"module": "ES2020",
5-
"moduleResolution": "node",
4+
"module": "Node16",
5+
"moduleResolution": "Node16",
6+
"types": ["node"],
67
"esModuleInterop": true,
78
"strict": true,
89
"outDir": "dist",

0 commit comments

Comments
 (0)