chron-mcp 0.1.7 → 0.1.8

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 CHANGED
@@ -150,6 +150,23 @@ Chron ships with `skills/chron.skill.md` — a plain-text instruction file that
150
150
  | `log_exchange` | Log a user/assistant pair atomically (for batch imports) |
151
151
  | `list_sessions` | List all sessions ordered by most recently active |
152
152
  | `get_session_history` | Retrieve the full timestamped log for a session |
153
+ | `verify_session` | Verify the tamper-evident hash chain — detects any post-log edits |
154
+
155
+ ---
156
+
157
+ ## Tamper-evident hash chaining
158
+
159
+ Every message is linked to the previous one via a SHA-256 chain:
160
+
161
+ ```
162
+ content_hash = SHA256(session_id | role | content | created_at | prev_hash)
163
+ ```
164
+
165
+ `verify_session` walks the chain and returns:
166
+ - `valid: true` — no messages were modified after logging
167
+ - `valid: false, first_break: <id>` — exact row ID of the first tampered message
168
+
169
+ This turns your local log into a verifiable audit artifact. Any edit to a stored message — content, timestamp, or role — breaks the chain and is detected immediately.
153
170
 
154
171
  ---
155
172
 
@@ -1,3 +1,5 @@
1
1
  import * as schema from './schema';
2
- export declare function initDb(dbPath?: string): Promise<import("drizzle-orm/libsql").LibSQLDatabase<typeof schema>>;
2
+ export declare function initDb(dbPath?: string): Promise<import("drizzle-orm/libsql").LibSQLDatabase<typeof schema> & {
3
+ $client: import("@libsql/client").Client;
4
+ }>;
3
5
  export type Db = Awaited<ReturnType<typeof initDb>>;
