@remnic/core 9.3.767 → 9.3.769
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/dist/access-cli.js +5 -5
- package/dist/access-http.d.ts +51 -1
- package/dist/access-http.js +2 -2
- package/dist/access-mcp.d.ts +7 -1
- package/dist/access-mcp.js +5 -3
- package/dist/{chunk-TVLN5EZZ.js → chunk-CNXMWYLA.js} +78 -3
- package/dist/chunk-CNXMWYLA.js.map +1 -0
- package/dist/{chunk-UG274TNV.js → chunk-E2SPGGUI.js} +13 -8
- package/dist/chunk-E2SPGGUI.js.map +1 -0
- package/dist/{chunk-HBOPSFQQ.js → chunk-EVXI2I6G.js} +3 -3
- package/dist/{chunk-4DLFJJOQ.js → chunk-QMN3CIFS.js} +2 -2
- package/dist/{chunk-J3UJJZKI.js → chunk-SIPZ5UMK.js} +85 -5
- package/dist/chunk-SIPZ5UMK.js.map +1 -0
- package/dist/cli.js +3 -3
- package/dist/connectors/index.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +11 -5
- package/dist/orchestrator.js +5 -5
- package/dist/tokens.d.ts +3 -1
- package/dist/tokens.js +3 -1
- package/package.json +2 -2
- package/src/access-http.test.ts +350 -0
- package/src/access-http.ts +160 -6
- package/src/access-mcp.ts +120 -3
- package/src/index.ts +3 -0
- package/src/tokens.test.ts +58 -0
- package/src/tokens.ts +19 -8
- package/dist/chunk-J3UJJZKI.js.map +0 -1
- package/dist/chunk-TVLN5EZZ.js.map +0 -1
- package/dist/chunk-UG274TNV.js.map +0 -1
- /package/dist/{chunk-HBOPSFQQ.js.map → chunk-EVXI2I6G.js.map} +0 -0
- /package/dist/{chunk-4DLFJJOQ.js.map → chunk-QMN3CIFS.js.map} +0 -0
package/src/access-http.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { abortError, isAbortError } from "./abort-error.js";
|
|
|
11
11
|
import { EngramAccessInputError, type EngramAccessService, type EngramAccessMemoryResponse, type EngramAccessWriteResponse } from "./access-service.js";
|
|
12
12
|
import { CorrectionContractError } from "./correction/correction-contract.js";
|
|
13
13
|
import { WearablesInputError } from "./wearables/errors.js";
|
|
14
|
-
import { EngramMcpServer } from "./access-mcp.js";
|
|
14
|
+
import { EngramMcpServer, MCP_SUPPORTED_PROTOCOL_VERSIONS } from "./access-mcp.js";
|
|
15
15
|
import { validateRequest, type SchemaName, type SchemaTypeFor } from "./access-schema.js";
|
|
16
16
|
import {
|
|
17
17
|
OFFLINE_SYNC_APPLY_MAX_BODY_BYTES,
|
|
@@ -54,6 +54,21 @@ export interface EngramAccessHttpServerOptions {
|
|
|
54
54
|
authTokens?: string[];
|
|
55
55
|
/** Dynamic token loader — called on each auth check so new/revoked tokens take effect without restart. */
|
|
56
56
|
authTokensGetter?: () => string[];
|
|
57
|
+
/**
|
|
58
|
+
* Dynamic token-ENTRY loader ({token, connector} pairs from one coherent
|
|
59
|
+
* snapshot). Preferred over `authTokensGetter` when a `tokenPathPolicy`
|
|
60
|
+
* is set: the connector used for the policy decision comes from the SAME
|
|
61
|
+
* entry that validated, so identity can never lag validation.
|
|
62
|
+
*/
|
|
63
|
+
authTokenEntriesGetter?: () => ReadonlyArray<{ token: string; connector?: string }>;
|
|
64
|
+
/**
|
|
65
|
+
* Optional per-request scope policy for tokens sourced from
|
|
66
|
+
* `authTokenEntriesGetter`. Return false to deny the (validated) token
|
|
67
|
+
* for this pathname. Static `authToken`/`authTokens` (operator-supplied)
|
|
68
|
+
* bypass the policy. Entries whose connector is missing FAIL CLOSED when
|
|
69
|
+
* a policy is configured.
|
|
70
|
+
*/
|
|
71
|
+
tokenPathPolicy?: (connector: string, pathname: string | undefined) => boolean;
|
|
57
72
|
principal?: string;
|
|
58
73
|
maxBodyBytes?: number;
|
|
59
74
|
adminConsoleEnabled?: boolean;
|
|
@@ -78,6 +93,28 @@ export interface EngramAccessHttpServerOptions {
|
|
|
78
93
|
* existing health behavior.
|
|
79
94
|
*/
|
|
80
95
|
readiness?: () => AccessHttpReadinessState;
|
|
96
|
+
/**
|
|
97
|
+
* When set, every 401 response includes
|
|
98
|
+
* `WWW-Authenticate: Bearer resource_metadata="<value>"` so MCP clients
|
|
99
|
+
* can discover the OAuth 2.0 protected-resource metadata document
|
|
100
|
+
* (RFC 9728). Must be an absolute http(s) URL; constructor throws on
|
|
101
|
+
* anything else. Unset → bare `Bearer`.
|
|
102
|
+
*/
|
|
103
|
+
resourceMetadataUrl?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Optional pre-auth request handler (e.g. OAuth facade mounted by
|
|
106
|
+
* `@remnic/server`). Runs after the admin-console handler and BEFORE
|
|
107
|
+
* bearer authorization. Return true if the request was fully handled
|
|
108
|
+
* (response ended). `ctx.authorized` reports whether the request
|
|
109
|
+
* carries a valid operator bearer token, so the handler can gate
|
|
110
|
+
* operator-only endpoints without owning token validation.
|
|
111
|
+
* Errors thrown by the handler flow into the existing error handling.
|
|
112
|
+
*/
|
|
113
|
+
externalRequestHandler?: (
|
|
114
|
+
req: IncomingMessage,
|
|
115
|
+
res: ServerResponse,
|
|
116
|
+
ctx: { authorized: boolean },
|
|
117
|
+
) => Promise<boolean>;
|
|
81
118
|
}
|
|
82
119
|
|
|
83
120
|
export interface EngramAccessHttpServerStatus {
|
|
@@ -183,6 +220,23 @@ function parseHttpServerPort(port: number | undefined): number {
|
|
|
183
220
|
}
|
|
184
221
|
return port;
|
|
185
222
|
}
|
|
223
|
+
function assertResourceMetadataUrl(value: string | undefined): string | undefined {
|
|
224
|
+
if (value === undefined) return undefined;
|
|
225
|
+
let parsed: URL;
|
|
226
|
+
try {
|
|
227
|
+
parsed = new URL(value);
|
|
228
|
+
} catch {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`access HTTP resourceMetadataUrl must be an absolute http(s) URL, got: ${value}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`access HTTP resourceMetadataUrl must use http or https, got: ${parsed.protocol}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
186
240
|
|
|
187
241
|
function parseTrustZoneKindFilter(raw: string | null): TrustZoneRecordKind | undefined {
|
|
188
242
|
if (raw === null) return undefined;
|
|
@@ -283,6 +337,8 @@ export class EngramAccessHttpServer {
|
|
|
283
337
|
private readonly authToken?: string;
|
|
284
338
|
private readonly authTokens: string[];
|
|
285
339
|
private readonly authTokensGetter?: () => string[];
|
|
340
|
+
private readonly authTokenEntriesGetter?: () => ReadonlyArray<{ token: string; connector?: string }>;
|
|
341
|
+
private readonly tokenPathPolicy?: (connector: string, pathname: string | undefined) => boolean;
|
|
286
342
|
private readonly authenticatedPrincipal?: string;
|
|
287
343
|
private readonly maxBodyBytes: number;
|
|
288
344
|
private readonly adminConsoleEnabled: boolean;
|
|
@@ -292,6 +348,12 @@ export class EngramAccessHttpServer {
|
|
|
292
348
|
private readonly trustPrincipalHeader: boolean;
|
|
293
349
|
private readonly adapterRegistry: AdapterRegistry | null;
|
|
294
350
|
private readonly readiness: () => AccessHttpReadinessState;
|
|
351
|
+
private readonly resourceMetadataUrl?: string;
|
|
352
|
+
private readonly externalRequestHandler?: (
|
|
353
|
+
req: IncomingMessage,
|
|
354
|
+
res: ServerResponse,
|
|
355
|
+
ctx: { authorized: boolean },
|
|
356
|
+
) => Promise<boolean>;
|
|
295
357
|
private readonly writeRequestTimestamps: number[] = [];
|
|
296
358
|
private readonly mcpServer: EngramMcpServer;
|
|
297
359
|
private server: Server | null = null;
|
|
@@ -323,6 +385,8 @@ export class EngramAccessHttpServer {
|
|
|
323
385
|
this.authToken = options.authToken?.trim() || undefined;
|
|
324
386
|
this.authTokens = (options.authTokens ?? []).map((t) => t.trim()).filter(Boolean);
|
|
325
387
|
this.authTokensGetter = options.authTokensGetter;
|
|
388
|
+
this.authTokenEntriesGetter = options.authTokenEntriesGetter;
|
|
389
|
+
this.tokenPathPolicy = options.tokenPathPolicy;
|
|
326
390
|
this.authenticatedPrincipal = options.principal?.trim() || undefined;
|
|
327
391
|
this.maxBodyBytes = Number.isFinite(options.maxBodyBytes)
|
|
328
392
|
? Math.max(1, Math.floor(options.maxBodyBytes ?? 131072))
|
|
@@ -333,6 +397,8 @@ export class EngramAccessHttpServer {
|
|
|
333
397
|
this.adminControls = options.adminControls;
|
|
334
398
|
this.trustPrincipalHeader = options.trustPrincipalHeader === true;
|
|
335
399
|
this.readiness = options.readiness ?? (() => ({ ready: true, warmupAttempts: 0 }));
|
|
400
|
+
this.resourceMetadataUrl = assertResourceMetadataUrl(options.resourceMetadataUrl);
|
|
401
|
+
this.externalRequestHandler = options.externalRequestHandler;
|
|
336
402
|
this.adapterRegistry = options.enableAdapters !== false
|
|
337
403
|
? (options.adapterRegistry ?? new AdapterRegistry())
|
|
338
404
|
: null;
|
|
@@ -351,7 +417,7 @@ export class EngramAccessHttpServer {
|
|
|
351
417
|
}
|
|
352
418
|
|
|
353
419
|
async start(): Promise<EngramAccessHttpServerStatus> {
|
|
354
|
-
if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter) {
|
|
420
|
+
if (!this.authToken && this.authTokens.length === 0 && !this.authTokensGetter && !this.authTokenEntriesGetter) {
|
|
355
421
|
throw new Error("engram access HTTP requires authToken or authTokens");
|
|
356
422
|
}
|
|
357
423
|
if (this.server) return this.status();
|
|
@@ -642,11 +708,40 @@ export class EngramAccessHttpServer {
|
|
|
642
708
|
|
|
643
709
|
}
|
|
644
710
|
|
|
711
|
+
// Run any host-supplied pre-auth request handler. It runs AFTER the
|
|
712
|
+
// admin-console branch (admin assets are public) and BEFORE the
|
|
713
|
+
// operator bearer gate. The handler decides whether it has fully
|
|
714
|
+
// owned the response (return true) or wants the request to fall
|
|
715
|
+
// through to the normal pipeline. `ctx.authorized` is computed
|
|
716
|
+
// here so the handler can implement operator-only endpoints
|
|
717
|
+
// (e.g. /oauth/pending) without owning token validation.
|
|
718
|
+
if (this.externalRequestHandler) {
|
|
719
|
+
const authorized = this.isAuthorized(req, pathname);
|
|
720
|
+
if (await this.externalRequestHandler(req, res, { authorized })) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
645
725
|
if (!this.isAuthorized(req, pathname)) {
|
|
646
726
|
const body = JSON.stringify({ error: "unauthorized", code: "unauthorized" });
|
|
647
727
|
res.writeHead(401, {
|
|
648
728
|
"content-type": "application/json; charset=utf-8",
|
|
649
|
-
"www-authenticate":
|
|
729
|
+
"www-authenticate": this.bearerChallenge(),
|
|
730
|
+
"x-request-id": correlationId,
|
|
731
|
+
});
|
|
732
|
+
res.end(body);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Method-conformance for the streamable-HTTP MCP endpoint:
|
|
737
|
+
// GET/DELETE on /mcp must return 405 + Allow: POST instead of
|
|
738
|
+
// silently falling through to the generic 404. POST continues
|
|
739
|
+
// to the normal handler below.
|
|
740
|
+
if (pathname === "/mcp" && (req.method === "GET" || req.method === "DELETE")) {
|
|
741
|
+
const body = JSON.stringify({ error: "method_not_allowed", code: "method_not_allowed" });
|
|
742
|
+
res.writeHead(405, {
|
|
743
|
+
"content-type": "application/json; charset=utf-8",
|
|
744
|
+
allow: "POST",
|
|
650
745
|
"x-request-id": correlationId,
|
|
651
746
|
});
|
|
652
747
|
res.end(body);
|
|
@@ -2682,7 +2777,28 @@ export class EngramAccessHttpServer {
|
|
|
2682
2777
|
}
|
|
2683
2778
|
|
|
2684
2779
|
private async handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
2780
|
+
// Reject requests that advertise an unknown MCP protocol version in
|
|
2781
|
+
// the streamable-HTTP `MCP-Protocol-Version` header. Absent or
|
|
2782
|
+
// valid → proceed. Unknown → 400 with a JSON-RPC-shaped error so
|
|
2783
|
+
// the client surfaces a clear message. The supported set is
|
|
2784
|
+
// exported by @remnic/core's access-mcp module to keep the
|
|
2785
|
+
// version policy in a single place.
|
|
2786
|
+
const headerVersion = req.headers["mcp-protocol-version"];
|
|
2787
|
+
if (typeof headerVersion === "string" && headerVersion.length > 0) {
|
|
2788
|
+
if (!(MCP_SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(headerVersion)) {
|
|
2789
|
+
this.respondJson(res, 400, {
|
|
2790
|
+
jsonrpc: "2.0",
|
|
2791
|
+
id: null,
|
|
2792
|
+
error: {
|
|
2793
|
+
code: -32000,
|
|
2794
|
+
message: `unsupported MCP-Protocol-Version: ${headerVersion}; supported: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(", ")}`,
|
|
2795
|
+
},
|
|
2796
|
+
});
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2685
2800
|
const body = await this.readJsonBody(req);
|
|
2801
|
+
|
|
2686
2802
|
const request = body as {
|
|
2687
2803
|
jsonrpc?: string;
|
|
2688
2804
|
id?: string | number | null;
|
|
@@ -3118,8 +3234,29 @@ export class EngramAccessHttpServer {
|
|
|
3118
3234
|
return result.data as SchemaTypeFor<S>;
|
|
3119
3235
|
}
|
|
3120
3236
|
|
|
3121
|
-
|
|
3122
|
-
|
|
3237
|
+
/**
|
|
3238
|
+
* Build the WWW-Authenticate challenge string for 401 responses.
|
|
3239
|
+
* When `resourceMetadataUrl` is configured, includes the RFC 9728
|
|
3240
|
+
* `resource_metadata` parameter so MCP clients (e.g. ChatGPT) can
|
|
3241
|
+
* discover the OAuth 2.0 protected-resource metadata document.
|
|
3242
|
+
* Otherwise the bare `Bearer` challenge is returned (unchanged).
|
|
3243
|
+
*/
|
|
3244
|
+
private bearerChallenge(): string {
|
|
3245
|
+
if (this.resourceMetadataUrl) {
|
|
3246
|
+
return `Bearer resource_metadata="${this.resourceMetadataUrl}"`;
|
|
3247
|
+
}
|
|
3248
|
+
return "Bearer";
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
private isAuthorized(req: IncomingMessage, pathname?: string): boolean {
|
|
3252
|
+
if (
|
|
3253
|
+
!this.authToken &&
|
|
3254
|
+
this.authTokens.length === 0 &&
|
|
3255
|
+
!this.authTokensGetter &&
|
|
3256
|
+
!this.authTokenEntriesGetter
|
|
3257
|
+
) {
|
|
3258
|
+
return false;
|
|
3259
|
+
}
|
|
3123
3260
|
// Primary path: Authorization: Bearer <token> header.
|
|
3124
3261
|
const raw = req.headers.authorization;
|
|
3125
3262
|
let candidate: string | null = null;
|
|
@@ -3159,7 +3296,24 @@ export class EngramAccessHttpServer {
|
|
|
3159
3296
|
for (const valid of this.authTokens) {
|
|
3160
3297
|
if (this.timingSafeStringEqual(token, valid)) return true;
|
|
3161
3298
|
}
|
|
3162
|
-
//
|
|
3299
|
+
// Entry-based dynamic tokens are AUTHORITATIVE when configured: the
|
|
3300
|
+
// dynamic-token decision ends here (no fall-through to the string
|
|
3301
|
+
// getter, which carries no identity and would bypass the policy).
|
|
3302
|
+
// Validation and connector identity come from the same snapshot entry,
|
|
3303
|
+
// so a scope policy can never observe a token fresher than the
|
|
3304
|
+
// identity it scopes (mint/revoke coherence).
|
|
3305
|
+
if (this.authTokenEntriesGetter) {
|
|
3306
|
+
for (const entry of this.authTokenEntriesGetter()) {
|
|
3307
|
+
if (!this.timingSafeStringEqual(token, entry.token)) continue;
|
|
3308
|
+
if (!this.tokenPathPolicy) return true;
|
|
3309
|
+
// Fail closed: a policy without a connector identity denies.
|
|
3310
|
+
if (typeof entry.connector !== "string" || entry.connector.length === 0) return false;
|
|
3311
|
+
return this.tokenPathPolicy(entry.connector, pathname);
|
|
3312
|
+
}
|
|
3313
|
+
return false;
|
|
3314
|
+
}
|
|
3315
|
+
// String-token getter (no identity, no policy) — only consulted when
|
|
3316
|
+
// no entry getter is configured.
|
|
3163
3317
|
if (this.authTokensGetter) {
|
|
3164
3318
|
for (const valid of this.authTokensGetter()) {
|
|
3165
3319
|
if (this.timingSafeStringEqual(token, valid)) return true;
|
package/src/access-mcp.ts
CHANGED
|
@@ -78,7 +78,79 @@ type McpResource = {
|
|
|
78
78
|
_meta?: Record<string, unknown>;
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Conservative allowlist of canonical MCP tool suffixes that are
|
|
83
|
+
* unambiguously read-only. Tools in this set are tagged with
|
|
84
|
+
* `annotations: { readOnlyHint: true }` so ChatGPT (and other MCP
|
|
85
|
+
* clients that honor the hint) can skip per-call confirmation.
|
|
86
|
+
*
|
|
87
|
+
* The list is suffix-based so it covers both the `remnic.*` and
|
|
88
|
+
* `engram.*` naming forms. Anything not on it stays unannotated:
|
|
89
|
+
* uncertainty is resolved as "might mutate".
|
|
90
|
+
*
|
|
91
|
+
* Excluded by construction: anything that writes, runs a pipeline,
|
|
92
|
+
* flushes, applies, records, imports, or destructively deletes.
|
|
93
|
+
*/
|
|
94
|
+
const MCP_READ_ONLY_TOOL_SUFFIXES: Readonly<Record<string, true>> = {
|
|
95
|
+
recall: true,
|
|
96
|
+
recall_explain: true,
|
|
97
|
+
recall_tier_explain: true,
|
|
98
|
+
recall_xray: true,
|
|
99
|
+
briefing: true,
|
|
100
|
+
wearables_status: true,
|
|
101
|
+
transcript_day: true,
|
|
102
|
+
transcript_search: true,
|
|
103
|
+
transcript_memories: true,
|
|
104
|
+
action_confidence: true,
|
|
105
|
+
capsule_list: true,
|
|
106
|
+
procedural_stats: true,
|
|
107
|
+
memory_get: true,
|
|
108
|
+
memory_timeline: true,
|
|
109
|
+
entity_get: true,
|
|
110
|
+
review_queue_list: true,
|
|
111
|
+
lcm_search: true,
|
|
112
|
+
continuity_incident_list: true,
|
|
113
|
+
identity_anchor_get: true,
|
|
114
|
+
memory_identity: true,
|
|
115
|
+
memory_search: true,
|
|
116
|
+
memory_profile: true,
|
|
117
|
+
memory_entities_list: true,
|
|
118
|
+
memory_questions: true,
|
|
119
|
+
memory_last_recall: true,
|
|
120
|
+
memory_intent_debug: true,
|
|
121
|
+
memory_qmd_debug: true,
|
|
122
|
+
memory_graph_explain: true,
|
|
123
|
+
graph_snapshot: true,
|
|
124
|
+
review_list: true,
|
|
125
|
+
profiling_report: true,
|
|
126
|
+
peer_list: true,
|
|
127
|
+
peer_get: true,
|
|
128
|
+
peer_profile_get: true,
|
|
129
|
+
console_state: true,
|
|
130
|
+
dreams_status: true,
|
|
131
|
+
codegraph_list_projects: true,
|
|
132
|
+
codegraph_index_status: true,
|
|
133
|
+
codegraph_search_graph: true,
|
|
134
|
+
codegraph_trace_path: true,
|
|
135
|
+
codegraph_detect_changes: true,
|
|
136
|
+
codegraph_query_graph: true,
|
|
137
|
+
codegraph_get_schema: true,
|
|
138
|
+
codegraph_get_snippet: true,
|
|
139
|
+
codegraph_get_architecture: true,
|
|
140
|
+
codegraph_search_code: true,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* MCP protocol versions this server understands, ordered newest → oldest.
|
|
145
|
+
* Exported so the HTTP transport can validate the `MCP-Protocol-Version`
|
|
146
|
+
* header and reject requests advertising an unknown version.
|
|
147
|
+
*/
|
|
148
|
+
export const MCP_SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = [
|
|
149
|
+
"2025-06-18",
|
|
150
|
+
"2025-03-26",
|
|
151
|
+
"2024-11-05",
|
|
152
|
+
];
|
|
153
|
+
const MCP_DEFAULT_PROTOCOL_VERSION: string = MCP_SUPPORTED_PROTOCOL_VERSIONS[0] ?? "2025-06-18";
|
|
82
154
|
const LEGACY_MCP_PREFIX = "engram.";
|
|
83
155
|
const CANONICAL_MCP_PREFIX = "remnic.";
|
|
84
156
|
|
|
@@ -87,13 +159,27 @@ function toCanonicalToolName(name: string): string {
|
|
|
87
159
|
? `${CANONICAL_MCP_PREFIX}${name.slice(LEGACY_MCP_PREFIX.length)}`
|
|
88
160
|
: name;
|
|
89
161
|
}
|
|
90
|
-
|
|
91
162
|
function toLegacyToolName(name: string): string {
|
|
92
163
|
return name.startsWith(CANONICAL_MCP_PREFIX)
|
|
93
164
|
? `${LEGACY_MCP_PREFIX}${name.slice(CANONICAL_MCP_PREFIX.length)}`
|
|
94
165
|
: name;
|
|
95
166
|
}
|
|
96
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Suffix-based allowlist matcher. Returns true for tools whose canonical
|
|
170
|
+
* suffix (after stripping `remnic.` or `engram.`) is in
|
|
171
|
+
* {@link MCP_READ_ONLY_TOOL_SUFFIXES}. Unprefixed names are treated as
|
|
172
|
+
* unannotated.
|
|
173
|
+
*/
|
|
174
|
+
function isReadOnlyToolName(name: string): boolean {
|
|
175
|
+
for (const prefix of [CANONICAL_MCP_PREFIX, LEGACY_MCP_PREFIX]) {
|
|
176
|
+
if (name.startsWith(prefix)) {
|
|
177
|
+
return MCP_READ_ONLY_TOOL_SUFFIXES[name.slice(prefix.length)] === true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
97
183
|
function withToolAliases(tool: McpTool, emitLegacyTools = true): McpTool[] {
|
|
98
184
|
const canonicalName = toCanonicalToolName(tool.name);
|
|
99
185
|
const canonicalTool = canonicalName === tool.name ? tool : { ...tool, name: canonicalName };
|
|
@@ -2342,6 +2428,17 @@ export class EngramMcpServer {
|
|
|
2342
2428
|
);
|
|
2343
2429
|
this.tools = [...this.tools, ...chatTools];
|
|
2344
2430
|
}
|
|
2431
|
+
// Apply `readOnlyHint` annotations to the conservative read-only
|
|
2432
|
+
// allowlist. Done as a final pass so every spread (chat, codegraph,
|
|
2433
|
+
// coding_*, correction, etc.) inherits the annotation without
|
|
2434
|
+
// scattering the same logic across every `withToolAliases` call site.
|
|
2435
|
+
// Suffix-based matching covers both the `remnic.*` and `engram.*`
|
|
2436
|
+
// naming forms emitted by `withToolAliases`.
|
|
2437
|
+
this.tools = this.tools.map((tool) =>
|
|
2438
|
+
isReadOnlyToolName(tool.name) && tool.annotations?.readOnlyHint !== true
|
|
2439
|
+
? { ...tool, annotations: { ...(tool.annotations ?? {}), readOnlyHint: true } }
|
|
2440
|
+
: tool,
|
|
2441
|
+
);
|
|
2345
2442
|
}
|
|
2346
2443
|
|
|
2347
2444
|
/** Get clientInfo for a specific MCP session. Returns undefined for non-MCP requests. */
|
|
@@ -2369,6 +2466,24 @@ export class EngramMcpServer {
|
|
|
2369
2466
|
}
|
|
2370
2467
|
if (method === "initialize") {
|
|
2371
2468
|
const params = request.params ?? {};
|
|
2469
|
+
// MCP initialize REQUIRES params.protocolVersion (string). Reject a
|
|
2470
|
+
// missing/mistyped field with JSON-RPC invalid params instead of
|
|
2471
|
+
// silently negotiating (repo rule: never reinterpret invalid input).
|
|
2472
|
+
// An unsupported-but-well-formed version gets the spec-mandated
|
|
2473
|
+
// counter-offer below instead: the server answers with the newest
|
|
2474
|
+
// version it supports and the client decides whether to proceed.
|
|
2475
|
+
if (typeof params.protocolVersion !== "string" || params.protocolVersion.length === 0) {
|
|
2476
|
+
return {
|
|
2477
|
+
jsonrpc: "2.0",
|
|
2478
|
+
id,
|
|
2479
|
+
error: {
|
|
2480
|
+
code: -32602,
|
|
2481
|
+
message:
|
|
2482
|
+
"initialize requires params.protocolVersion (string); " +
|
|
2483
|
+
`supported versions: ${MCP_SUPPORTED_PROTOCOL_VERSIONS.join(", ")}`,
|
|
2484
|
+
},
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2372
2487
|
const rawClientInfo = params.clientInfo as { name?: string; version?: string } | undefined;
|
|
2373
2488
|
// Generate a server-side session ID for this MCP session.
|
|
2374
2489
|
// The caller should send this back as Mcp-Session-Id on subsequent requests.
|
|
@@ -2391,7 +2506,9 @@ export class EngramMcpServer {
|
|
|
2391
2506
|
jsonrpc: "2.0",
|
|
2392
2507
|
id,
|
|
2393
2508
|
result: {
|
|
2394
|
-
protocolVersion:
|
|
2509
|
+
protocolVersion: MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(params.protocolVersion)
|
|
2510
|
+
? params.protocolVersion
|
|
2511
|
+
: MCP_DEFAULT_PROTOCOL_VERSION,
|
|
2395
2512
|
capabilities: {
|
|
2396
2513
|
tools: {},
|
|
2397
2514
|
resources: {},
|
package/src/index.ts
CHANGED
|
@@ -977,11 +977,14 @@ export {
|
|
|
977
977
|
// ---------------------------------------------------------------------------
|
|
978
978
|
|
|
979
979
|
export {
|
|
980
|
+
buildTokenEntry,
|
|
981
|
+
commitTokenEntry,
|
|
980
982
|
generateToken,
|
|
981
983
|
listTokens,
|
|
982
984
|
revokeToken,
|
|
983
985
|
getAllValidTokens,
|
|
984
986
|
getAllValidTokensCached,
|
|
987
|
+
getAllValidTokenEntriesCached,
|
|
985
988
|
resolveConnectorFromToken,
|
|
986
989
|
loadTokenStore,
|
|
987
990
|
saveTokenStore,
|
package/src/tokens.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
buildTokenEntry,
|
|
9
9
|
commitTokenEntry,
|
|
10
10
|
generateToken,
|
|
11
|
+
getAllValidTokenEntriesCached,
|
|
11
12
|
getAllValidTokensCached,
|
|
12
13
|
loadTokenStore,
|
|
13
14
|
revokeToken,
|
|
@@ -208,3 +209,60 @@ test("generateToken uses a recognizable prefix for the omp connector", async ()
|
|
|
208
209
|
await rm(dir, { recursive: true, force: true });
|
|
209
210
|
}
|
|
210
211
|
});
|
|
212
|
+
|
|
213
|
+
test("generateToken uses a recognizable prefix for the chatgpt connector", async () => {
|
|
214
|
+
const { dir, tokensPath } = await makeTempTokenPath();
|
|
215
|
+
try {
|
|
216
|
+
const entry = generateToken("chatgpt", tokensPath);
|
|
217
|
+
assert.equal(entry.connector, "chatgpt");
|
|
218
|
+
assert.ok(
|
|
219
|
+
entry.token.startsWith("remnic_cg_"),
|
|
220
|
+
`expected remnic_cg_ prefix, got ${entry.token}`,
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
await rm(dir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("commitTokenEntry replaces the prior chatgpt entry so re-linking rotates the token", async () => {
|
|
228
|
+
const { dir, tokensPath } = await makeTempTokenPath();
|
|
229
|
+
try {
|
|
230
|
+
const first = generateToken("chatgpt", tokensPath);
|
|
231
|
+
commitTokenEntry(first, tokensPath);
|
|
232
|
+
const second = generateToken("chatgpt", tokensPath);
|
|
233
|
+
commitTokenEntry(second, tokensPath);
|
|
234
|
+
assert.notEqual(first.token, second.token, "re-linking must mint a new token");
|
|
235
|
+
const store = loadTokenStore(tokensPath);
|
|
236
|
+
const chatgptEntries = store.tokens.filter((t) => t.connector === "chatgpt");
|
|
237
|
+
assert.equal(chatgptEntries.length, 1, "only one chatgpt entry survives the rotate");
|
|
238
|
+
assert.equal(chatgptEntries[0]?.token, second.token, "the new token is the surviving one");
|
|
239
|
+
} finally {
|
|
240
|
+
await rm(dir, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("entries snapshot is coherent with validation: mint and revoke take effect immediately", async () => {
|
|
245
|
+
const { dir, tokensPath } = await makeTempTokenPath();
|
|
246
|
+
try {
|
|
247
|
+
// Warm the cache so a stale snapshot WOULD be observable if mutation
|
|
248
|
+
// failed to invalidate it.
|
|
249
|
+
assert.deepEqual([...getAllValidTokenEntriesCached(tokensPath)], []);
|
|
250
|
+
|
|
251
|
+
// Mint-then-use: the fresh token resolves with its connector in the
|
|
252
|
+
// SAME snapshot call that validates it — no window where the token is
|
|
253
|
+
// valid but its identity is unknown.
|
|
254
|
+
const minted = generateToken("chatgpt", tokensPath);
|
|
255
|
+
const afterMint = getAllValidTokenEntriesCached(tokensPath);
|
|
256
|
+
const found = afterMint.find((entry) => entry.token === minted.token);
|
|
257
|
+
assert.ok(found, "freshly minted token must appear immediately");
|
|
258
|
+
assert.equal(found.connector, "chatgpt");
|
|
259
|
+
assert.deepEqual(getAllValidTokensCached(tokensPath), [minted.token]);
|
|
260
|
+
|
|
261
|
+
// Revoke-then-use: the token disappears from the snapshot immediately.
|
|
262
|
+
assert.equal(revokeToken("chatgpt", tokensPath), true);
|
|
263
|
+
assert.deepEqual([...getAllValidTokenEntriesCached(tokensPath)], []);
|
|
264
|
+
assert.deepEqual(getAllValidTokensCached(tokensPath), []);
|
|
265
|
+
} finally {
|
|
266
|
+
await rm(dir, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
});
|
package/src/tokens.ts
CHANGED
|
@@ -36,6 +36,7 @@ const TOKEN_PREFIXES: Record<string, string> = {
|
|
|
36
36
|
"windsurf": "remnic_ws_",
|
|
37
37
|
"amp": "remnic_am_",
|
|
38
38
|
"generic-mcp": "remnic_gm_",
|
|
39
|
+
"chatgpt": "remnic_cg_",
|
|
39
40
|
};
|
|
40
41
|
|
|
41
42
|
function defaultTokensPath(): string {
|
|
@@ -260,26 +261,36 @@ export function getAllValidTokens(tokensPath?: string): string[] {
|
|
|
260
261
|
return loadTokenStore(tokensPath).tokens.map((t) => t.token);
|
|
261
262
|
}
|
|
262
263
|
|
|
263
|
-
// Cached token
|
|
264
|
-
// Re-reads tokens.json at most once per TTL interval (default 5s).
|
|
264
|
+
// Cached token-entry snapshot to avoid synchronous disk I/O on every HTTP
|
|
265
|
+
// request. Re-reads tokens.json at most once per TTL interval (default 5s).
|
|
266
|
+
// There is deliberately exactly ONE cache: validation (token strings) and
|
|
267
|
+
// identity (connector ids) are derived from the SAME snapshot, so a token
|
|
268
|
+
// can never validate against a fresher snapshot than the one that resolves
|
|
269
|
+
// its connector. saveTokenStore() invalidates on every mutation, so a
|
|
270
|
+
// freshly minted or revoked token is coherent immediately.
|
|
265
271
|
const TOKEN_CACHE_TTL_MS = 5_000;
|
|
266
|
-
let
|
|
272
|
+
let _cachedEntries: TokenEntry[] = [];
|
|
267
273
|
let _cachedAt = 0;
|
|
268
274
|
let _cachedPath: string | undefined;
|
|
269
275
|
|
|
270
276
|
function invalidateTokenCache(): void {
|
|
271
|
-
|
|
277
|
+
_cachedEntries = [];
|
|
272
278
|
_cachedAt = 0;
|
|
273
279
|
_cachedPath = undefined;
|
|
274
280
|
}
|
|
275
281
|
|
|
276
|
-
|
|
282
|
+
/** Cached token-entry snapshot ({token, connector} pairs). */
|
|
283
|
+
export function getAllValidTokenEntriesCached(tokensPath?: string): readonly TokenEntry[] {
|
|
277
284
|
const now = Date.now();
|
|
278
|
-
if (now - _cachedAt < TOKEN_CACHE_TTL_MS && tokensPath === _cachedPath) return
|
|
279
|
-
|
|
285
|
+
if (now - _cachedAt < TOKEN_CACHE_TTL_MS && tokensPath === _cachedPath) return _cachedEntries;
|
|
286
|
+
_cachedEntries = loadTokenStore(tokensPath).tokens;
|
|
280
287
|
_cachedAt = now;
|
|
281
288
|
_cachedPath = tokensPath;
|
|
282
|
-
return
|
|
289
|
+
return _cachedEntries;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function getAllValidTokensCached(tokensPath?: string): string[] {
|
|
293
|
+
return getAllValidTokenEntriesCached(tokensPath).map((entry) => entry.token);
|
|
283
294
|
}
|
|
284
295
|
|
|
285
296
|
export function resolveConnectorFromToken(token: string, tokensPath?: string): string | undefined {
|