@redaksjon/protokoll 1.0.26 → 1.0.27-dev.20260305054841.9de8b06
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/engineLogging.js +2 -2
- package/dist/engineLogging.js.map +1 -1
- package/dist/mcp/server-hono.js +568 -15
- package/dist/mcp/server-hono.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp/server-hono.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import 'dotenv/config';
|
|
3
3
|
import { scryptSync, timingSafeEqual, randomUUID, createHash } from 'node:crypto';
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
5
|
import { Command } from 'commander';
|
|
5
6
|
import { Hono } from 'hono';
|
|
6
7
|
import { serve } from '@hono/node-server';
|
|
@@ -11,17 +12,17 @@ import { bodyLimit } from 'hono/body-limit';
|
|
|
11
12
|
import { join, isAbsolute, relative, extname, basename, resolve, dirname } from 'node:path';
|
|
12
13
|
import * as fs from 'node:fs/promises';
|
|
13
14
|
import { readFile, mkdir, access } from 'node:fs/promises';
|
|
15
|
+
import { tmpdir, homedir } from 'node:os';
|
|
14
16
|
import { glob } from 'glob';
|
|
17
|
+
import yaml__default, { load } from 'js-yaml';
|
|
15
18
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
16
19
|
import { ListRootsRequestSchema, ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
17
20
|
import * as Cardigantime from '@utilarium/cardigantime';
|
|
18
21
|
import Logging from '@fjell/logging';
|
|
19
22
|
import { z } from 'zod';
|
|
20
|
-
import { m as markTranscriptIndexDirtyForStorage, j as getOutputDirectory, k as getOutputStorage, D as DEFAULT_CONFIG_FILE, s as setRoots, l as getServerConfig, n as getStorageConfig, o as getContext, c as configureEngineLoggingBridge, p as createQuietLogger, t as tools, h as handleToolCall, a as handleListResources, b as handleReadResource, g as getPrompts, d as getPrompt,
|
|
21
|
-
import { tmpdir } from 'node:os';
|
|
23
|
+
import { m as markTranscriptIndexDirtyForStorage, j as getOutputDirectory, k as getOutputStorage, D as DEFAULT_CONFIG_FILE, s as setRoots, l as getServerConfig, n as getStorageConfig, o as getContext, c as configureEngineLoggingBridge, p as createQuietLogger, t as tools, h as handleToolCall, a as handleListResources, q as parseUri, r as handleReadTranscript, u as readTranscriptsListResource, b as handleReadResource, g as getPrompts, d as getPrompt, v as isInitialized, e as initializeServerConfig, f as getCachedRoots, w as setWorkerInstance } from '../engineLogging.js';
|
|
22
24
|
import { Transcript, Weighting, Pipeline } from '@redaksjon/protokoll-engine';
|
|
23
25
|
import { PklTranscript } from '@redaksjon/protokoll-format';
|
|
24
|
-
import yaml__default from 'js-yaml';
|
|
25
26
|
import 'node:fs';
|
|
26
27
|
import 'node:url';
|
|
27
28
|
import '@redaksjon/context';
|
|
@@ -916,7 +917,8 @@ function parseKeys(value) {
|
|
|
916
917
|
user_id: userId,
|
|
917
918
|
secret_hash: secretHash,
|
|
918
919
|
enabled: asBooleanWithDefault(obj.enabled, true),
|
|
919
|
-
expires_at: asString(obj.expires_at) ?? void 0
|
|
920
|
+
expires_at: asString(obj.expires_at) ?? void 0,
|
|
921
|
+
allowed_projects: asStringArray(obj.allowed_projects)
|
|
920
922
|
};
|
|
921
923
|
});
|
|
922
924
|
}
|
|
@@ -1068,7 +1070,8 @@ function createRbacAuthorizer(config) {
|
|
|
1068
1070
|
context: {
|
|
1069
1071
|
user_id: user.user_id,
|
|
1070
1072
|
roles: [...user.roles],
|
|
1071
|
-
key_id: key.key_id
|
|
1073
|
+
key_id: key.key_id,
|
|
1074
|
+
allowed_projects: key.allowed_projects && key.allowed_projects.length > 0 ? [...key.allowed_projects] : void 0
|
|
1072
1075
|
}
|
|
1073
1076
|
};
|
|
1074
1077
|
},
|
|
@@ -1231,6 +1234,164 @@ function buildEnvStorageConfig() {
|
|
|
1231
1234
|
if (hasGcsValue) storage.gcs = gcs;
|
|
1232
1235
|
return storage;
|
|
1233
1236
|
}
|
|
1237
|
+
function readAbsolutePath(value) {
|
|
1238
|
+
if (typeof value !== "string") return void 0;
|
|
1239
|
+
const trimmed = value.trim();
|
|
1240
|
+
if (!trimmed.startsWith("/")) return void 0;
|
|
1241
|
+
return trimmed;
|
|
1242
|
+
}
|
|
1243
|
+
function readAbsolutePathList(value) {
|
|
1244
|
+
if (!Array.isArray(value)) return [];
|
|
1245
|
+
return value.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry.startsWith("/"));
|
|
1246
|
+
}
|
|
1247
|
+
function commonAncestor(paths) {
|
|
1248
|
+
if (paths.length === 0) {
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
const normalized = paths.map((pathValue) => resolve(pathValue));
|
|
1252
|
+
const [first, ...rest] = normalized;
|
|
1253
|
+
let current = first;
|
|
1254
|
+
while (current !== dirname(current)) {
|
|
1255
|
+
const matches = rest.every((entry) => entry === current || entry.startsWith(`${current}/`));
|
|
1256
|
+
if (matches) {
|
|
1257
|
+
return current;
|
|
1258
|
+
}
|
|
1259
|
+
current = dirname(current);
|
|
1260
|
+
}
|
|
1261
|
+
return rest.every((entry) => entry === current || entry.startsWith(`${current}/`)) ? current : null;
|
|
1262
|
+
}
|
|
1263
|
+
function hasStorageConfig(config) {
|
|
1264
|
+
return typeof config.storage === "object" && config.storage !== null;
|
|
1265
|
+
}
|
|
1266
|
+
async function mergeStorageFromCanonicalConfig(config, explicitConfigPath) {
|
|
1267
|
+
if (hasStorageConfig(config)) {
|
|
1268
|
+
return config;
|
|
1269
|
+
}
|
|
1270
|
+
const roots = [
|
|
1271
|
+
readAbsolutePath(config.inputDirectory),
|
|
1272
|
+
readAbsolutePath(config.outputDirectory),
|
|
1273
|
+
readAbsolutePath(config.processedDirectory),
|
|
1274
|
+
...readAbsolutePathList(config.contextDirectories)
|
|
1275
|
+
].filter((value) => Boolean(value));
|
|
1276
|
+
if (roots.length === 0) {
|
|
1277
|
+
return config;
|
|
1278
|
+
}
|
|
1279
|
+
const ancestor = commonAncestor(roots.map((value) => dirname(value)));
|
|
1280
|
+
if (!ancestor) {
|
|
1281
|
+
return config;
|
|
1282
|
+
}
|
|
1283
|
+
const canonicalConfigPath = resolve(ancestor, DEFAULT_CONFIG_FILE);
|
|
1284
|
+
if (explicitConfigPath && resolve(explicitConfigPath) === canonicalConfigPath) {
|
|
1285
|
+
return config;
|
|
1286
|
+
}
|
|
1287
|
+
try {
|
|
1288
|
+
const rawCanonicalConfig = await readFile(canonicalConfigPath, "utf8");
|
|
1289
|
+
const parsed = load(rawCanonicalConfig);
|
|
1290
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1291
|
+
return config;
|
|
1292
|
+
}
|
|
1293
|
+
const candidate = parsed;
|
|
1294
|
+
if (!hasStorageConfig(candidate)) {
|
|
1295
|
+
return config;
|
|
1296
|
+
}
|
|
1297
|
+
lifecycleLogger.info("startup.storage.recovered_from_canonical_config", {
|
|
1298
|
+
canonicalConfigPath,
|
|
1299
|
+
inferredFromRoots: roots
|
|
1300
|
+
});
|
|
1301
|
+
return {
|
|
1302
|
+
...config,
|
|
1303
|
+
storage: candidate.storage
|
|
1304
|
+
};
|
|
1305
|
+
} catch {
|
|
1306
|
+
return config;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
function exportStorageConfigToEnv(config) {
|
|
1310
|
+
const storage = config.storage && typeof config.storage === "object" ? config.storage : null;
|
|
1311
|
+
if (!storage) {
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const backend = typeof storage.backend === "string" ? storage.backend.trim() : "";
|
|
1315
|
+
if (backend !== "gcs") {
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const gcs = storage.gcs && typeof storage.gcs === "object" ? storage.gcs : {};
|
|
1319
|
+
const setIfMissing = (name, value) => {
|
|
1320
|
+
if (typeof process.env[name] === "string" && process.env[name].trim().length > 0) {
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
1324
|
+
process.env[name] = value.trim();
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
setIfMissing("PROTOKOLL_STORAGE_BACKEND", "gcs");
|
|
1328
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_PROJECT_ID", gcs.projectId);
|
|
1329
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_INPUT_URI", gcs.inputUri);
|
|
1330
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_OUTPUT_URI", gcs.outputUri);
|
|
1331
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_CONTEXT_URI", gcs.contextUri);
|
|
1332
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_INPUT_BUCKET", gcs.inputBucket);
|
|
1333
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_INPUT_PREFIX", gcs.inputPrefix);
|
|
1334
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_OUTPUT_BUCKET", gcs.outputBucket);
|
|
1335
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_OUTPUT_PREFIX", gcs.outputPrefix);
|
|
1336
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_CONTEXT_BUCKET", gcs.contextBucket);
|
|
1337
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_CONTEXT_PREFIX", gcs.contextPrefix);
|
|
1338
|
+
setIfMissing("PROTOKOLL_STORAGE_GCS_CREDENTIALS_FILE", gcs.credentialsFile);
|
|
1339
|
+
}
|
|
1340
|
+
function readCredentialsEnvPath() {
|
|
1341
|
+
const candidates = [
|
|
1342
|
+
process.env.PROTOKOLL_STORAGE_GCS_CREDENTIALS_FILE,
|
|
1343
|
+
process.env.PROTOKOLL_LOCAL_GCS_CREDENTIALS_FILE,
|
|
1344
|
+
process.env.GOOGLE_APPLICATION_CREDENTIALS
|
|
1345
|
+
];
|
|
1346
|
+
for (const candidate of candidates) {
|
|
1347
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
|
1348
|
+
const trimmed = candidate.trim();
|
|
1349
|
+
return trimmed.startsWith("~/") ? resolve(homedir(), trimmed.slice(2)) : trimmed;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return void 0;
|
|
1353
|
+
}
|
|
1354
|
+
async function injectLocalGcsCredentialsIfMissing(config) {
|
|
1355
|
+
const storage = config.storage && typeof config.storage === "object" ? config.storage : null;
|
|
1356
|
+
if (!storage) {
|
|
1357
|
+
return config;
|
|
1358
|
+
}
|
|
1359
|
+
if (storage.backend !== "gcs") {
|
|
1360
|
+
return config;
|
|
1361
|
+
}
|
|
1362
|
+
const gcs = storage.gcs && typeof storage.gcs === "object" ? storage.gcs : {};
|
|
1363
|
+
const existingCredentials = readNonEmptyString(gcs.credentialsFile);
|
|
1364
|
+
if (existingCredentials) {
|
|
1365
|
+
return config;
|
|
1366
|
+
}
|
|
1367
|
+
const explicitFromEnv = readCredentialsEnvPath();
|
|
1368
|
+
const defaultLocalPath = resolve(homedir(), ".ssh/gcp-discursive-riotplan-service.json");
|
|
1369
|
+
const credentialCandidates = [
|
|
1370
|
+
explicitFromEnv,
|
|
1371
|
+
defaultLocalPath
|
|
1372
|
+
].filter((value) => Boolean(value));
|
|
1373
|
+
for (const candidate of credentialCandidates) {
|
|
1374
|
+
try {
|
|
1375
|
+
await access(candidate);
|
|
1376
|
+
lifecycleLogger.info("startup.storage.gcs.credentials.injected", {
|
|
1377
|
+
path: candidate,
|
|
1378
|
+
source: candidate === explicitFromEnv ? "env" : "local_default"
|
|
1379
|
+
});
|
|
1380
|
+
return {
|
|
1381
|
+
...config,
|
|
1382
|
+
storage: {
|
|
1383
|
+
...storage,
|
|
1384
|
+
gcs: {
|
|
1385
|
+
...gcs,
|
|
1386
|
+
credentialsFile: candidate
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
} catch {
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return config;
|
|
1394
|
+
}
|
|
1234
1395
|
function describeRawStorageConfig(config) {
|
|
1235
1396
|
const lines = [];
|
|
1236
1397
|
const rawStorage = config.storage;
|
|
@@ -1373,6 +1534,269 @@ function getAuthorizationHeaderSummary(c) {
|
|
|
1373
1534
|
if (hasApiKey) return "x-api-key";
|
|
1374
1535
|
return "none";
|
|
1375
1536
|
}
|
|
1537
|
+
const authContextStore = new AsyncLocalStorage();
|
|
1538
|
+
function getActiveAuthContext() {
|
|
1539
|
+
return authContextStore.getStore() ?? null;
|
|
1540
|
+
}
|
|
1541
|
+
function normalizeAllowedProjects(auth) {
|
|
1542
|
+
if (!auth?.allowed_projects || auth.allowed_projects.length === 0) {
|
|
1543
|
+
return [];
|
|
1544
|
+
}
|
|
1545
|
+
return auth.allowed_projects.map((value) => value.trim()).filter(Boolean);
|
|
1546
|
+
}
|
|
1547
|
+
function isProjectAllowed(projectId, allowedProjects) {
|
|
1548
|
+
if (!projectId) return false;
|
|
1549
|
+
const normalized = projectId.trim().toLowerCase();
|
|
1550
|
+
return allowedProjects.some((allowed) => allowed.toLowerCase() === normalized);
|
|
1551
|
+
}
|
|
1552
|
+
function hasProjectScope(auth) {
|
|
1553
|
+
return normalizeAllowedProjects(auth).length > 0;
|
|
1554
|
+
}
|
|
1555
|
+
function asRecord(value) {
|
|
1556
|
+
return value && typeof value === "object" ? value : {};
|
|
1557
|
+
}
|
|
1558
|
+
function extractTranscriptRefs(args) {
|
|
1559
|
+
const refs = [];
|
|
1560
|
+
const directKeys = ["transcriptPath", "sourceTranscriptPath", "targetTranscriptPath"];
|
|
1561
|
+
for (const key of directKeys) {
|
|
1562
|
+
const value = args[key];
|
|
1563
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
1564
|
+
refs.push(value.trim());
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
const listValue = args.transcriptPaths;
|
|
1568
|
+
if (Array.isArray(listValue)) {
|
|
1569
|
+
for (const entry of listValue) {
|
|
1570
|
+
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
1571
|
+
refs.push(entry.trim());
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
return refs;
|
|
1576
|
+
}
|
|
1577
|
+
async function enforceProjectScopeForTool(toolName, args, authContext) {
|
|
1578
|
+
const allowedProjects = normalizeAllowedProjects(authContext);
|
|
1579
|
+
if (allowedProjects.length === 0) {
|
|
1580
|
+
return args;
|
|
1581
|
+
}
|
|
1582
|
+
const scopedArgs = asRecord(args);
|
|
1583
|
+
const contextDirectory = typeof scopedArgs.contextDirectory === "string" ? scopedArgs.contextDirectory : void 0;
|
|
1584
|
+
if (toolName === "protokoll_list_transcripts") {
|
|
1585
|
+
const entityType = typeof scopedArgs.entityType === "string" ? scopedArgs.entityType.trim() : "";
|
|
1586
|
+
const entityId = typeof scopedArgs.entityId === "string" ? scopedArgs.entityId.trim() : "";
|
|
1587
|
+
if (entityType && entityType !== "project") {
|
|
1588
|
+
throw new Error('This API key is project-scoped. Use entityType="project" with an allowed entityId.');
|
|
1589
|
+
}
|
|
1590
|
+
if (entityId) {
|
|
1591
|
+
if (!isProjectAllowed(entityId, allowedProjects)) {
|
|
1592
|
+
throw new Error(`Project-scoped key cannot access project "${entityId}".`);
|
|
1593
|
+
}
|
|
1594
|
+
return {
|
|
1595
|
+
...scopedArgs,
|
|
1596
|
+
entityType: "project",
|
|
1597
|
+
entityId
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
if (allowedProjects.length === 1) {
|
|
1601
|
+
return {
|
|
1602
|
+
...scopedArgs,
|
|
1603
|
+
entityType: "project",
|
|
1604
|
+
entityId: allowedProjects[0]
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
throw new Error('This API key is scoped to multiple projects. Provide entityType="project" and an allowed entityId.');
|
|
1608
|
+
}
|
|
1609
|
+
if (toolName === "protokoll_create_note" || toolName === "protokoll_process_audio") {
|
|
1610
|
+
const providedProject = typeof scopedArgs.projectId === "string" ? scopedArgs.projectId.trim() : "";
|
|
1611
|
+
if (providedProject) {
|
|
1612
|
+
if (!isProjectAllowed(providedProject, allowedProjects)) {
|
|
1613
|
+
throw new Error(`Project-scoped key cannot write to project "${providedProject}".`);
|
|
1614
|
+
}
|
|
1615
|
+
return args;
|
|
1616
|
+
}
|
|
1617
|
+
if (allowedProjects.length === 1) {
|
|
1618
|
+
return {
|
|
1619
|
+
...scopedArgs,
|
|
1620
|
+
projectId: allowedProjects[0]
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
throw new Error("This API key is scoped to multiple projects. Provide an explicit allowed projectId.");
|
|
1624
|
+
}
|
|
1625
|
+
if (toolName === "protokoll_add_term") {
|
|
1626
|
+
const provided = Array.isArray(scopedArgs.projects) ? scopedArgs.projects.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
|
|
1627
|
+
if (provided.length > 0) {
|
|
1628
|
+
for (const projectId of provided) {
|
|
1629
|
+
if (!isProjectAllowed(projectId, allowedProjects)) {
|
|
1630
|
+
throw new Error(`Project-scoped key cannot attach term to project "${projectId}".`);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
return args;
|
|
1634
|
+
}
|
|
1635
|
+
if (allowedProjects.length === 1) {
|
|
1636
|
+
return {
|
|
1637
|
+
...scopedArgs,
|
|
1638
|
+
projects: [allowedProjects[0]]
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
throw new Error("This API key is scoped to multiple projects. Provide explicit allowed projects when creating a term.");
|
|
1642
|
+
}
|
|
1643
|
+
if (toolName === "protokoll_edit_term") {
|
|
1644
|
+
const replaceProjects = Array.isArray(scopedArgs.projects) ? scopedArgs.projects.filter((value) => typeof value === "string" && value.trim().length > 0) : null;
|
|
1645
|
+
const addProjects = Array.isArray(scopedArgs.add_projects) ? scopedArgs.add_projects.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
|
|
1646
|
+
const removeProjects = Array.isArray(scopedArgs.remove_projects) ? scopedArgs.remove_projects.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
|
|
1647
|
+
const validateProjectList = (values, action) => {
|
|
1648
|
+
for (const projectId of values) {
|
|
1649
|
+
if (!isProjectAllowed(projectId, allowedProjects)) {
|
|
1650
|
+
throw new Error(`Project-scoped key cannot ${action} project "${projectId}".`);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
if (replaceProjects) validateProjectList(replaceProjects, "set");
|
|
1655
|
+
if (addProjects.length > 0) validateProjectList(addProjects, "add");
|
|
1656
|
+
if (removeProjects.length > 0) validateProjectList(removeProjects, "remove");
|
|
1657
|
+
return args;
|
|
1658
|
+
}
|
|
1659
|
+
if (toolName === "protokoll_add_person" || toolName === "protokoll_add_company") {
|
|
1660
|
+
if (allowedProjects.length !== 1) {
|
|
1661
|
+
throw new Error("This API key is scoped to multiple projects. Person/company creation currently requires a single allowed project.");
|
|
1662
|
+
}
|
|
1663
|
+
return args;
|
|
1664
|
+
}
|
|
1665
|
+
if (toolName === "protokoll_edit_project" || toolName === "protokoll_update_project") {
|
|
1666
|
+
const projectId = typeof scopedArgs.id === "string" ? scopedArgs.id.trim() : "";
|
|
1667
|
+
if (!projectId || !isProjectAllowed(projectId, allowedProjects)) {
|
|
1668
|
+
throw new Error("Project-scoped key can only modify its allowed project.");
|
|
1669
|
+
}
|
|
1670
|
+
return args;
|
|
1671
|
+
}
|
|
1672
|
+
if (toolName === "protokoll_add_project" || toolName === "protokoll_delete_entity") {
|
|
1673
|
+
throw new Error("Project-scoped keys cannot create or delete projects/entities.");
|
|
1674
|
+
}
|
|
1675
|
+
if (toolName === "protokoll_get_entity") {
|
|
1676
|
+
const entityType = typeof scopedArgs.entityType === "string" ? scopedArgs.entityType.trim() : "";
|
|
1677
|
+
const entityId = typeof scopedArgs.entityId === "string" ? scopedArgs.entityId.trim() : "";
|
|
1678
|
+
if (entityType !== "project") {
|
|
1679
|
+
throw new Error("Project-scoped keys can only read project entities from context.");
|
|
1680
|
+
}
|
|
1681
|
+
if (!isProjectAllowed(entityId, allowedProjects)) {
|
|
1682
|
+
throw new Error(`Project-scoped key cannot access project "${entityId}".`);
|
|
1683
|
+
}
|
|
1684
|
+
return args;
|
|
1685
|
+
}
|
|
1686
|
+
if (toolName === "protokoll_list_projects") {
|
|
1687
|
+
return args;
|
|
1688
|
+
}
|
|
1689
|
+
if (toolName === "protokoll_context_status" || toolName === "protokoll_list_people" || toolName === "protokoll_list_terms" || toolName === "protokoll_list_companies" || toolName === "protokoll_search_context") {
|
|
1690
|
+
throw new Error(`Project-scoped key cannot call ${toolName}. Use project-scoped transcript/note tools and explicit project lookups.`);
|
|
1691
|
+
}
|
|
1692
|
+
if (toolName === "protokoll_edit_transcript") {
|
|
1693
|
+
const requestedProject = typeof scopedArgs.projectId === "string" ? scopedArgs.projectId.trim() : "";
|
|
1694
|
+
if (requestedProject && !isProjectAllowed(requestedProject, allowedProjects)) {
|
|
1695
|
+
throw new Error(`Project-scoped key cannot move transcripts to project "${requestedProject}".`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
if (toolName === "protokoll_get_transcript_by_uuid") {
|
|
1699
|
+
const uuid = typeof scopedArgs.uuid === "string" ? scopedArgs.uuid.trim() : "";
|
|
1700
|
+
if (uuid) {
|
|
1701
|
+
const readResult = await handleReadTranscript({ transcriptPath: uuid, contextDirectory });
|
|
1702
|
+
const metadata = asRecord(readResult.metadata);
|
|
1703
|
+
const projectId = typeof metadata.projectId === "string" ? metadata.projectId.trim() : "";
|
|
1704
|
+
if (!isProjectAllowed(projectId, allowedProjects)) {
|
|
1705
|
+
throw new Error(`Project-scoped key cannot access transcript from project "${projectId || "unassigned"}".`);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
return args;
|
|
1709
|
+
}
|
|
1710
|
+
const transcriptRefs = extractTranscriptRefs(scopedArgs);
|
|
1711
|
+
if (transcriptRefs.length === 0) {
|
|
1712
|
+
return args;
|
|
1713
|
+
}
|
|
1714
|
+
for (const transcriptRef of transcriptRefs) {
|
|
1715
|
+
const readResult = await handleReadTranscript({
|
|
1716
|
+
transcriptPath: transcriptRef,
|
|
1717
|
+
contextDirectory
|
|
1718
|
+
});
|
|
1719
|
+
const metadata = asRecord(readResult.metadata);
|
|
1720
|
+
const projectId = typeof metadata.projectId === "string" ? metadata.projectId.trim() : "";
|
|
1721
|
+
if (!isProjectAllowed(projectId, allowedProjects)) {
|
|
1722
|
+
throw new Error(`Project-scoped key cannot access transcript from project "${projectId || "unassigned"}".`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
return args;
|
|
1726
|
+
}
|
|
1727
|
+
function filterProjectScopedToolResult(toolName, result, authContext) {
|
|
1728
|
+
const allowedProjects = normalizeAllowedProjects(authContext);
|
|
1729
|
+
if (allowedProjects.length === 0) {
|
|
1730
|
+
return result;
|
|
1731
|
+
}
|
|
1732
|
+
if (toolName === "protokoll_list_projects") {
|
|
1733
|
+
const payload = asRecord(result);
|
|
1734
|
+
const projectsRaw = payload.projects;
|
|
1735
|
+
const projects = Array.isArray(projectsRaw) ? projectsRaw.filter((project) => {
|
|
1736
|
+
const item = asRecord(project);
|
|
1737
|
+
const id = typeof item.id === "string" ? item.id.trim() : "";
|
|
1738
|
+
return isProjectAllowed(id, allowedProjects);
|
|
1739
|
+
}) : [];
|
|
1740
|
+
return {
|
|
1741
|
+
...payload,
|
|
1742
|
+
projects,
|
|
1743
|
+
total: projects.length,
|
|
1744
|
+
count: projects.length
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
return result;
|
|
1748
|
+
}
|
|
1749
|
+
async function postProcessProjectScopedCreate(toolName, args, result, authContext) {
|
|
1750
|
+
const allowedProjects = normalizeAllowedProjects(authContext);
|
|
1751
|
+
if (allowedProjects.length !== 1) {
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
const scopedProjectId = allowedProjects[0];
|
|
1755
|
+
const payload = asRecord(result);
|
|
1756
|
+
const entity = asRecord(payload.entity);
|
|
1757
|
+
const entityId = typeof entity.id === "string" ? entity.id.trim() : "";
|
|
1758
|
+
if (!entityId) {
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
if (toolName === "protokoll_add_person") {
|
|
1762
|
+
await handleToolCall("protokoll_edit_project", {
|
|
1763
|
+
id: scopedProjectId,
|
|
1764
|
+
add_associated_people: [entityId],
|
|
1765
|
+
contextDirectory: asRecord(args).contextDirectory
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
if (toolName === "protokoll_add_company") {
|
|
1769
|
+
await handleToolCall("protokoll_edit_project", {
|
|
1770
|
+
id: scopedProjectId,
|
|
1771
|
+
add_associated_companies: [entityId],
|
|
1772
|
+
contextDirectory: asRecord(args).contextDirectory
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
function filterProjectEntitiesResourceJson(contents, allowedProjects) {
|
|
1777
|
+
if (!contents.text) {
|
|
1778
|
+
return contents;
|
|
1779
|
+
}
|
|
1780
|
+
try {
|
|
1781
|
+
const parsed = JSON.parse(contents.text);
|
|
1782
|
+
const entities = Array.isArray(parsed.entities) ? parsed.entities : [];
|
|
1783
|
+
const filtered = entities.filter((entity) => {
|
|
1784
|
+
const item = asRecord(entity);
|
|
1785
|
+
const id = typeof item.id === "string" ? item.id.trim() : "";
|
|
1786
|
+
return isProjectAllowed(id, allowedProjects);
|
|
1787
|
+
});
|
|
1788
|
+
return {
|
|
1789
|
+
...contents,
|
|
1790
|
+
text: JSON.stringify({
|
|
1791
|
+
...parsed,
|
|
1792
|
+
entities: filtered,
|
|
1793
|
+
count: filtered.length
|
|
1794
|
+
}, null, 2)
|
|
1795
|
+
};
|
|
1796
|
+
} catch {
|
|
1797
|
+
return contents;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1376
1800
|
async function configureRbacIfSecured(config) {
|
|
1377
1801
|
if (rbacReloadTimer) {
|
|
1378
1802
|
clearInterval(rbacReloadTimer);
|
|
@@ -1580,12 +2004,18 @@ function createMcpServer() {
|
|
|
1580
2004
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1581
2005
|
const { name, arguments: args } = request.params;
|
|
1582
2006
|
const startedAt = Date.now();
|
|
2007
|
+
const authContext = getActiveAuthContext();
|
|
1583
2008
|
requestLogger.info("tool.call.start", {
|
|
1584
2009
|
toolName: name,
|
|
1585
|
-
args: summarizeToolArgs(args)
|
|
2010
|
+
args: summarizeToolArgs(args),
|
|
2011
|
+
userId: authContext?.user_id ?? null,
|
|
2012
|
+
keyId: authContext?.key_id ?? null
|
|
1586
2013
|
});
|
|
1587
2014
|
try {
|
|
1588
|
-
const
|
|
2015
|
+
const scopedArgs = await enforceProjectScopeForTool(name, args, authContext);
|
|
2016
|
+
const toolResult = await handleToolCall(name, scopedArgs);
|
|
2017
|
+
await postProcessProjectScopedCreate(name, scopedArgs, toolResult, authContext);
|
|
2018
|
+
const result = filterProjectScopedToolResult(name, toolResult, authContext);
|
|
1589
2019
|
sendEntityChangeNotifications(name, args).catch((err) => {
|
|
1590
2020
|
rootLogger.error("entity.notification.dispatch_error", {
|
|
1591
2021
|
toolName: name,
|
|
@@ -1622,12 +2052,99 @@ function createMcpServer() {
|
|
|
1622
2052
|
}
|
|
1623
2053
|
});
|
|
1624
2054
|
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
1625
|
-
|
|
2055
|
+
const listed = await handleListResources();
|
|
2056
|
+
const authContext = getActiveAuthContext();
|
|
2057
|
+
if (!hasProjectScope(authContext)) {
|
|
2058
|
+
return listed;
|
|
2059
|
+
}
|
|
2060
|
+
const filteredResources = listed.resources.filter((resource) => {
|
|
2061
|
+
try {
|
|
2062
|
+
const parsed = parseUri(resource.uri);
|
|
2063
|
+
if (parsed.resourceType === "entities-list") {
|
|
2064
|
+
return parsed.entityType === "project";
|
|
2065
|
+
}
|
|
2066
|
+
if (parsed.resourceType === "entity") {
|
|
2067
|
+
return parsed.entityType === "project" && isProjectAllowed(parsed.entityId, normalizeAllowedProjects(authContext));
|
|
2068
|
+
}
|
|
2069
|
+
if (parsed.resourceType === "audio-inbound" || parsed.resourceType === "audio-processed") {
|
|
2070
|
+
return false;
|
|
2071
|
+
}
|
|
2072
|
+
return true;
|
|
2073
|
+
} catch {
|
|
2074
|
+
return true;
|
|
2075
|
+
}
|
|
2076
|
+
});
|
|
2077
|
+
return {
|
|
2078
|
+
...listed,
|
|
2079
|
+
resources: filteredResources
|
|
2080
|
+
};
|
|
1626
2081
|
});
|
|
1627
2082
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
1628
2083
|
const { uri } = request.params;
|
|
2084
|
+
const authContext = getActiveAuthContext();
|
|
2085
|
+
const allowedProjects = normalizeAllowedProjects(authContext);
|
|
2086
|
+
if (allowedProjects.length > 0) {
|
|
2087
|
+
const parsed = parseUri(uri);
|
|
2088
|
+
if (parsed.resourceType === "audio-inbound" || parsed.resourceType === "audio-processed" || parsed.resourceType === "config") {
|
|
2089
|
+
throw new Error(`Project-scoped key cannot access ${parsed.resourceType} resources`);
|
|
2090
|
+
}
|
|
2091
|
+
if (parsed.resourceType === "entity") {
|
|
2092
|
+
const entity = parsed;
|
|
2093
|
+
if (entity.entityType !== "project") {
|
|
2094
|
+
throw new Error("Project-scoped key can only read project entities");
|
|
2095
|
+
}
|
|
2096
|
+
if (!isProjectAllowed(entity.entityId, allowedProjects)) {
|
|
2097
|
+
throw new Error(`Project-scoped key cannot access project "${entity.entityId}"`);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
if (parsed.resourceType === "entities-list") {
|
|
2101
|
+
const entityList = parsed;
|
|
2102
|
+
if (entityList.entityType !== "project") {
|
|
2103
|
+
throw new Error("Project-scoped key can only list project entities");
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
if (parsed.resourceType === "transcript") {
|
|
2107
|
+
const transcriptUri = parsed;
|
|
2108
|
+
const transcriptResult = await handleReadTranscript({
|
|
2109
|
+
transcriptPath: transcriptUri.transcriptPath
|
|
2110
|
+
});
|
|
2111
|
+
const metadata = asRecord(transcriptResult.metadata);
|
|
2112
|
+
const projectId = typeof metadata.projectId === "string" ? metadata.projectId.trim() : "";
|
|
2113
|
+
if (!isProjectAllowed(projectId, allowedProjects)) {
|
|
2114
|
+
throw new Error(`Project-scoped key cannot access transcript from project "${projectId || "unassigned"}"`);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
if (parsed.resourceType === "transcripts-list") {
|
|
2118
|
+
const list = parsed;
|
|
2119
|
+
const requestedProjectId = typeof list.projectId === "string" ? list.projectId.trim() : "";
|
|
2120
|
+
if (requestedProjectId.length > 0 && !isProjectAllowed(requestedProjectId, allowedProjects)) {
|
|
2121
|
+
throw new Error(`Project-scoped key cannot list transcripts for project "${requestedProjectId}"`);
|
|
2122
|
+
}
|
|
2123
|
+
if (requestedProjectId.length === 0) {
|
|
2124
|
+
if (allowedProjects.length !== 1) {
|
|
2125
|
+
throw new Error("Project-scoped key with multiple projects must specify projectId in transcript list resource");
|
|
2126
|
+
}
|
|
2127
|
+
const contents = await readTranscriptsListResource({
|
|
2128
|
+
directory: list.directory,
|
|
2129
|
+
startDate: list.startDate,
|
|
2130
|
+
endDate: list.endDate,
|
|
2131
|
+
limit: list.limit,
|
|
2132
|
+
offset: list.offset,
|
|
2133
|
+
projectId: allowedProjects[0]
|
|
2134
|
+
});
|
|
2135
|
+
return { contents: [contents] };
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
1629
2139
|
try {
|
|
1630
2140
|
const contents = await handleReadResource(uri);
|
|
2141
|
+
if (allowedProjects.length > 0) {
|
|
2142
|
+
const parsed = parseUri(uri);
|
|
2143
|
+
if (parsed.resourceType === "entities-list") {
|
|
2144
|
+
const filtered = filterProjectEntitiesResourceJson(contents, allowedProjects);
|
|
2145
|
+
return { contents: [filtered] };
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
1631
2148
|
return { contents: [contents] };
|
|
1632
2149
|
} catch (error) {
|
|
1633
2150
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1806,7 +2323,19 @@ app.post(
|
|
|
1806
2323
|
const rawTitle = body["title"];
|
|
1807
2324
|
const rawProject = body["project"];
|
|
1808
2325
|
const title = typeof rawTitle === "string" && rawTitle.trim() ? rawTitle.trim() : void 0;
|
|
1809
|
-
|
|
2326
|
+
let project = typeof rawProject === "string" && rawProject.trim() ? rawProject.trim() : void 0;
|
|
2327
|
+
const authContext = c.get(AUTH_CONTEXT_KEY);
|
|
2328
|
+
if (hasProjectScope(authContext ?? null)) {
|
|
2329
|
+
const allowedProjects = normalizeAllowedProjects(authContext ?? null);
|
|
2330
|
+
if (!project && allowedProjects.length === 1) {
|
|
2331
|
+
project = allowedProjects[0];
|
|
2332
|
+
}
|
|
2333
|
+
if (!project || !isProjectAllowed(project, allowedProjects)) {
|
|
2334
|
+
return c.json({
|
|
2335
|
+
error: "Project-scoped key cannot upload outside allowed projects"
|
|
2336
|
+
}, 403);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
1810
2339
|
await mkdir(outputDir, { recursive: true });
|
|
1811
2340
|
const { uuid, filePath } = await createUploadTranscript({
|
|
1812
2341
|
audioFile: basename(uploadObjectPath),
|
|
@@ -1862,6 +2391,14 @@ app.get("/audio/:uuid", async (c) => {
|
|
|
1862
2391
|
const transcript = PklTranscript.open(filePath, { readOnly: true });
|
|
1863
2392
|
const metadata = transcript.metadata;
|
|
1864
2393
|
await transcript.close();
|
|
2394
|
+
const authContext = c.get(AUTH_CONTEXT_KEY);
|
|
2395
|
+
if (hasProjectScope(authContext ?? null)) {
|
|
2396
|
+
const allowedProjects = normalizeAllowedProjects(authContext ?? null);
|
|
2397
|
+
const projectId = typeof metadata.projectId === "string" ? metadata.projectId : null;
|
|
2398
|
+
if (!isProjectAllowed(projectId, allowedProjects)) {
|
|
2399
|
+
return c.json({ error: "Project-scoped key cannot access this transcript audio" }, 403);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
1865
2402
|
if (!metadata.audioHash) {
|
|
1866
2403
|
return c.json({ error: "No audio file associated with this transcript" }, 404);
|
|
1867
2404
|
}
|
|
@@ -1928,7 +2465,8 @@ app.get("/auth/whoami", async (c) => {
|
|
|
1928
2465
|
return c.json({
|
|
1929
2466
|
user_id: auth.user_id,
|
|
1930
2467
|
roles: auth.roles,
|
|
1931
|
-
key_id: auth.key_id
|
|
2468
|
+
key_id: auth.key_id,
|
|
2469
|
+
allowed_projects: auth.allowed_projects ?? []
|
|
1932
2470
|
});
|
|
1933
2471
|
});
|
|
1934
2472
|
app.get("/admin/ping", async (c) => {
|
|
@@ -2154,7 +2692,11 @@ app.post("/mcp", async (c) => {
|
|
|
2154
2692
|
rpcId: jsonRpcMessage.id ?? null,
|
|
2155
2693
|
elapsedMs: Date.now() - requestStartedAt
|
|
2156
2694
|
});
|
|
2157
|
-
const
|
|
2695
|
+
const requestAuthContext = c.get(AUTH_CONTEXT_KEY) ?? null;
|
|
2696
|
+
const response = await authContextStore.run(
|
|
2697
|
+
requestAuthContext,
|
|
2698
|
+
() => session.transport.handleRequest(c)
|
|
2699
|
+
);
|
|
2158
2700
|
requestLogger.debug("complete", {
|
|
2159
2701
|
method: "POST",
|
|
2160
2702
|
route: "/mcp",
|
|
@@ -2280,6 +2822,9 @@ async function main() {
|
|
|
2280
2822
|
await cardigantime.configure(program);
|
|
2281
2823
|
program.parse();
|
|
2282
2824
|
const args = program.opts();
|
|
2825
|
+
const securedFromCli = program.getOptionValueSource("secured") === "cli" ? Boolean(args.secured) : void 0;
|
|
2826
|
+
const debugFromCli = program.getOptionValueSource("debug") === "cli" ? Boolean(args.debug) : void 0;
|
|
2827
|
+
const verboseFromCli = program.getOptionValueSource("verbose") === "cli" ? Boolean(args.verbose) : void 0;
|
|
2283
2828
|
if (args.cwd) {
|
|
2284
2829
|
process.chdir(resolve(args.cwd));
|
|
2285
2830
|
}
|
|
@@ -2305,7 +2850,7 @@ async function main() {
|
|
|
2305
2850
|
process.env.PROTOKOLL_CONFIG = configPath;
|
|
2306
2851
|
process.env.WORKSPACE_ROOT = dirname(configPath);
|
|
2307
2852
|
}
|
|
2308
|
-
|
|
2853
|
+
let cardigantimeConfig = await cardigantime.read({
|
|
2309
2854
|
...args,
|
|
2310
2855
|
inputDirectory: args.inputDirectory ?? process.env.PROTOKOLL_INPUT_DIRECTORY,
|
|
2311
2856
|
outputDirectory: args.outputDirectory ?? process.env.PROTOKOLL_OUTPUT_DIRECTORY,
|
|
@@ -2315,15 +2860,23 @@ async function main() {
|
|
|
2315
2860
|
classifyModel: args.classifyModel ?? process.env.PROTOKOLL_CLASSIFY_MODEL,
|
|
2316
2861
|
composeModel: args.composeModel ?? process.env.PROTOKOLL_COMPOSE_MODEL,
|
|
2317
2862
|
transcriptionModel: args.transcriptionModel ?? process.env.PROTOKOLL_TRANSCRIPTION_MODEL,
|
|
2318
|
-
secured:
|
|
2863
|
+
secured: securedFromCli ?? parseBooleanEnv(process.env.PROTOKOLL_HTTP_SECURED),
|
|
2319
2864
|
rbacUsersPath: args.rbacUsersPath ?? process.env.RBAC_USERS_PATH,
|
|
2320
2865
|
rbacKeysPath: args.rbacKeysPath ?? process.env.RBAC_KEYS_PATH,
|
|
2321
2866
|
rbacPolicyPath: args.rbacPolicyPath ?? process.env.RBAC_POLICY_PATH,
|
|
2322
2867
|
rbacReloadSeconds: args.rbacReloadSeconds ?? process.env.RBAC_RELOAD_SECONDS,
|
|
2323
|
-
debug:
|
|
2324
|
-
verbose:
|
|
2868
|
+
debug: debugFromCli ?? parseBooleanEnv(process.env.PROTOKOLL_DEBUG),
|
|
2869
|
+
verbose: verboseFromCli ?? parseBooleanEnv(process.env.PROTOKOLL_VERBOSE),
|
|
2325
2870
|
storage: buildEnvStorageConfig()
|
|
2326
2871
|
});
|
|
2872
|
+
cardigantimeConfig = await mergeStorageFromCanonicalConfig(
|
|
2873
|
+
cardigantimeConfig,
|
|
2874
|
+
args.config
|
|
2875
|
+
);
|
|
2876
|
+
cardigantimeConfig = await injectLocalGcsCredentialsIfMissing(
|
|
2877
|
+
cardigantimeConfig
|
|
2878
|
+
);
|
|
2879
|
+
exportStorageConfigToEnv(cardigantimeConfig);
|
|
2327
2880
|
configureHttpLogLevel(cardigantimeConfig.debug === true);
|
|
2328
2881
|
if (!args.config) {
|
|
2329
2882
|
const resolvedConfigDirs = cardigantimeConfig.resolvedConfigDirs;
|