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:
2026-05-24 11:01:13 +02:00
parent 0bb828fb98
commit c997f204b5
9 changed files with 1642 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/UserMcpTokens.php';
dbnToolsRequireAuth();
if (!dbnToolsIsFreeTier()) {
dbnToolsError('DBN user MCP tokens are available for Do Better Norge member accounts only.', 403, 'sso_only');
}
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
if (!UserMcpTokens::isUserEligible($userId)) {
dbnToolsError('MCP access requires a Plus or Pro plan.', 403, 'not_paid');
}
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
if ($method === 'GET') {
dbnToolsRespond([
'ok' => true,
'tokens' => UserMcpTokens::listForUser($userId),
'config' => [
'stdio_command' => 'npx',
'stdio_args' => ['-y', '@bluenotelogic/mcp', 'dobetternorge-mcp', '--stdio'],
'hosted_url' => 'https://mcp.dobetternorge.no/mcp',
'env_var' => 'DBN_MCP_TOKEN',
],
]);
}
if ($method !== 'POST') {
dbnToolsError('Method not allowed.', 405, 'method_not_allowed');
}
$input = dbnToolsJsonInput(4000);
$action = (string)($input['action'] ?? 'create');
if ($action === 'create') {
$created = UserMcpTokens::createForUser($userId, (string)($input['name'] ?? 'DBN MCP'));
dbnToolsRespond(['ok' => true, 'token' => $created]);
}
if ($action === 'revoke') {
$tokenId = (int)($input['id'] ?? 0);
if ($tokenId <= 0) {
dbnToolsError('Token id is required.', 422, 'missing_id');
}
dbnToolsRespond(['ok' => true, 'revoked' => UserMcpTokens::revokeForUser($userId, $tokenId)]);
}
dbnToolsError('Unknown action.', 400, 'bad_action');
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../includes/bootstrap.php';
require_once __DIR__ . '/../../../includes/DbnMcpRuntime.php';
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-DBN-MCP-Token');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
function dbnMcpUserTokenFromRequest(): string
{
$header = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? '');
if (preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
return trim($m[1]);
}
return trim((string)($_SERVER['HTTP_X_DBN_MCP_TOKEN'] ?? $_SERVER['HTTP_X_MCP_TOKEN'] ?? ''));
}
function dbnMcpPathSegments(): array
{
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$path = preg_replace('#^.*?/api/mcp/user/?#', '', $path);
$path = trim((string)$path, '/');
return $path === '' ? [] : explode('/', $path);
}
try {
$token = dbnMcpUserTokenFromRequest();
$tokenRow = UserMcpTokens::resolve($token);
if ($tokenRow === null) {
dbnToolsError('Invalid, revoked, or non-Plus/Pro DBN MCP token.', 401, 'invalid_token');
}
$segments = dbnMcpPathSegments();
$resource = $segments[0] ?? 'session';
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
if ($resource === 'session' && $method === 'GET') {
dbnToolsRespond([
'ok' => true,
'user' => [
'id' => (int)$tokenRow['user_id'],
'email' => (string)($tokenRow['email'] ?? ''),
'tier' => (string)$tokenRow['tier'],
],
'privacy' => 'Tool calls process in memory by default. Use dbn.save_to_case to persist a result.',
]);
}
if ($resource === 'tools' && $method === 'GET' && !isset($segments[1])) {
dbnToolsRespond(['ok' => true, 'tools' => DbnMcpRuntime::tools()]);
}
if ($resource === 'tools' && $method === 'POST' && isset($segments[1]) && ($segments[2] ?? '') === 'invoke') {
$slug = urldecode((string)$segments[1]);
$args = dbnToolsJsonInput(2_500_000);
$result = DbnMcpRuntime::invoke($slug, $args, $tokenRow);
dbnToolsRespond($result);
}
dbnToolsError('Unknown MCP user route.', 404, 'not_found', ['path' => $segments]);
} catch (DbnToolsHttpException $e) {
dbnToolsError($e->getMessage(), $e->status, $e->errorCode, $e->extra);
} catch (Throwable $e) {
error_log('[dbn-user-mcp] ' . $e->getMessage());
dbnToolsError('DBN MCP runtime failed.', 500, 'internal_error');
}