package/dist/db/index.js CHANGED
@@ -60,6 +60,8 @@ const CREATE_SQL = [
60
60
  role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
61
61
  content TEXT NOT NULL,
62
62
  created_at TEXT NOT NULL,
63
+ prev_hash TEXT,
64
+ content_hash TEXT,
63
65
  FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
64
66
  )`,
65
67
  `CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title ON sessions(title)`,
@@ -76,5 +78,14 @@ async function initDb(dbPath) {
76
78
  for (const sql of CREATE_SQL) {
77
79
  await client.execute(sql);
78
80
  }
81
+ // Migrate existing messages tables that predate hash chaining columns
82
+ const tableInfo = await client.execute('PRAGMA table_info(messages)');
83
+ const columns = tableInfo.rows.map((r) => r[1]);
84
+ if (!columns.includes('prev_hash')) {
85
+ await client.execute('ALTER TABLE messages ADD COLUMN prev_hash TEXT');
86
+ }
87
+ if (!columns.includes('content_hash')) {
88
+ await client.execute('ALTER TABLE messages ADD COLUMN content_hash TEXT');
89
+ }
79
90
  return (0, libsql_1.drizzle)(client, { schema });
80
91
  }
@@ -11,9 +11,16 @@ export declare const sessions: import("drizzle-orm/sqlite-core").SQLiteTableWith
11
11
  driverParam: string;
12
12
  notNull: true;
13
13
  hasDefault: false;
14
+ isPrimaryKey: true;
15
+ isAutoincrement: false;
16
+ hasRuntimeDefault: false;
14
17
  enumValues: [string, ...string[]];
15
18
  baseColumn: never;
16
- }, object>;
19
+ identity: undefined;
20
+ generated: undefined;
21
+ }, {}, {
22
+ length: number | undefined;
23
+ }>;
17
24
  title: import("drizzle-orm/sqlite-core").SQLiteColumn<{
18
25
  name: "title";
19
26
  tableName: "sessions";
@@ -23,9 +30,16 @@ export declare const sessions: import("drizzle-orm/sqlite-core").SQLiteTableWith
23
30
  driverParam: string;
24
31
  notNull: true;
25
32
  hasDefault: false;
33
+ isPrimaryKey: false;
34
+ isAutoincrement: false;
35
+ hasRuntimeDefault: false;
26
36
  enumValues: [string, ...string[]];
27
37
  baseColumn: never;
28
- }, object>;
38
+ identity: undefined;
39
+ generated: undefined;
40
+ }, {}, {
41
+ length: number | undefined;
42
+ }>;
29
43
  ai_tool: import("drizzle-orm/sqlite-core").SQLiteColumn<{
30
44
  name: "ai_tool";
31
45
  tableName: "sessions";
@@ -35,9 +49,16 @@ export declare const sessions: import("drizzle-orm/sqlite-core").SQLiteTableWith
35
49
  driverParam: string;
36
50
  notNull: false;
37
51
  hasDefault: false;
52
+ isPrimaryKey: false;
53
+ isAutoincrement: false;
54
+ hasRuntimeDefault: false;
38
55
  enumValues: [string, ...string[]];
39
56
  baseColumn: never;
40
- }, object>;
57
+ identity: undefined;
58
+ generated: undefined;
59
+ }, {}, {
60
+ length: number | undefined;
61
+ }>;
41
62
  created_at: import("drizzle-orm/sqlite-core").SQLiteColumn<{
42
63
  name: "created_at";
43
64
  tableName: "sessions";
@@ -47,9 +68,16 @@ export declare const sessions: import("drizzle-orm/sqlite-core").SQLiteTableWith
47
68
  driverParam: string;
48
69
  notNull: true;
49
70
  hasDefault: false;
71
+ isPrimaryKey: false;
72
+ isAutoincrement: false;
73
+ hasRuntimeDefault: false;
50
74
  enumValues: [string, ...string[]];
51
75
  baseColumn: never;
52
- }, object>;
76
+ identity: undefined;
77
+ generated: undefined;
78
+ }, {}, {
79
+ length: number | undefined;
80
+ }>;
53
81
  updated_at: import("drizzle-orm/sqlite-core").SQLiteColumn<{
54
82
  name: "updated_at";
55
83
  tableName: "sessions";
@@ -59,9 +87,16 @@ export declare const sessions: import("drizzle-orm/sqlite-core").SQLiteTableWith
59
87
  driverParam: string;
60
88
  notNull: true;
61
89
  hasDefault: false;
90
+ isPrimaryKey: false;
91
+ isAutoincrement: false;
92
+ hasRuntimeDefault: false;
62
93
  enumValues: [string, ...string[]];
63
94
  baseColumn: never;
64
- }, object>;
95
+ identity: undefined;
96
+ generated: undefined;
97
+ }, {}, {
98
+ length: number | undefined;
99
+ }>;
65
100
  };
66
101
  dialect: "sqlite";
67
102
  }>;
@@ -78,9 +113,16 @@ export declare const messages: import("drizzle-orm/sqlite-core").SQLiteTableWith
78
113
  driverParam: string;
79
114
  notNull: true;
80
115
  hasDefault: false;
116
+ isPrimaryKey: true;
117
+ isAutoincrement: false;
118
+ hasRuntimeDefault: false;
81
119
  enumValues: [string, ...string[]];
82
120
  baseColumn: never;
83
- }, object>;
121
+ identity: undefined;
122
+ generated: undefined;
123
+ }, {}, {
124
+ length: number | undefined;
125
+ }>;
84
126
  session_id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
85
127
  name: "session_id";
86
128
  tableName: "messages";
@@ -90,9 +132,16 @@ export declare const messages: import("drizzle-orm/sqlite-core").SQLiteTableWith
90
132
  driverParam: string;
91
133
  notNull: true;
92
134
  hasDefault: false;
135
+ isPrimaryKey: false;
136
+ isAutoincrement: false;
137
+ hasRuntimeDefault: false;
93
138
  enumValues: [string, ...string[]];
94
139
  baseColumn: never;
95
- }, object>;
140
+ identity: undefined;
141
+ generated: undefined;
142
+ }, {}, {
143
+ length: number | undefined;
144
+ }>;
96
145
  role: import("drizzle-orm/sqlite-core").SQLiteColumn<{
97
146
  name: "role";
98
147
  tableName: "messages";
@@ -102,9 +151,16 @@ export declare const messages: import("drizzle-orm/sqlite-core").SQLiteTableWith
102
151
  driverParam: string;
103
152
  notNull: true;
104
153
  hasDefault: false;
154
+ isPrimaryKey: false;
155
+ isAutoincrement: false;
156
+ hasRuntimeDefault: false;
105
157
  enumValues: ["user", "assistant"];
106
158
  baseColumn: never;
107
- }, object>;
159
+ identity: undefined;
160
+ generated: undefined;
161
+ }, {}, {
162
+ length: number | undefined;
163
+ }>;
108
164
  content: import("drizzle-orm/sqlite-core").SQLiteColumn<{
109
165
  name: "content";
110
166
  tableName: "messages";
@@ -114,9 +170,16 @@ export declare const messages: import("drizzle-orm/sqlite-core").SQLiteTableWith
114
170
  driverParam: string;
115
171
  notNull: true;
116
172
  hasDefault: false;
173
+ isPrimaryKey: false;
174
+ isAutoincrement: false;
175
+ hasRuntimeDefault: false;
117
176
  enumValues: [string, ...string[]];
118
177
  baseColumn: never;
119
- }, object>;
178
+ identity: undefined;
179
+ generated: undefined;
180
+ }, {}, {
181
+ length: number | undefined;
182
+ }>;
120
183
  created_at: import("drizzle-orm/sqlite-core").SQLiteColumn<{
121
184
  name: "created_at";
122
185
  tableName: "messages";
@@ -126,9 +189,54 @@ export declare const messages: import("drizzle-orm/sqlite-core").SQLiteTableWith
126
189
  driverParam: string;
127
190
  notNull: true;
128
191
  hasDefault: false;
192
+ isPrimaryKey: false;
193
+ isAutoincrement: false;
194
+ hasRuntimeDefault: false;
129
195
  enumValues: [string, ...string[]];
130
196
  baseColumn: never;
131
- }, object>;
197
+ identity: undefined;
198
+ generated: undefined;
199
+ }, {}, {
200
+ length: number | undefined;
201
+ }>;
202
+ prev_hash: import("drizzle-orm/sqlite-core").SQLiteColumn<{
203
+ name: "prev_hash";
204
+ tableName: "messages";
205
+ dataType: "string";
206
+ columnType: "SQLiteText";
207
+ data: string;
208
+ driverParam: string;
209
+ notNull: false;
210
+ hasDefault: false;
211
+ isPrimaryKey: false;
212
+ isAutoincrement: false;
213
+ hasRuntimeDefault: false;
214
+ enumValues: [string, ...string[]];
215
+ baseColumn: never;
216
+ identity: undefined;
217
+ generated: undefined;
218
+ }, {}, {
219
+ length: number | undefined;
220
+ }>;
221
+ content_hash: import("drizzle-orm/sqlite-core").SQLiteColumn<{
222
+ name: "content_hash";
223
+ tableName: "messages";
224
+ dataType: "string";
225
+ columnType: "SQLiteText";
226
+ data: string;
227
+ driverParam: string;
228
+ notNull: false;
229
+ hasDefault: false;
230
+ isPrimaryKey: false;
231
+ isAutoincrement: false;
232
+ hasRuntimeDefault: false;
233
+ enumValues: [string, ...string[]];
234
+ baseColumn: never;
235
+ identity: undefined;
236
+ generated: undefined;
237
+ }, {}, {
238
+ length: number | undefined;
239
+ }>;
132
240
  };
133
241
  dialect: "sqlite";
134
242
  }>;
package/dist/db/schema.js CHANGED
@@ -15,4 +15,6 @@ exports.messages = (0, sqlite_core_1.sqliteTable)('messages', {
15
15
  role: (0, sqlite_core_1.text)('role', { enum: ['user', 'assistant'] }).notNull(),
16
16
  content: (0, sqlite_core_1.text)('content').notNull(),
17
17
  created_at: (0, sqlite_core_1.text)('created_at').notNull(),
18
+ prev_hash: (0, sqlite_core_1.text)('prev_hash'),
19
+ content_hash: (0, sqlite_core_1.text)('content_hash'),
18
20
  });
package/dist/server.js CHANGED
@@ -5,6 +5,7 @@ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
5
5
  const zod_1 = require("zod");
6
6
  const sessions_1 = require("./tools/sessions");
7
7
  const messages_1 = require("./tools/messages");
8
+ const verify_1 = require("./tools/verify");
8
9
  const roleEnum = zod_1.z.enum(['user', 'assistant']);
9
10
  function createServer(db) {
10
11
  const server = new mcp_js_1.McpServer({
@@ -34,5 +35,8 @@ function createServer(db) {
34
35
  session_id: zod_1.z.string(),
35
36
  limit: zod_1.z.number().int().positive().optional().describe('Return only the most recent N messages'),
36
37
  }, (0, sessions_1.getSessionHistory)(db));
38
+ server.tool('verify_session', 'Verify the tamper-evident hash chain for a session. Returns valid=true if no rows were edited after logging, or the first broken link if tampering is detected.', {
39
+ session_id: zod_1.z.string().describe('Session ID to verify'),
40
+ }, (0, verify_1.verifySession)(db));
37
41
  return server;
38
42
  }
@@ -6,26 +6,36 @@ const drizzle_orm_1 = require("drizzle-orm");
6
6
  const uuid_1 = require("uuid");
7
7
  const schema_1 = require("../db/schema");
8
8
  const time_1 = require("../utils/time");
9
+ const hash_1 = require("../utils/hash");
9
10
  function logMessage(db) {
10
11
  return async (args) => {
11
12
  const existing = await db.select({ id: schema_1.sessions.id }).from(schema_1.sessions).where((0, drizzle_orm_1.eq)(schema_1.sessions.id, args.session_id)).limit(1);
12
13
  if (existing.length === 0) {
13
14
  return { content: [{ type: 'text', text: `Session not found: ${args.session_id}` }], isError: true };
14
15
  }
16
+ const last = await db.select({ content_hash: schema_1.messages.content_hash })
17
+ .from(schema_1.messages)
18
+ .where((0, drizzle_orm_1.eq)(schema_1.messages.session_id, args.session_id))
19
+ .orderBy((0, drizzle_orm_1.desc)(schema_1.messages.created_at), (0, drizzle_orm_1.desc)((0, drizzle_orm_1.sql) `rowid`))
20
+ .limit(1);
15
21
  const now = (0, time_1.localISOString)();
16
22
  const id = (0, uuid_1.v4)();
23
+ const prevHash = last[0]?.content_hash ?? null;
24
+ const contentHash = (0, hash_1.computeContentHash)(args.session_id, args.role, args.content, now, prevHash);
17
25
  await db.insert(schema_1.messages).values({
18
26
  id,
19
27
  session_id: args.session_id,
20
28
  role: args.role,
21
29
  content: args.content,
22
30
  created_at: now,
31
+ prev_hash: prevHash,
32
+ content_hash: contentHash,
23
33
  });
24
34
  await db.update(schema_1.sessions)
25
35
  .set({ updated_at: now })
26
36
  .where((0, drizzle_orm_1.eq)(schema_1.sessions.id, args.session_id));
27
37
  return {
28
- content: [{ type: 'text', text: JSON.stringify({ message_id: id, created_at: now }) }],
38
+ content: [{ type: 'text', text: JSON.stringify({ message_id: id, created_at: now, content_hash: contentHash }) }],
29
39
  };
30
40
  };
31
41
  }
@@ -35,16 +45,26 @@ function logExchange(db) {
35
45
  if (existing.length === 0) {
36
46
  return { content: [{ type: 'text', text: `Session not found: ${args.session_id}` }], isError: true };
37
47
  }
48
+ const last = await db.select({ content_hash: schema_1.messages.content_hash })
49
+ .from(schema_1.messages)
50
+ .where((0, drizzle_orm_1.eq)(schema_1.messages.session_id, args.session_id))
51
+ .orderBy((0, drizzle_orm_1.desc)(schema_1.messages.created_at), (0, drizzle_orm_1.desc)((0, drizzle_orm_1.sql) `rowid`))
52
+ .limit(1);
38
53
  const userNow = (0, time_1.localISOString)();
39
54
  const userId = (0, uuid_1.v4)();
40
55
  const assistantNow = (0, time_1.localISOString)();
41
56
  const assistantId = (0, uuid_1.v4)();
57
+ const prevHash = last[0]?.content_hash ?? null;
58
+ const userHash = (0, hash_1.computeContentHash)(args.session_id, 'user', args.user_content, userNow, prevHash);
59
+ const assistantHash = (0, hash_1.computeContentHash)(args.session_id, 'assistant', args.assistant_content, assistantNow, userHash);
42
60
  await db.insert(schema_1.messages).values({
43
61
  id: userId,
44
62
  session_id: args.session_id,
45
63
  role: 'user',
46
64
  content: args.user_content,
47
65
  created_at: userNow,
66
+ prev_hash: prevHash,
67
+ content_hash: userHash,
48
68
  });
49
69
  await db.insert(schema_1.messages).values({
50
70
  id: assistantId,
@@ -52,6 +72,8 @@ function logExchange(db) {
52
72
  role: 'assistant',
53
73
  content: args.assistant_content,
54
74
  created_at: assistantNow,
75
+ prev_hash: userHash,
76
+ content_hash: assistantHash,
55
77
  });
56
78
  await db.update(schema_1.sessions)
57
79
  .set({ updated_at: assistantNow })
@@ -62,8 +84,10 @@ function logExchange(db) {
62
84
  text: JSON.stringify({
63
85
  user_message_id: userId,
64
86
  user_created_at: userNow,
87
+ user_content_hash: userHash,
65
88
  assistant_message_id: assistantId,
66
89
  assistant_created_at: assistantNow,
90
+ assistant_content_hash: assistantHash,
67
91
  }),
68
92
  }],
69
93
  };
@@ -27,7 +27,11 @@ function startSession(db) {
27
27
  };
28
28
  }
29
29
  catch (e) {
30
- if (!e?.message?.includes('UNIQUE constraint failed'))
30
+ const isUnique = e?.message?.includes('UNIQUE constraint failed') ||
31
+ e?.code === 'SQLITE_CONSTRAINT_UNIQUE' ||
32
+ e?.cause?.message?.includes('UNIQUE constraint failed') ||
33
+ e?.cause?.extendedCode === 'SQLITE_CONSTRAINT_UNIQUE';
34
+ if (!isUnique)
31
35
  throw e;
32
36
  const existing = await db.select().from(schema_1.sessions).where((0, drizzle_orm_1.eq)(schema_1.sessions.title, args.title)).limit(1);
33
37
  const session = existing[0];
@@ -0,0 +1,9 @@
1
+ import type { Db } from '../db/index';
2
+ export declare function verifySession(db: Db): (args: {
3
+ session_id: string;
4
+ }) => Promise<{
5
+ content: {
6
+ type: "text";
7
+ text: string;
8
+ }[];
9
+ }>;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verifySession = verifySession;
4
+ const drizzle_orm_1 = require("drizzle-orm");
5
+ const schema_1 = require("../db/schema");
6
+ const hash_1 = require("../utils/hash");
7
+ function verifySession(db) {
8
+ return async (args) => {
9
+ const rows = await db.select().from(schema_1.messages)
10
+ .where((0, drizzle_orm_1.eq)(schema_1.messages.session_id, args.session_id))
11
+ .orderBy((0, drizzle_orm_1.asc)(schema_1.messages.created_at), (0, drizzle_orm_1.asc)((0, drizzle_orm_1.sql) `rowid`));
12
+ const chained = rows.filter(r => r.content_hash !== null);
13
+ if (chained.length === 0) {
14
+ return {
15
+ content: [{
16
+ type: 'text',
17
+ text: JSON.stringify({ valid: true, messages: rows.length, chained: 0, note: 'No chained messages — pre-dates hash chaining' }),
18
+ }],
19
+ };
20
+ }
21
+ for (let i = 0; i < chained.length; i++) {
22
+ const row = chained[i];
23
+ const expectedPrev = i === 0 ? null : chained[i - 1].content_hash;
24
+ if (row.prev_hash !== expectedPrev) {
25
+ return {
26
+ content: [{
27
+ type: 'text',
28
+ text: JSON.stringify({ valid: false, first_break: row.id, reason: 'prev_hash mismatch' }),
29
+ }],
30
+ };
31
+ }
32
+ const expected = (0, hash_1.computeContentHash)(row.session_id, row.role, row.content, row.created_at, row.prev_hash);
33
+ if (row.content_hash !== expected) {
34
+ return {
35
+ content: [{
36
+ type: 'text',
37
+ text: JSON.stringify({ valid: false, first_break: row.id, reason: 'content_hash mismatch — row was tampered' }),
38
+ }],
39
+ };
40
+ }
41
+ }
42
+ return {
43
+ content: [{
44
+ type: 'text',
45
+ text: JSON.stringify({ valid: true, messages: rows.length, chained: chained.length }),
46
+ }],
47
+ };
48
+ };
49
+ }
@@ -0,0 +1 @@
1
+ export declare function computeContentHash(sessionId: string, role: string, content: string, createdAt: string, prevHash: string | null): string;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeContentHash = computeContentHash;
4
+ const crypto_1 = require("crypto");
5
+ function computeContentHash(sessionId, role, content, createdAt, prevHash) {
6
+ return (0, crypto_1.createHash)('sha256')
7
+ .update(`${sessionId}|${role}|${content}|${createdAt}|${prevHash ?? ''}`)
8
+ .digest('hex');
9
+ }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "chron-mcp",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "mcpName": "io.github.SirinivasK/chron",
5
5
  "description": "Audit-grade timestamped logs for every AI conversation",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "https://github.com/nivaya/chron.git"
8
+ "url": "https://github.com/sirinivask/chron.git"
9
9
  },
10
10
  "main": "dist/index.js",
11
11
  "vitest": {
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@libsql/client": "^0.17.3",
49
49
  "@modelcontextprotocol/sdk": "^1.12.0",
50
- "drizzle-orm": "^0.30.10",
50
+ "drizzle-orm": "^0.45.2",
51
51
  "express": "^4.18.2",
52
52
  "uuid": "^11.1.1",
53
53
  "zod": "^3.22.4"