feat: add Do Better Norge MCP server — token system, runtime API, interactive setup page
- UserMcpTokens: per-user SHA256-hashed token mint/validate/revoke (Plus/Pro only) - DbnMcpRuntime: 19 MCP tools (search, ask, summarize, timeline, redact, translate, legal_analysis, korrespond, barnevernet_analyze, advocate_brief, deep_research, discrepancy_find, transcribe_audio, corpus_stats, list_documents, get_document, citation_graph, case_workbench_plan, save_to_case) - api/mcp/user/: session/tools/invoke HTTP endpoints with Bearer token auth - api/mcp-tokens.php: token create/revoke/list REST API - mcp.php: interactive setup page with token management, 5-client config tabs, auto-fill on token creation, tool catalog grid, privacy notice - account.php: simplified MCP section with link to mcp.php - nav.php: MCP nav link - .htaccess: Authorization header passthrough, MCP route rewrite, CORS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/FreeTier.php';
|
||||
|
||||
final class UserMcpTokens
|
||||
{
|
||||
public const TOKEN_PREFIX = 'dbn_user_mcp_';
|
||||
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
$db = dbnmDb();
|
||||
$db->exec(
|
||||
"CREATE TABLE IF NOT EXISTS user_mcp_tokens (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id INT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL DEFAULT 'Default',
|
||||
scopes JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_used_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked_at DATETIME NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_hash (token_hash),
|
||||
KEY idx_user_active (user_id, is_active, revoked_at),
|
||||
CONSTRAINT fk_user_mcp_tokens_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
}
|
||||
|
||||
public static function isUserEligible(int $userId): bool
|
||||
{
|
||||
return $userId > 0 && FreeTier::isPaidTier(FreeTier::tier($userId));
|
||||
}
|
||||
|
||||
public static function listForUser(int $userId): array
|
||||
{
|
||||
self::ensureSchema();
|
||||
$db = dbnmDb();
|
||||
$stmt = $db->prepare(
|
||||
'SELECT id, token_prefix, name, scopes, is_active, last_used_at, created_at, revoked_at
|
||||
FROM user_mcp_tokens
|
||||
WHERE user_id = ?
|
||||
ORDER BY is_active DESC, created_at DESC'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($rows as &$row) {
|
||||
$row['id'] = (int)$row['id'];
|
||||
$row['is_active'] = (int)$row['is_active'] === 1;
|
||||
$row['scopes'] = json_decode((string)($row['scopes'] ?? '[]'), true) ?: [];
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function createForUser(int $userId, string $name = 'Default'): array
|
||||
{
|
||||
if (!self::isUserEligible($userId)) {
|
||||
throw new DbnToolsHttpException('MCP tokens require a Plus or Pro plan.', 403, 'not_paid');
|
||||
}
|
||||
self::ensureSchema();
|
||||
|
||||
$plain = self::TOKEN_PREFIX . bin2hex(random_bytes(32));
|
||||
$hash = hash('sha256', $plain);
|
||||
$prefix = substr($plain, 0, 24);
|
||||
$name = mb_substr(trim($name) !== '' ? trim($name) : 'Default', 0, 100, 'UTF-8');
|
||||
$scopes = [
|
||||
'tools' => ['dbn.*'],
|
||||
'privacy' => 'process-and-forget-default',
|
||||
'tiers' => ['plus', 'pro'],
|
||||
];
|
||||
|
||||
$db = dbnmDb();
|
||||
$stmt = $db->prepare(
|
||||
'INSERT INTO user_mcp_tokens (user_id, token_hash, token_prefix, name, scopes, is_active, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, NOW())'
|
||||
);
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$hash,
|
||||
$prefix,
|
||||
$name,
|
||||
json_encode($scopes, JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => (int)$db->lastInsertId(),
|
||||
'token' => $plain,
|
||||
'token_prefix' => $prefix,
|
||||
'name' => $name,
|
||||
'scopes' => $scopes,
|
||||
];
|
||||
}
|
||||
|
||||
public static function revokeForUser(int $userId, int $tokenId): bool
|
||||
{
|
||||
self::ensureSchema();
|
||||
$db = dbnmDb();
|
||||
$stmt = $db->prepare(
|
||||
'UPDATE user_mcp_tokens
|
||||
SET is_active = 0, revoked_at = NOW()
|
||||
WHERE id = ? AND user_id = ? AND is_active = 1 AND revoked_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$tokenId, $userId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
public static function resolve(string $token): ?array
|
||||
{
|
||||
$token = trim($token);
|
||||
if ($token === '' || !str_starts_with($token, self::TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
self::ensureSchema();
|
||||
$hash = hash('sha256', $token);
|
||||
$db = dbnmDb();
|
||||
$stmt = $db->prepare(
|
||||
'SELECT t.*, u.email
|
||||
FROM user_mcp_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = ?
|
||||
AND t.is_active = 1
|
||||
AND t.revoked_at IS NULL
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$hash]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userId = (int)$row['user_id'];
|
||||
$tier = FreeTier::tier($userId);
|
||||
if (!FreeTier::isPaidTier($tier)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$db->prepare('UPDATE user_mcp_tokens SET last_used_at = NOW() WHERE id = ?')
|
||||
->execute([(int)$row['id']]);
|
||||
} catch (Throwable $_) {
|
||||
// Token touch is best-effort.
|
||||
}
|
||||
|
||||
$row['id'] = (int)$row['id'];
|
||||
$row['user_id'] = $userId;
|
||||
$row['tier'] = $tier;
|
||||
$row['scopes'] = json_decode((string)($row['scopes'] ?? '[]'), true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user