@dreamforgestudiollc/emet 0.0.0-stage → 0.2.4
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/.env.example +136 -0
- package/CHANGELOG.md +296 -0
- package/CHARTER.md +125 -0
- package/LICENSE +22 -0
- package/README.md +783 -2
- package/docs/AUTHORSHIP.md +26 -0
- package/docs/BENCHMARKS.md +261 -0
- package/docs/BOOTSTRAP.md +127 -0
- package/docs/BOTS.md +79 -0
- package/docs/COVERAGE.md +64 -0
- package/docs/DEPLOY.md +246 -0
- package/docs/DISCIPLINES.md +29 -0
- package/docs/GUARDRAILS-AND-MONORAILS.md +107 -0
- package/docs/GUIDED-SETUP.md +296 -0
- package/docs/PERMISSIONS.md +118 -0
- package/docs/ROADMAP.md +59 -0
- package/docs/SETUP.md +259 -0
- package/guides/NEURODIVERGENT_SUPPORT.md +278 -0
- package/package.json +70 -4
- package/server.json +39 -0
- package/src/aleph.js +395 -0
- package/src/annotations.js +135 -0
- package/src/approve-throttle.js +54 -0
- package/src/atlas-role.js +58 -0
- package/src/conformance.js +688 -0
- package/src/content_analyzer.js +450 -0
- package/src/database.js +811 -0
- package/src/disciplines.js +26 -0
- package/src/dispatch.js +279 -0
- package/src/env.js +52 -0
- package/src/floor.js +187 -0
- package/src/gaps.js +476 -0
- package/src/guides.js +48 -0
- package/src/http-remote.js +269 -0
- package/src/index.js +390 -0
- package/src/invites.js +167 -0
- package/src/layers.js +449 -0
- package/src/lessons-to-checks.js +366 -0
- package/src/logger.js +299 -0
- package/src/metadata.js +61 -0
- package/src/module-path.js +32 -0
- package/src/mongo-connect.js +48 -0
- package/src/named-items.js +208 -0
- package/src/oauth-provider.js +838 -0
- package/src/persons.js +116 -0
- package/src/provenance.js +257 -0
- package/src/redact.js +231 -0
- package/src/request-log.js +124 -0
- package/src/scopes.js +97 -0
- package/src/security-headers.js +26 -0
- package/src/session-graph.js +945 -0
- package/src/session.js +1097 -0
- package/src/setup.js +1313 -0
- package/src/startup-pages.js +354 -0
- package/src/startup-payload.js +126 -0
- package/src/tools.js +4271 -0
- package/src/transcript-session.js +106 -0
- package/src/validation.js +1163 -0
- package/src/version.js +2 -0
- package/templates/BOARD.md +72 -0
- package/templates/BOTHANDOFF.md +57 -0
- package/templates/CHARACTER.md +30 -0
- package/templates/HANDOFF.md +63 -0
- package/templates/IDENTITY.md +29 -0
- package/templates/INDEX.md +82 -0
- package/templates/INSTALL.md +29 -0
- package/templates/PROCEDURE.md +39 -0
- package/templates/ROSTER.md +43 -0
- package/templates/SEED.md +65 -0
- package/templates/STAGING.md +37 -0
- package/templates/STANDARD.md +99 -0
- package/templates/THREAD.md +43 -0
- package/templates/USER.md +29 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* EMET remote MCP server — PRODUCTION entry (OAuth-protected).
|
|
4
|
+
* ------------------------------------------------------------------
|
|
5
|
+
* Streamable HTTP transport + full OAuth 2.1 (via the MCP SDK's mcpAuthRouter)
|
|
6
|
+
* guarding /mcp with bearer auth. Shares tools.js and database.js with the
|
|
7
|
+
* stdio entry point - one codebase, two transports.
|
|
8
|
+
*
|
|
9
|
+
* Required env for deploy:
|
|
10
|
+
* MONGODB_URI Atlas connection string (scoped user recommended)
|
|
11
|
+
* PUBLIC_URL the public https base URL of this host (issuer)
|
|
12
|
+
* EMET_OAUTH_PASSPHRASE the consent passphrase (set by the user)
|
|
13
|
+
* Optional:
|
|
14
|
+
* PORT (default 8849), HOST (default 0.0.0.0), EMET_SOURCE_TAG (e.g. "mobile"),
|
|
15
|
+
* The server trusts exactly one proxy hop (Railway). TRUST_PROXY is not read.
|
|
16
|
+
* EMET_CIMD (default on; set 0 to stop advertising Client ID Metadata Document
|
|
17
|
+
* support and fall back to Dynamic Client Registration only — a rollback that
|
|
18
|
+
* needs no redeploy; see oauth-provider.js)
|
|
19
|
+
*
|
|
20
|
+
* Configuration comes from the environment, or from a .env file in the repo root.
|
|
21
|
+
* PUBLIC_URL defaults to http://127.0.0.1:PORT for local development.
|
|
22
|
+
*/
|
|
23
|
+
import './env.js';
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import { setting } from './env.js';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import { fileURLToPath } from 'url';
|
|
28
|
+
import express from 'express';
|
|
29
|
+
|
|
30
|
+
import { mcpAuthRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
31
|
+
import { redirectUriMatches } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js';
|
|
32
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
33
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
34
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
35
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
36
|
+
import { EMET_VERSION } from './version.js';
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
const db = await import('./database.js');
|
|
40
|
+
const t = await import('./tools.js');
|
|
41
|
+
const { dispatchTool } = await import('./dispatch.js');
|
|
42
|
+
const { MongoOAuthProvider, withCimdMetadata } = await import('./oauth-provider.js');
|
|
43
|
+
const { requestLogger } = await import('./request-log.js');
|
|
44
|
+
const { securityHeaders } = await import('./security-headers.js');
|
|
45
|
+
const { ApproveThrottle } = await import('./approve-throttle.js');
|
|
46
|
+
const { StructuredLogger, AuditOperation } = await import('./logger.js');
|
|
47
|
+
|
|
48
|
+
const PORT = Number(process.env.PORT) || 8849;
|
|
49
|
+
const HOST = process.env.HOST || '0.0.0.0';
|
|
50
|
+
const PUBLIC_URL = (process.env.PUBLIC_URL || `http://127.0.0.1:${PORT}`).replace(/\/+$/, '');
|
|
51
|
+
const PASSPHRASE = setting('OAUTH_PASSPHRASE') || '';
|
|
52
|
+
// One hop only. A larger TRUST_PROXY would let a client spoof req.ip.
|
|
53
|
+
const TRUST_PROXY = 1;
|
|
54
|
+
const RESOURCE_URL = new URL(PUBLIC_URL + '/mcp');
|
|
55
|
+
const PRM_URL = getOAuthProtectedResourceMetadataUrl(RESOURCE_URL);
|
|
56
|
+
|
|
57
|
+
// This used to be a stub whose debug/info/warn/audit were empty functions, with
|
|
58
|
+
// AuditOperation a Proxy returning its own key names. Every audit call on this
|
|
59
|
+
// transport - and this is the transport a hosted install runs - was discarded,
|
|
60
|
+
// so the server reported an audit trail it did not keep. Both transports now
|
|
61
|
+
// construct the same recorder. Output goes to stderr, which the host captures.
|
|
62
|
+
const logger = new StructuredLogger({
|
|
63
|
+
serviceName: 'emet',
|
|
64
|
+
version: EMET_VERSION,
|
|
65
|
+
minLevel: process.env.LOG_LEVEL || (process.env.DEBUG === 'true' ? 'debug' : 'info'),
|
|
66
|
+
jsonOutput: process.env.LOG_FORMAT !== 'text',
|
|
67
|
+
auditEnabled: true,
|
|
68
|
+
auditLogPath: setting('AUDIT_LOG') || null
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const dbManager = new db.EmetDatabase(logger);
|
|
72
|
+
const rateLimiter = new t.RateLimiter(logger);
|
|
73
|
+
|
|
74
|
+
// Over HTTP the source tag is the one bound at approval (auth.extra.sourceTag).
|
|
75
|
+
// Stdio still resolves the caller's tag inside saveMemory. A token with no
|
|
76
|
+
// bound tag may name a registered tag until it is refreshed; that refresh is
|
|
77
|
+
// refused so the next consent binds a tag.
|
|
78
|
+
|
|
79
|
+
function buildServer() {
|
|
80
|
+
const server = new Server({ name: 'emet', version: EMET_VERSION }, { capabilities: { tools: {} } });
|
|
81
|
+
server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => {
|
|
82
|
+
const { scopesAllow } = await import('./scopes.js');
|
|
83
|
+
const auth = extra && extra.authInfo;
|
|
84
|
+
const scopes = (auth && auth.scopes) || [];
|
|
85
|
+
return { tools: t.TOOLS.filter((tool) => scopesAllow(scopes, tool.name, { http: true, authenticated: Boolean(auth), args: null })) };
|
|
86
|
+
});
|
|
87
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
88
|
+
const { name, arguments: args = {} } = request.params;
|
|
89
|
+
const { actorStore, personFromAuth } = await import('./persons.js');
|
|
90
|
+
const auth = extra && extra.authInfo;
|
|
91
|
+
const clientId = (auth && auth.clientId) || 'http:visitor';
|
|
92
|
+
const actor = {
|
|
93
|
+
personId: personFromAuth(extra),
|
|
94
|
+
clientId,
|
|
95
|
+
scopes: (auth && auth.scopes) || [],
|
|
96
|
+
transport: 'http',
|
|
97
|
+
sourceTag: (auth && auth.extra && auth.extra.sourceTag) || null,
|
|
98
|
+
authenticated: Boolean(auth)
|
|
99
|
+
};
|
|
100
|
+
const bucket = t.rateBucket(actor);
|
|
101
|
+
const rl = rateLimiter.checkLimit(name, bucket);
|
|
102
|
+
if (!rl.allowed) return actorStore.run(actor, () => t.handleError(new t.RateLimitError(rl.reason, rl.retryAfterMs), name, logger));
|
|
103
|
+
rateLimiter.recordRequest(name, bucket);
|
|
104
|
+
// Every tool call leaves one audit entry (H6). callTool never throws: it
|
|
105
|
+
// returns either a success response or handleError's isError response.
|
|
106
|
+
const t0 = Date.now();
|
|
107
|
+
// Parameter NAMES, never values (the owner, 2026-09-27, code audit 1.6).
|
|
108
|
+
const paramNames = Object.keys(args || {}).sort();
|
|
109
|
+
const result = await actorStore.run(actor, () => callTool(name, args));
|
|
110
|
+
logger.audit(AuditOperation.TOOL_CALL, { tool: name, success: result.isError !== true, durationMs: Date.now() - t0, params: paramNames });
|
|
111
|
+
return result;
|
|
112
|
+
});
|
|
113
|
+
return server;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function callTool(name, args) {
|
|
117
|
+
// One dispatch table for both transports - src/dispatch.js (audit 2026-09-27).
|
|
118
|
+
return dispatchTool(name, args, { dbManager, logger, AuditOperation });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const provider = new MongoOAuthProvider(() => dbManager.db, { passphrase: PASSPHRASE, issuerUrl: PUBLIC_URL });
|
|
122
|
+
// Failed passphrase attempts per client address (audit finding H4, 2026-09-19).
|
|
123
|
+
// The tool-call limiter sits behind bearer auth; this is the one gate in front of it.
|
|
124
|
+
const approveThrottle = new ApproveThrottle();
|
|
125
|
+
|
|
126
|
+
const app = express();
|
|
127
|
+
app.set('trust proxy', TRUST_PROXY);
|
|
128
|
+
|
|
129
|
+
// On every response, including /mcp, the OAuth metadata routes, errors, and
|
|
130
|
+
// the consent page. The consent page still sets its own content security policy.
|
|
131
|
+
app.use(securityHeaders);
|
|
132
|
+
|
|
133
|
+
// One redacted line per OAuth/MCP request and per 4xx/5xx anywhere (H4,
|
|
134
|
+
// 2026-09-07). EMET_REQUEST_LOG=0 silences it. See request-log.js.
|
|
135
|
+
app.use(requestLogger({ enabled: process.env.EMET_REQUEST_LOG !== '0' }));
|
|
136
|
+
|
|
137
|
+
app.get('/health', (_req, res) => res.json({ ok: true, service: 'emet', tools: t.TOOLS.length, cimd: provider.cimdEnabled }));
|
|
138
|
+
|
|
139
|
+
// OAuth 2.1 authorization server + protected-resource metadata:
|
|
140
|
+
// /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource/mcp,
|
|
141
|
+
// /register, /authorize, /token, /revoke
|
|
142
|
+
const AUTH_OPTIONS = {
|
|
143
|
+
provider,
|
|
144
|
+
issuerUrl: new URL(PUBLIC_URL),
|
|
145
|
+
resourceServerUrl: RESOURCE_URL,
|
|
146
|
+
scopesSupported: ['emet.read', 'emet.write'],
|
|
147
|
+
resourceName: 'EMET Memory',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Authorization-server metadata, served AHEAD of the SDK router so it can carry
|
|
151
|
+
// `client_id_metadata_document_supported` (SDK 1.30.0 builds this object
|
|
152
|
+
// internally with no option to extend it). Everything else in the document is
|
|
153
|
+
// the SDK's own; only the CIMD flag is added, and DCR stays advertised.
|
|
154
|
+
const AS_METADATA = withCimdMetadata(createOAuthMetadata(AUTH_OPTIONS), provider.cimdEnabled);
|
|
155
|
+
app.get('/.well-known/oauth-authorization-server', (_req, res) => {
|
|
156
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
157
|
+
res.json(AS_METADATA);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
app.use(mcpAuthRouter(AUTH_OPTIONS));
|
|
161
|
+
|
|
162
|
+
// Passphrase approval -> mint authorization code -> redirect back to the client
|
|
163
|
+
app.post('/oauth/approve', express.urlencoded({ extended: false }), async (req, res) => {
|
|
164
|
+
try {
|
|
165
|
+
const { client_id, redirect_uri, code_challenge, state, scopes, resource, passphrase, invite_code } = req.body || {};
|
|
166
|
+
const throttle = approveThrottle.check(req.ip);
|
|
167
|
+
if (!throttle.allowed) {
|
|
168
|
+
res.locals.reason = 'throttled';
|
|
169
|
+
res.status(429).setHeader('Retry-After', String(throttle.retryAfterS));
|
|
170
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
171
|
+
return res.end('<p style="font-family:system-ui;background:#12141a;color:#e8e6e0;padding:24px">Too many attempts. Try again later.</p>');
|
|
172
|
+
}
|
|
173
|
+
let inviteSubject = null;
|
|
174
|
+
let inviteScopes = null;
|
|
175
|
+
let inviteDoor = null;
|
|
176
|
+
let sourceTag = null;
|
|
177
|
+
if (invite_code && String(invite_code).trim()) {
|
|
178
|
+
await dbManager.getConnection('working');
|
|
179
|
+
const { redeemInvite } = await import('./invites.js');
|
|
180
|
+
const redeemed = await redeemInvite(dbManager.db, invite_code);
|
|
181
|
+
if (!redeemed.ok) {
|
|
182
|
+
approveThrottle.recordFailure(req.ip);
|
|
183
|
+
res.locals.reason = 'bad_invite';
|
|
184
|
+
res.status(401).setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
185
|
+
return res.end('<p style="font-family:system-ui;background:#12141a;color:#e8e6e0;padding:24px">That invite code is not valid. Go back and try again.</p>');
|
|
186
|
+
}
|
|
187
|
+
inviteSubject = redeemed.person_id;
|
|
188
|
+
inviteDoor = redeemed.door === 'member' ? 'member' : 'guest';
|
|
189
|
+
sourceTag = redeemed.source_tag || null;
|
|
190
|
+
// An empty scope list is owner access; a guest must never get it
|
|
191
|
+
// (security audit 2026-09-29).
|
|
192
|
+
const { guestScopesOrNull } = await import('./scopes.js');
|
|
193
|
+
inviteScopes = guestScopesOrNull(redeemed.scopes);
|
|
194
|
+
if (!inviteScopes) {
|
|
195
|
+
res.locals.reason = 'invite_without_scopes';
|
|
196
|
+
res.status(401).setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
197
|
+
return res.end('<p style="font-family:system-ui;background:#12141a;color:#e8e6e0;padding:24px">That invite grants no access. Ask for a new one.</p>');
|
|
198
|
+
}
|
|
199
|
+
} else if (!provider.checkPassphrase(passphrase || '')) {
|
|
200
|
+
approveThrottle.recordFailure(req.ip);
|
|
201
|
+
res.locals.reason = 'bad_passphrase';
|
|
202
|
+
res.status(401).setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
203
|
+
return res.end('<p style="font-family:system-ui;background:#12141a;color:#e8e6e0;padding:24px">Incorrect passphrase. Go back and try again.</p>');
|
|
204
|
+
} else {
|
|
205
|
+
const requested = String((req.body && req.body.source) || '').trim();
|
|
206
|
+
const { registeredSourceTags } = await import('./setup.js');
|
|
207
|
+
const registry = registeredSourceTags();
|
|
208
|
+
if (registry.length && !registry.includes(requested)) {
|
|
209
|
+
res.locals.reason = 'bad_source';
|
|
210
|
+
return res.status(400).end('source tag is not registered');
|
|
211
|
+
}
|
|
212
|
+
sourceTag = requested || null;
|
|
213
|
+
}
|
|
214
|
+
const client = await provider.clientsStore.getClient(client_id);
|
|
215
|
+
if (!client) { res.locals.reason = 'unknown_client'; return res.status(400).end('unknown client'); }
|
|
216
|
+
// Same rule the SDK's /authorize applies: exact match, except that loopback
|
|
217
|
+
// redirects (localhost / 127.0.0.1 / [::1]) match with the port ignored, per
|
|
218
|
+
// RFC 8252 §7.3 — Claude Code binds an ephemeral port on every run.
|
|
219
|
+
if (!(client.redirect_uris || []).some((registered) => redirectUriMatches(redirect_uri, registered))) {
|
|
220
|
+
res.locals.reason = 'redirect_uri_not_registered';
|
|
221
|
+
return res.status(400).end('redirect_uri not registered');
|
|
222
|
+
}
|
|
223
|
+
approveThrottle.recordSuccess(req.ip);
|
|
224
|
+
res.locals.reason = client.cimd ? 'approved_cimd' : 'approved_dcr';
|
|
225
|
+
const requestedScopes = inviteScopes || (scopes ? String(scopes).split(' ').filter(Boolean) : []);
|
|
226
|
+
// A passphrase approval with no requested scopes is the owner. Store that
|
|
227
|
+
// as the full-access alias so an empty HTTP scope list never means owner.
|
|
228
|
+
const grantedScopes = inviteSubject ? requestedScopes : (requestedScopes.length ? requestedScopes : ['emet']);
|
|
229
|
+
const code = await provider.createAuthorizationCode({
|
|
230
|
+
client_id, redirect_uri, code_challenge,
|
|
231
|
+
scopes: grantedScopes,
|
|
232
|
+
resource: resource || null,
|
|
233
|
+
subject: inviteSubject || null,
|
|
234
|
+
// The door rides on the token: 'member' for EMET Member Access, 'invite' for EMET Guest Access.
|
|
235
|
+
issued_via: inviteSubject ? (inviteDoor === 'member' ? 'member' : 'invite') : null,
|
|
236
|
+
source_tag: sourceTag,
|
|
237
|
+
});
|
|
238
|
+
const u = new URL(redirect_uri);
|
|
239
|
+
u.searchParams.set('code', code);
|
|
240
|
+
if (state) u.searchParams.set('state', state);
|
|
241
|
+
res.redirect(302, u.href);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
console.error('[emet] approve error', e);
|
|
244
|
+
res.locals.reason = 'approve_error';
|
|
245
|
+
res.status(500).end('approve failed');
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Protected MCP endpoint (bearer required -> body parse -> streamable HTTP)
|
|
250
|
+
app.post('/mcp', requireBearerAuth({ verifier: provider, resourceMetadataUrl: PRM_URL }), express.json({ limit: '256kb' }), async (req, res) => {
|
|
251
|
+
try {
|
|
252
|
+
const server = buildServer();
|
|
253
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
254
|
+
res.on('close', () => { try { transport.close(); } catch {} try { server.close(); } catch {} });
|
|
255
|
+
await server.connect(transport);
|
|
256
|
+
await transport.handleRequest(req, res, req.body);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
console.error('[emet] mcp error', e);
|
|
259
|
+
res.locals.reason = 'mcp_error';
|
|
260
|
+
if (!res.headersSent) res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
(async () => {
|
|
265
|
+
if (!PASSPHRASE) console.error('[emet] WARNING: EMET_OAUTH_PASSPHRASE unset — all authorization attempts will be rejected.');
|
|
266
|
+
try { await dbManager.ensureClient(); console.error('[emet] Mongo connected'); }
|
|
267
|
+
catch (e) { console.error('[emet] Mongo connect FAILED:', e.message); }
|
|
268
|
+
app.listen(PORT, HOST, () => console.error(`[emet] listening ${PUBLIC_URL} (bind ${HOST}:${PORT}); /mcp OAuth-protected; PRM ${PRM_URL}`));
|
|
269
|
+
})();
|
package/src/index.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EMET - Enduring Memory & Epistemic Tiers
|
|
5
|
+
* Copyright (c) 2026 Dreamforge Studio LLC
|
|
6
|
+
*
|
|
7
|
+
* Derived from CASCADE Memory System
|
|
8
|
+
* Copyright (c) 2025-2026 CIPS Corp (C.I.P.S. LLC)
|
|
9
|
+
* Licensed under the MIT License - see LICENSE
|
|
10
|
+
*
|
|
11
|
+
* https://github.com/For-Sunny/cascade-memory-enterprise
|
|
12
|
+
*
|
|
13
|
+
* MCP Server - 6-Layer Structured Memory Architecture
|
|
14
|
+
*
|
|
15
|
+
* REFACTORED: January 22, 2026
|
|
16
|
+
* Split into modular architecture:
|
|
17
|
+
* - database.js: Connection pool, schema, dual-write pattern
|
|
18
|
+
* - tools.js: Tool definitions and handlers
|
|
19
|
+
* - index.js: Thin entry point (this file)
|
|
20
|
+
*
|
|
21
|
+
* DUAL-WRITE Architecture:
|
|
22
|
+
* - RAM disk for instant reads
|
|
23
|
+
* - READ: RAM first (instant) -> Disk fallback
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
27
|
+
import { setting } from './env.js';
|
|
28
|
+
import { redactText } from './redact.js';
|
|
29
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
30
|
+
import {
|
|
31
|
+
CallToolRequestSchema,
|
|
32
|
+
ListToolsRequestSchema,
|
|
33
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
34
|
+
import { EventEmitter } from 'events';
|
|
35
|
+
import fs from 'fs';
|
|
36
|
+
|
|
37
|
+
// Import validation module
|
|
38
|
+
import {
|
|
39
|
+
ValidationError,
|
|
40
|
+
VALID_LAYERS,
|
|
41
|
+
CONTENT_LIMITS,
|
|
42
|
+
NUMERIC_LIMITS
|
|
43
|
+
} from './validation.js';
|
|
44
|
+
|
|
45
|
+
// Import database module
|
|
46
|
+
import {
|
|
47
|
+
EmetDatabase,
|
|
48
|
+
EmetError,
|
|
49
|
+
DatabaseError,
|
|
50
|
+
ConfigurationError,
|
|
51
|
+
ErrorCodes,
|
|
52
|
+
StatusCodes,
|
|
53
|
+
DEBUG,
|
|
54
|
+
MEMORY_LAYERS,
|
|
55
|
+
sanitizeErrorMessage,
|
|
56
|
+
sanitizeDetails
|
|
57
|
+
} from './database.js';
|
|
58
|
+
|
|
59
|
+
import { dispatchTool } from './dispatch.js';
|
|
60
|
+
// Import tools module (the per-tool handlers are reached through dispatch.js)
|
|
61
|
+
import {
|
|
62
|
+
TOOLS,
|
|
63
|
+
RateLimiter,
|
|
64
|
+
rateBucket,
|
|
65
|
+
RateLimitError,
|
|
66
|
+
RATE_LIMIT_CONFIG,
|
|
67
|
+
handleError,
|
|
68
|
+
createSuccessResponse,
|
|
69
|
+
getStatus
|
|
70
|
+
} from './tools.js';
|
|
71
|
+
|
|
72
|
+
// ============================================
|
|
73
|
+
// STRUCTURED LOGGING SYSTEM
|
|
74
|
+
// ============================================
|
|
75
|
+
|
|
76
|
+
// Extracted to ./logger.js on 2026-09-13 so the HTTP transport uses the same
|
|
77
|
+
// recorder instead of a stub. See that file for why.
|
|
78
|
+
import { LogLevel, AuditOperation, StructuredLogger } from './logger.js';
|
|
79
|
+
import { EMET_VERSION } from './version.js';
|
|
80
|
+
|
|
81
|
+
// ============================================
|
|
82
|
+
// INITIALIZATION
|
|
83
|
+
// ============================================
|
|
84
|
+
|
|
85
|
+
// Initialize logger
|
|
86
|
+
const LOG_LEVEL = process.env.LOG_LEVEL || (process.env.DEBUG === 'true' ? 'debug' : 'info');
|
|
87
|
+
const AUDIT_LOG_PATH = setting('AUDIT_LOG') || null;
|
|
88
|
+
|
|
89
|
+
const logger = new StructuredLogger({
|
|
90
|
+
serviceName: 'emet',
|
|
91
|
+
version: EMET_VERSION,
|
|
92
|
+
minLevel: LOG_LEVEL,
|
|
93
|
+
jsonOutput: process.env.LOG_FORMAT !== 'text',
|
|
94
|
+
auditEnabled: true,
|
|
95
|
+
auditLogPath: AUDIT_LOG_PATH
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Initialize database manager with dual-write paths
|
|
99
|
+
const dbManager = new EmetDatabase(logger);
|
|
100
|
+
|
|
101
|
+
// Initialize rate limiter
|
|
102
|
+
const rateLimiter = new RateLimiter(logger);
|
|
103
|
+
|
|
104
|
+
// ============================================
|
|
105
|
+
// MCP SERVER
|
|
106
|
+
// ============================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Initialize MCP Server
|
|
110
|
+
*/
|
|
111
|
+
const server = new Server(
|
|
112
|
+
{
|
|
113
|
+
name: "emet",
|
|
114
|
+
version: EMET_VERSION,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
capabilities: {
|
|
118
|
+
tools: {},
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* List available tools
|
|
125
|
+
*/
|
|
126
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
127
|
+
return { tools: TOOLS };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Handle tool calls with rate limiting and centralized error handling
|
|
132
|
+
*/
|
|
133
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
134
|
+
const { name, arguments: args } = request.params;
|
|
135
|
+
const { actorStore, personFromAuth } = await import('./persons.js');
|
|
136
|
+
const auth = extra && extra.authInfo;
|
|
137
|
+
const actor = {
|
|
138
|
+
personId: personFromAuth(extra),
|
|
139
|
+
clientId: auth ? (auth.clientId || 'http:visitor') : 'stdio',
|
|
140
|
+
scopes: (auth && auth.scopes) || [],
|
|
141
|
+
transport: auth ? 'http' : 'stdio',
|
|
142
|
+
authenticated: Boolean(auth)
|
|
143
|
+
};
|
|
144
|
+
const bucket = rateBucket(actor);
|
|
145
|
+
|
|
146
|
+
// Rate limit check, per subject and client. Stdio is one bucket per process.
|
|
147
|
+
const rateLimitCheck = rateLimiter.checkLimit(name, bucket);
|
|
148
|
+
|
|
149
|
+
if (!rateLimitCheck.allowed) {
|
|
150
|
+
logger.audit(AuditOperation.RATE_LIMIT_HIT, {
|
|
151
|
+
tool: name,
|
|
152
|
+
reason: rateLimitCheck.reason,
|
|
153
|
+
retryAfterMs: rateLimitCheck.retryAfterMs,
|
|
154
|
+
currentStatus: rateLimiter.getStatus(bucket)
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
logger.warn('Rate limit exceeded', {
|
|
158
|
+
tool: name,
|
|
159
|
+
reason: rateLimitCheck.reason,
|
|
160
|
+
retryAfterMs: rateLimitCheck.retryAfterMs
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const rateLimitError = new RateLimitError(rateLimitCheck.reason, rateLimitCheck.retryAfterMs);
|
|
164
|
+
return actorStore.run(actor, () => handleError(rateLimitError, name, logger));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
rateLimiter.recordRequest(name, bucket);
|
|
168
|
+
|
|
169
|
+
// Every tool call leaves one audit entry (H6, 2026-09-19). callTool never
|
|
170
|
+
// throws: it returns either a success response or handleError's response.
|
|
171
|
+
const t0 = Date.now();
|
|
172
|
+
// Parameter NAMES, never values (the owner, 2026-09-27, code audit 1.6): the next
|
|
173
|
+
// audit can measure which parameters hosts use; nothing a host sent is stored.
|
|
174
|
+
const paramNames = Object.keys(args || {}).sort();
|
|
175
|
+
const result = await actorStore.run(actor, () => callTool(name, args));
|
|
176
|
+
logger.audit(AuditOperation.TOOL_CALL, { tool: name, success: result.isError !== true, durationMs: Date.now() - t0, params: paramNames });
|
|
177
|
+
return result;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
async function callTool(name, args) {
|
|
181
|
+
// One dispatch table for both transports - src/dispatch.js (audit 2026-09-27).
|
|
182
|
+
return dispatchTool(name, args, { dbManager, logger, AuditOperation });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ============================================
|
|
186
|
+
// SERVER STARTUP
|
|
187
|
+
// ============================================
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Start server
|
|
191
|
+
*/
|
|
192
|
+
async function main() {
|
|
193
|
+
const startTime = Date.now();
|
|
194
|
+
|
|
195
|
+
logger.info('============================================');
|
|
196
|
+
logger.info(`EMET Memory MCP Server v${EMET_VERSION}`);
|
|
197
|
+
logger.info('============================================');
|
|
198
|
+
|
|
199
|
+
logger.info('Server configuration loaded', {
|
|
200
|
+
debugMode: DEBUG,
|
|
201
|
+
logLevel: LOG_LEVEL,
|
|
202
|
+
auditLogPath: AUDIT_LOG_PATH
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
logger.info('Validation configuration', {
|
|
206
|
+
maxContentLength: CONTENT_LIMITS.MAX_CONTENT_LENGTH,
|
|
207
|
+
maxQueryLength: CONTENT_LIMITS.MAX_QUERY_LENGTH,
|
|
208
|
+
validLayers: VALID_LAYERS,
|
|
209
|
+
limitRange: `${NUMERIC_LIMITS.MIN_LIMIT}-${NUMERIC_LIMITS.MAX_LIMIT}`
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
logger.info('Rate limiting configuration', {
|
|
213
|
+
globalLimit: RATE_LIMIT_CONFIG.GLOBAL_MAX_REQUESTS,
|
|
214
|
+
globalWindowMs: RATE_LIMIT_CONFIG.GLOBAL_WINDOW_MS,
|
|
215
|
+
toolLimits: RATE_LIMIT_CONFIG.TOOL_MAX_REQUESTS,
|
|
216
|
+
cleanupIntervalMs: RATE_LIMIT_CONFIG.CLEANUP_INTERVAL_MS
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
logger.info('Logging configuration', {
|
|
220
|
+
sessionId: logger.sessionId,
|
|
221
|
+
logLevel: LOG_LEVEL,
|
|
222
|
+
jsonOutput: logger.jsonOutput,
|
|
223
|
+
auditEnabled: logger.auditEnabled,
|
|
224
|
+
auditLogPath: AUDIT_LOG_PATH
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Connect stdio transport immediately so MCP handshake completes before DB init
|
|
228
|
+
const transport = new StdioServerTransport();
|
|
229
|
+
await server.connect(transport);
|
|
230
|
+
|
|
231
|
+
// Initialize all databases
|
|
232
|
+
const initializedLayers = [];
|
|
233
|
+
const failedLayers = [];
|
|
234
|
+
|
|
235
|
+
for (const layer of Object.keys(MEMORY_LAYERS)) {
|
|
236
|
+
try {
|
|
237
|
+
await dbManager.getConnection(layer);
|
|
238
|
+
initializedLayers.push(layer);
|
|
239
|
+
|
|
240
|
+
logger.audit(AuditOperation.CONNECTION_OPEN, {
|
|
241
|
+
layer,
|
|
242
|
+
success: true
|
|
243
|
+
});
|
|
244
|
+
} catch (e) {
|
|
245
|
+
failedLayers.push({ layer, error: e.message });
|
|
246
|
+
logger.error('Failed to initialize layer', {
|
|
247
|
+
layer,
|
|
248
|
+
error: e
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
logger.audit(AuditOperation.CONNECTION_OPEN, {
|
|
252
|
+
layer,
|
|
253
|
+
success: false,
|
|
254
|
+
errorMessage: e.message
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const startupDurationMs = Date.now() - startTime;
|
|
260
|
+
|
|
261
|
+
logger.audit(AuditOperation.SERVER_START, {
|
|
262
|
+
initializedLayers,
|
|
263
|
+
failedLayers: failedLayers.length > 0 ? failedLayers : undefined,
|
|
264
|
+
startupDurationMs,
|
|
265
|
+
configuration: {
|
|
266
|
+
rateLimitingEnabled: true,
|
|
267
|
+
auditLoggingEnabled: logger.auditEnabled
|
|
268
|
+
},
|
|
269
|
+
success: failedLayers.length === 0
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
logger.info('============================================');
|
|
273
|
+
logger.info(`EMET v${EMET_VERSION} ready!`, {
|
|
274
|
+
startupDurationMs,
|
|
275
|
+
layersInitialized: initializedLayers.length,
|
|
276
|
+
layersFailed: failedLayers.length,
|
|
277
|
+
sessionId: logger.sessionId
|
|
278
|
+
});
|
|
279
|
+
logger.info('DoS protection ENGAGED!');
|
|
280
|
+
logger.info('Centralized error handling ACTIVE!');
|
|
281
|
+
logger.info('Structured logging ACTIVE!');
|
|
282
|
+
logger.info('Audit trail ENABLED!');
|
|
283
|
+
logger.info('============================================');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ============================================
|
|
287
|
+
// CRASH LOGGING
|
|
288
|
+
// ============================================
|
|
289
|
+
|
|
290
|
+
const CRASH_LOG_PATH = process.env.EMET_CRASH_LOG || null;
|
|
291
|
+
|
|
292
|
+
function writeCrashLog(type, err) {
|
|
293
|
+
try {
|
|
294
|
+
if (!CRASH_LOG_PATH) return; // opt-in: set EMET_CRASH_LOG to a writable path
|
|
295
|
+
const entry = `[${new Date().toISOString()}] ${type}: ${err?.stack || err?.message || String(err)}\n`;
|
|
296
|
+
fs.appendFileSync(CRASH_LOG_PATH, entry, 'utf8');
|
|
297
|
+
} catch (_) {}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
process.on('uncaughtException', (err) => {
|
|
301
|
+
writeCrashLog('UNCAUGHT_EXCEPTION', err);
|
|
302
|
+
process.exit(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
process.on('unhandledRejection', (reason) => {
|
|
306
|
+
writeCrashLog('UNHANDLED_REJECTION', reason instanceof Error ? reason : new Error(String(reason)));
|
|
307
|
+
// Don't exit  log and continue; unhandled rejections are often recoverable
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
process.on('exit', (code) => {
|
|
311
|
+
if (code !== 0) {
|
|
312
|
+
writeCrashLog('EXIT', new Error(`Process exiting with code ${code}`));
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ============================================
|
|
317
|
+
// SHUTDOWN HANDLERS
|
|
318
|
+
// ============================================
|
|
319
|
+
|
|
320
|
+
process.on('SIGINT', async () => {
|
|
321
|
+
logger.info('Received SIGINT signal - initiating shutdown');
|
|
322
|
+
|
|
323
|
+
logger.audit(AuditOperation.SERVER_STOP, {
|
|
324
|
+
signal: 'SIGINT',
|
|
325
|
+
auditStats: logger.getAuditStats()
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
rateLimiter.stop();
|
|
329
|
+
await logger.shutdown();
|
|
330
|
+
await dbManager.closeAll();
|
|
331
|
+
|
|
332
|
+
logger.info('Shutdown complete');
|
|
333
|
+
process.exit(0);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
process.on('SIGTERM', async () => {
|
|
337
|
+
logger.info('Received SIGTERM signal - initiating shutdown');
|
|
338
|
+
|
|
339
|
+
logger.audit(AuditOperation.SERVER_STOP, {
|
|
340
|
+
signal: 'SIGTERM',
|
|
341
|
+
auditStats: logger.getAuditStats()
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
rateLimiter.stop();
|
|
345
|
+
await logger.shutdown();
|
|
346
|
+
await dbManager.closeAll();
|
|
347
|
+
|
|
348
|
+
logger.info('Shutdown complete');
|
|
349
|
+
process.exit(0);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Start the server
|
|
353
|
+
main().catch((error) => {
|
|
354
|
+
logger.audit(AuditOperation.SERVER_STOP, {
|
|
355
|
+
signal: 'FATAL_ERROR',
|
|
356
|
+
success: false,
|
|
357
|
+
errorMessage: error?.message || 'Unknown fatal error'
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
logger.error('Fatal server error', {
|
|
361
|
+
error,
|
|
362
|
+
message: error?.message || 'Unknown fatal error'
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
process.exit(1);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ============================================
|
|
369
|
+
// EXPORTS (for external use)
|
|
370
|
+
// ============================================
|
|
371
|
+
|
|
372
|
+
export {
|
|
373
|
+
// Logger
|
|
374
|
+
logger,
|
|
375
|
+
StructuredLogger,
|
|
376
|
+
LogLevel,
|
|
377
|
+
AuditOperation,
|
|
378
|
+
|
|
379
|
+
// Error classes
|
|
380
|
+
EmetError,
|
|
381
|
+
DatabaseError,
|
|
382
|
+
ConfigurationError,
|
|
383
|
+
ErrorCodes,
|
|
384
|
+
StatusCodes,
|
|
385
|
+
|
|
386
|
+
// Utilities
|
|
387
|
+
sanitizeErrorMessage,
|
|
388
|
+
handleError,
|
|
389
|
+
createSuccessResponse
|
|
390
|
+
};
|