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
+3
View File
@@ -1,6 +1,8 @@
DirectoryIndex index.php
Options -Indexes
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
<FilesMatch "^\.env">
Require all denied
</FilesMatch>
@@ -13,5 +15,6 @@ Options -Indexes
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^api/mcp/user/?(.*)$ api/mcp/user/index.php [QSA,L]
RewriteRule ^includes/ - [F,L]
</IfModule>
+12
View File
@@ -177,6 +177,18 @@ window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
<?php endif; ?>
</section>
<?php if ($isSso && $detail && in_array((string)($detail['tier'] ?? 'free'), ['plus', 'pro'], true)): ?>
<section class="account-section">
<p class="account-section__title">MCP access</p>
<p style="color:var(--muted,#667085); font-size:0.88rem; margin-top:0;">
Connect Claude, Cursor, and other AI tools to all 19 DBN legal preparation tools via MCP.
</p>
<a href="/mcp.php" class="account-upgrade-btn" style="display:inline-block; text-decoration:none; margin-top:0.75rem;">
Set up MCP →
</a>
</section>
<?php endif; ?>
<?php if ($detail): ?>
<!-- ── Usage ────────────────────────────────────────────────── -->
<section class="account-section">
+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');
}
+611
View File
@@ -0,0 +1,611 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/LegalTools.php';
require_once __DIR__ . '/UserMcpTokens.php';
final class DbnMcpRuntime
{
/** @return array<int,array<string,mixed>> */
public static function tools(): array
{
$lang = self::langSchema();
$text = ['type' => 'string', 'description' => 'Text to process.'];
$useCase = ['type' => 'boolean', 'description' => 'Use private My Case context. Defaults to false.'];
return [
self::tool('dbn.search_legal', 'Search DBN legal corpus', 'Search the DBN Norwegian family-law corpus.', [
'query' => ['type' => 'string', 'minLength' => 3],
'language' => $lang,
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 10],
'corpus_scope' => ['type' => 'string', 'enum' => ['shared', 'private', 'both']],
], ['query']),
self::tool('dbn.ask', 'Ask a legal question', 'Answer a legal preparation question with source-grounded DBN context.', [
'question' => ['type' => 'string', 'minLength' => 5],
'language' => $lang,
'use_case_context' => $useCase,
], ['question']),
self::tool('dbn.summarize', 'Summarize document', 'Summarize pasted case text with optional legal-corpus enrichment.', [
'text' => $text,
'language' => $lang,
'use_legal_corpus' => ['type' => 'boolean'],
'use_case_context' => $useCase,
], ['text']),
self::tool('dbn.timeline', 'Extract timeline', 'Extract dates, hearings, milestones, and deadlines from case text.', [
'text' => $text,
'language' => $lang,
'focus' => ['type' => 'string', 'enum' => ['all', 'deadlines', 'hearings', 'cps']],
'use_case_context' => $useCase,
], ['text']),
self::tool('dbn.redact', 'Redact private data', 'Remove or pseudonymize names, IDs, phone numbers, addresses, and places.', [
'text' => $text,
'language' => $lang,
'mode' => ['type' => 'string', 'enum' => ['standard', 'strict']],
'output_format' => ['type' => 'string', 'enum' => ['contextual', 'generic', 'pseudonym']],
], ['text']),
self::tool('dbn.translate', 'Translate legal document', 'Translate Norwegian family-law text with legal terminology annotations.', [
'text' => $text,
'source_lang' => $lang,
'target_lang' => $lang,
'doc_type' => ['type' => 'string', 'enum' => ['auto', 'barnevernet', 'adopsjon', 'emergency', 'samvaer', 'fylkesnemnd', 'other']],
], ['text', 'target_lang']),
self::tool('dbn.legal_analysis', 'Legal analysis', 'Extract legal issues from a document and answer each with DBN legal context.', [
'text' => $text,
'language' => $lang,
'doc_type' => ['type' => 'string', 'enum' => ['auto', 'barnevernet', 'adopsjon', 'emergency', 'samvaer', 'fylkesnemnd', 'other']],
], ['text']),
self::tool('dbn.korrespond', 'Draft authority correspondence', 'Draft a reply or new letter to Norwegian authorities.', [
'narrative' => ['type' => 'string'],
'received_text' => ['type' => 'string'],
'recipient_body' => ['type' => 'string'],
'goal' => ['type' => 'string'],
'mode' => ['type' => 'string', 'enum' => ['reply', 'initiate']],
'language' => $lang,
'use_case_context' => $useCase,
'force_draft' => ['type' => 'boolean'],
]),
self::tool('dbn.barnevernet_analyze', 'Analyze Barnevernet document', 'Analyze child-welfare documents for red flags and legal issues.', [
'document_text' => $text,
'filename' => ['type' => 'string'],
'advocate_role' => ['type' => 'string'],
'language' => $lang,
'use_case_context' => $useCase,
], ['document_text']),
self::tool('dbn.advocate_brief', 'Create advocate brief', 'Generate a source-grounded brief for a chosen party or role.', [
'query' => ['type' => 'string'],
'paste_text' => ['type' => 'string'],
'advocate_role' => ['type' => 'string'],
'language' => $lang,
'use_case_context' => $useCase,
], ['advocate_role']),
self::tool('dbn.deep_research', 'Deep research', 'Expand a legal question into research angles and synthesize a cited brief.', [
'query' => ['type' => 'string'],
'paste_text' => ['type' => 'string'],
'language' => $lang,
'use_case_context' => $useCase,
]),
self::tool('dbn.discrepancy_find', 'Find document discrepancies', 'Compare two document versions for contradictions, deletions, and added claims.', [
'document_a_text' => ['type' => 'string'],
'document_b_text' => ['type' => 'string'],
'filename_a' => ['type' => 'string'],
'filename_b' => ['type' => 'string'],
'language' => $lang,
], ['document_a_text', 'document_b_text']),
self::tool('dbn.transcribe_audio', 'Transcribe audio', 'Transcribe an audio file from base64 or a URL.', [
'audio_base64' => ['type' => 'string'],
'audio_url' => ['type' => 'string'],
'filename' => ['type' => 'string'],
'language' => ['type' => 'string'],
'diarize' => ['type' => 'boolean'],
]),
self::tool('dbn.corpus_stats', 'Corpus statistics', 'Return document/chunk counts and active legal sources.', []),
self::tool('dbn.list_documents', 'List corpus documents', 'List DBN legal corpus documents with filters.', [
'category' => ['type' => 'string'],
'title' => ['type' => 'string'],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50],
'offset' => ['type' => 'integer', 'minimum' => 0],
]),
self::tool('dbn.get_document', 'Get document chunks', 'Fetch a document and its chunks by document id.', [
'document_id' => ['type' => 'integer', 'minimum' => 1],
], ['document_id']),
self::tool('dbn.citation_graph', 'Explore citation graph', 'Explore cites, cited-by, implementation, or chain relationships.', [
'doc_id' => ['type' => 'integer', 'minimum' => 1],
'action' => ['type' => 'string', 'enum' => ['cites', 'cited_by', 'implements', 'chain']],
'depth' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 3],
], ['doc_id']),
self::tool('dbn.case_workbench_plan', 'Plan next case step', 'Create a stateless preparation plan. Does not save anything.', [
'situation' => ['type' => 'string'],
'goal' => ['type' => 'string'],
'deadline' => ['type' => 'string'],
'language' => $lang,
], ['situation']),
self::tool('dbn.save_to_case', 'Save result to My Case', 'Explicitly save a prior MCP tool result to the user case record.', [
'tool' => ['type' => 'string'],
'title' => ['type' => 'string'],
'input_payload' => ['type' => 'object'],
'output_payload' => ['type' => 'object'],
'meta' => ['type' => 'object'],
], ['tool', 'input_payload', 'output_payload']),
];
}
/** @param array<string,mixed> $args */
public static function invoke(string $slug, array $args, array $tokenRow): array
{
self::establishSession($tokenRow);
$started = microtime(true);
$result = match ($slug) {
'dbn.search_legal' => self::callJson('api/search.php', [
'query' => (string)($args['query'] ?? ''),
'language' => self::language($args['language'] ?? 'en'),
'limit' => (int)($args['limit'] ?? $args['top_k'] ?? 8),
'corpus_scope' => self::corpusScope($args['corpus_scope'] ?? 'both'),
]),
'dbn.ask' => self::callJson('api/ask.php', [
'question' => (string)($args['question'] ?? ''),
'language' => self::language($args['language'] ?? 'en'),
'use_my_case' => !empty($args['use_case_context']),
]),
'dbn.summarize' => self::callJson('api/summarize.php', [
'text' => (string)($args['text'] ?? ''),
'language' => self::language($args['language'] ?? 'en'),
'slices' => !empty($args['use_legal_corpus']) ? ['family-legal'] : [],
'use_my_case' => !empty($args['use_case_context']),
]),
'dbn.timeline' => self::callJson('api/timeline.php', [
'text' => (string)($args['text'] ?? ''),
'language' => self::language($args['language'] ?? 'en'),
'focus' => (string)($args['focus'] ?? 'all'),
'use_my_case' => !empty($args['use_case_context']),
]),
'dbn.redact' => self::callJson('api/redact.php', [
'text' => (string)($args['text'] ?? ''),
'language' => self::language($args['language'] ?? 'en'),
'mode' => (string)($args['mode'] ?? 'standard'),
'output_format' => (string)($args['output_format'] ?? 'contextual'),
]),
'dbn.translate' => self::callJson('api/translate.php', [
'text' => (string)($args['text'] ?? ''),
'language' => self::language($args['target_lang'] ?? $args['language'] ?? 'en'),
'source_lang' => self::language($args['source_lang'] ?? 'no'),
'target_lang' => self::language($args['target_lang'] ?? 'en'),
'doc_type' => self::docType($args['doc_type'] ?? 'auto'),
]),
'dbn.legal_analysis' => self::callJson('api/legal-analysis.php', [
'text' => (string)($args['text'] ?? ''),
'language' => self::language($args['language'] ?? 'en'),
'doc_type' => self::docType($args['doc_type'] ?? 'other'),
]),
'dbn.korrespond' => self::callJson('api/korrespond.php', [
'mode' => (string)($args['mode'] ?? 'initiate'),
'recipient_body' => (string)($args['recipient_body'] ?? 'other'),
'output_type' => (string)($args['output_type'] ?? 'email'),
'tone' => (string)($args['tone'] ?? 'neutral'),
'language' => self::language($args['language'] ?? 'en'),
'narrative' => (string)($args['narrative'] ?? $args['received_text'] ?? ''),
'goal' => (string)($args['goal'] ?? ''),
'use_my_case' => !empty($args['use_case_context']),
'force_draft' => ($args['force_draft'] ?? true) !== false,
]),
'dbn.barnevernet_analyze' => self::callMultipart('api/barnevernet.php', [
'language' => self::language($args['language'] ?? 'en'),
'advocate_role' => (string)($args['advocate_role'] ?? ''),
'use_my_case' => !empty($args['use_case_context']),
], ['files' => [self::tempTextFile((string)($args['document_text'] ?? ''), (string)($args['filename'] ?? 'barnevernet.txt'))]]),
'dbn.advocate_brief' => self::callJson('api/deep-research.php', [
'query' => (string)($args['query'] ?? ''),
'paste_text' => (string)($args['paste_text'] ?? ''),
'advocate_role' => (string)($args['advocate_role'] ?? ''),
'language' => self::language($args['language'] ?? 'en'),
'use_my_case' => !empty($args['use_case_context']),
]),
'dbn.deep_research' => self::callJson('api/deep-research.php', [
'query' => (string)($args['query'] ?? ''),
'paste_text' => (string)($args['paste_text'] ?? ''),
'language' => self::language($args['language'] ?? 'en'),
'use_my_case' => !empty($args['use_case_context']),
]),
'dbn.discrepancy_find' => self::callMultipart('api/discrepancy.php', [
'language' => self::language($args['language'] ?? 'en'),
], [
'file_a' => self::tempTextFile((string)($args['document_a_text'] ?? ''), (string)($args['filename_a'] ?? 'document-a.txt')),
'file_b' => self::tempTextFile((string)($args['document_b_text'] ?? ''), (string)($args['filename_b'] ?? 'document-b.txt')),
]),
'dbn.transcribe_audio' => self::invokeTranscribe($args),
'dbn.corpus_stats' => self::callGet('api/corpus-stats.php', []),
'dbn.list_documents' => self::callGet('api/corpus-documents.php', [
'category' => (string)($args['category'] ?? ''),
'title' => (string)($args['title'] ?? ''),
'limit' => (int)($args['limit'] ?? 20),
'offset' => (int)($args['offset'] ?? 0),
]),
'dbn.get_document' => self::callGet('api/document-chunks.php', [
'document_id' => (int)($args['document_id'] ?? 0),
]),
'dbn.citation_graph' => self::callExternalGraph($args),
'dbn.case_workbench_plan' => self::workbenchPlan($args),
'dbn.save_to_case' => self::saveToCase($args),
default => throw new DbnToolsHttpException('Unknown MCP tool: ' . $slug, 404, 'tool_not_found'),
};
$result['duration_ms'] = (int)round((microtime(true) - $started) * 1000);
return self::mcpResult($slug, $result);
}
private static function tool(string $slug, string $name, string $description, array $properties, array $required = []): array
{
return [
'slug' => $slug,
'display_name' => $name,
'description' => $description,
'version' => 1,
'config' => [
'input_schema' => [
'type' => 'object',
'properties' => (object)$properties,
'required' => $required,
'additionalProperties' => true,
],
],
];
}
private static function langSchema(): array
{
return ['type' => 'string', 'enum' => ['en', 'no', 'uk', 'pl', 'auto']];
}
private static function establishSession(array $tokenRow): void
{
$_SESSION['dbn_tools_authenticated'] = true;
$_SESSION['dbn_tools_authenticated_at'] = time();
$_SESSION['dbn_tools_sso_uid'] = (int)$tokenRow['user_id'];
$_SESSION['dbn_tools_user_id'] = (int)$tokenRow['user_id'];
$_SESSION['dbn_tools_user_email'] = (string)($tokenRow['email'] ?? '');
$_SESSION['dbn_tools_user_role'] = 'mcp';
$_SESSION['dbn_tools_tier'] = (string)($tokenRow['tier'] ?? 'plus');
}
private static function baseUrl(): string
{
$configured = (string)(dbnToolsEnv('DBN_TOOLS_BASE_URL') ?? '');
if ($configured !== '') {
return rtrim($configured, '/') . '/';
}
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
if ($host !== '') {
$scheme = dbnToolsIsHttps() ? 'https' : 'http';
$script = (string)($_SERVER['SCRIPT_NAME'] ?? '');
$prefix = '';
$marker = '/api/mcp/user';
$pos = strpos($script, $marker);
if ($pos !== false) {
$prefix = substr($script, 0, $pos);
}
return rtrim($scheme . '://' . $host . $prefix, '/') . '/';
}
return 'http://localhost/dobetternorge-tools/';
}
private static function callJson(string $path, array $payload): array
{
return self::curl($path, 'POST', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), [
'Content-Type: application/json',
'Accept: application/json, application/x-ndjson',
]);
}
private static function callGet(string $path, array $query): array
{
$query = array_filter($query, static fn($v) => $v !== '' && $v !== null);
return self::curl($path . ($query ? '?' . http_build_query($query) : ''), 'GET', null, [
'Accept: application/json',
]);
}
private static function callMultipart(string $path, array $payload, array $files): array
{
$body = ['payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
$temps = [];
foreach ($files as $field => $fileInfo) {
if (is_array($fileInfo) && array_is_list($fileInfo)) {
foreach ($fileInfo as $idx => $item) {
$temps[] = $item['path'];
$body[$field . '[' . $idx . ']'] = new CURLFile($item['path'], $item['mime'], $item['name']);
}
} else {
$temps[] = $fileInfo['path'];
$body[$field] = new CURLFile($fileInfo['path'], $fileInfo['mime'], $fileInfo['name']);
}
}
try {
return self::curl($path, 'POST', $body, ['Accept: application/json, application/x-ndjson']);
} finally {
foreach ($temps as $temp) {
if (is_string($temp) && is_file($temp)) {
@unlink($temp);
}
}
}
}
private static function curl(string $path, string $method, mixed $body, array $headers): array
{
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
$url = str_starts_with($path, 'http://') || str_starts_with($path, 'https://')
? $path
: self::baseUrl() . ltrim($path, '/');
$ch = curl_init($url);
if ($ch === false) {
throw new DbnToolsHttpException('Could not initialize internal tool request.', 500, 'curl_init_failed');
}
$cookie = session_name() . '=' . session_id();
$headers[] = 'Cookie: ' . $cookie;
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 240,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => false,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$err = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0) {
throw new DbnToolsHttpException('Internal tool request failed: ' . $err, 502, 'tool_request_failed');
}
return self::decodeToolResponse((string)$raw, $status);
}
private static function decodeToolResponse(string $raw, int $status): array
{
$trim = trim($raw);
$decoded = json_decode($trim, true);
if (is_array($decoded)) {
if ($status >= 400 || (isset($decoded['ok']) && $decoded['ok'] === false)) {
$message = is_array($decoded['error'] ?? null)
? (string)($decoded['error']['message'] ?? 'Tool failed.')
: (string)($decoded['error'] ?? 'Tool failed.');
throw new DbnToolsHttpException($message, $status ?: 500, 'tool_error', ['tool_response' => $decoded]);
}
return $decoded;
}
$events = [];
$final = null;
foreach (preg_split('/\r?\n/', $trim) ?: [] as $line) {
if (trim($line) === '') {
continue;
}
$event = json_decode($line, true);
if (!is_array($event)) {
continue;
}
$events[] = $event;
if (($event['event'] ?? '') === 'error') {
throw new DbnToolsHttpException((string)($event['message'] ?? $event['error'] ?? 'Tool failed.'), (int)($event['status'] ?? 500), (string)($event['code'] ?? 'tool_error'));
}
if (($event['event'] ?? '') === 'final') {
$final = is_array($event['result'] ?? null) ? $event['result'] : $event;
}
}
if ($final !== null) {
$final['_events'] = $events;
return $final;
}
if ($status >= 400) {
throw new DbnToolsHttpException('Tool returned HTTP ' . $status, $status, 'tool_http_error');
}
return ['ok' => true, 'raw' => mb_substr($raw, 0, 12000, 'UTF-8')];
}
private static function tempTextFile(string $text, string $name): array
{
if (mb_strlen(trim($text), 'UTF-8') < 10) {
throw new DbnToolsHttpException('Document text is required.', 422, 'empty_text');
}
$path = tempnam(sys_get_temp_dir(), 'dbn-mcp-');
if ($path === false) {
throw new DbnToolsHttpException('Could not create temporary file.', 500, 'temp_failed');
}
file_put_contents($path, $text);
return ['path' => $path, 'name' => preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?: 'document.txt', 'mime' => 'text/plain'];
}
private static function invokeTranscribe(array $args): array
{
$filename = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)($args['filename'] ?? 'audio.mp3')) ?: 'audio.mp3';
$path = tempnam(sys_get_temp_dir(), 'dbn-audio-');
if ($path === false) {
throw new DbnToolsHttpException('Could not create temporary audio file.', 500, 'temp_failed');
}
try {
if (!empty($args['audio_base64'])) {
$data = base64_decode((string)$args['audio_base64'], true);
if ($data === false || strlen($data) < 16) {
throw new DbnToolsHttpException('audio_base64 is invalid.', 422, 'bad_audio_base64');
}
if (strlen($data) > 25 * 1024 * 1024) {
throw new DbnToolsHttpException('audio_base64 is too large for MCP upload. Use audio_url.', 413, 'audio_too_large');
}
file_put_contents($path, $data);
} elseif (!empty($args['audio_url'])) {
self::downloadToFile((string)$args['audio_url'], $path, 200 * 1024 * 1024);
$filename = basename(parse_url((string)$args['audio_url'], PHP_URL_PATH) ?: $filename) ?: $filename;
} else {
throw new DbnToolsHttpException('Provide audio_base64 or audio_url.', 422, 'missing_audio');
}
$fields = [
'language' => (string)($args['language'] ?? 'auto'),
'diarize' => !empty($args['diarize']) ? '1' : '0',
];
$body = $fields + ['audio' => new CURLFile($path, mime_content_type($path) ?: 'application/octet-stream', $filename)];
return self::curl('api/transcribe.php', 'POST', $body, ['Accept: application/json']);
} finally {
if (is_file($path)) {
@unlink($path);
}
}
}
private static function downloadToFile(string $url, string $path, int $maxBytes): void
{
if (!preg_match('#^https?://#i', $url)) {
throw new DbnToolsHttpException('audio_url must start with http:// or https://.', 422, 'bad_audio_url');
}
$fh = fopen($path, 'wb');
if (!$fh) {
throw new DbnToolsHttpException('Could not open temporary audio file.', 500, 'temp_failed');
}
$seen = 0;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FILE => $fh,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 240,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_WRITEFUNCTION => static function ($ch, string $chunk) use ($fh, &$seen, $maxBytes): int {
$seen += strlen($chunk);
if ($seen > $maxBytes) {
return 0;
}
return fwrite($fh, $chunk) ?: 0;
},
]);
$ok = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
fclose($fh);
if ($ok === false || $status >= 400) {
throw new DbnToolsHttpException('Could not download audio_url: ' . ($err ?: 'HTTP ' . $status), 502, 'audio_download_failed');
}
}
private static function callExternalGraph(array $args): array
{
$params = [
'action' => in_array($args['action'] ?? 'cites', ['cites', 'cited_by', 'implements', 'chain'], true) ? $args['action'] : 'cites',
'doc_id' => (int)($args['doc_id'] ?? 0),
'limit' => 50,
'depth' => max(1, min(3, (int)($args['depth'] ?? 2))),
];
return self::curl('https://ai.bluenotelogic.com/api/graph-search.php?' . http_build_query($params), 'GET', null, ['Accept: application/json']);
}
private static function saveToCase(array $args): array
{
require_once __DIR__ . '/CaseResults.php';
if (!dbnToolsIsFreeTier()) {
throw new DbnToolsHttpException('Saving requires a DBN member session.', 403, 'sso_only');
}
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
$input = is_array($args['input_payload'] ?? null) ? $args['input_payload'] : [];
$output = is_array($args['output_payload'] ?? null) ? $args['output_payload'] : [];
$meta = is_array($args['meta'] ?? null) ? $args['meta'] : [];
if (!empty($args['title'])) {
$meta['title'] = (string)$args['title'];
}
$ownerId = CaseStore::caseResolveClientId($userId);
$id = CaseResults::save($userId, $ownerId, (string)($args['tool'] ?? ''), $input, $output, $meta);
if ($id <= 0) {
throw new DbnToolsHttpException('Could not save result. Check plan and tool eligibility.', 500, 'save_failed');
}
return ['ok' => true, 'result_id' => $id];
}
private static function workbenchPlan(array $args): array
{
$situation = trim((string)($args['situation'] ?? ''));
if ($situation === '') {
throw new DbnToolsHttpException('situation is required.', 422, 'missing_situation');
}
$goal = trim((string)($args['goal'] ?? ''));
$deadline = trim((string)($args['deadline'] ?? ''));
return [
'ok' => true,
'summary_text' => "Case preparation plan\n\n1. Clarify the immediate decision or authority action.\n2. Build a dated fact timeline from the source documents.\n3. Search DBN legal sources for the key legal duties and deadlines.\n4. Draft one concrete correspondence or brief.\n5. Save only the final result if you explicitly want it in My Case.",
'structured_result' => [
'situation' => $situation,
'goal' => $goal,
'deadline' => $deadline,
'recommended_tools' => ['dbn.timeline', 'dbn.search_legal', 'dbn.korrespond', 'dbn.save_to_case'],
'privacy' => 'No data was saved by this planning tool.',
],
];
}
private static function mcpResult(string $slug, array $payload): array
{
$text = self::summaryText($payload);
return [
'content' => [[
'type' => 'text',
'text' => $text,
]],
'structuredContent' => [
'ok' => $payload['ok'] ?? true,
'tool' => $slug,
'summary_text' => $text,
'structured_result' => $payload,
'sources' => $payload['sources'] ?? $payload['hits'] ?? $payload['cited_law'] ?? [],
'trace' => $payload['trace'] ?? $payload['_events'] ?? [],
'disclaimer' => $payload['disclaimer'] ?? dbnToolsDisclaimer('en'),
],
];
}
private static function summaryText(array $payload): string
{
foreach (['summary_text', 'answer', 'what_we_found', 'overall_assessment', 'translated_text', 'redacted_text', 'transcript'] as $key) {
if (!empty($payload[$key]) && is_string($payload[$key])) {
return $payload[$key];
}
}
if (!empty($payload['document']['title'])) {
return 'Document: ' . (string)$payload['document']['title'];
}
if (!empty($payload['stats'])) {
return 'Corpus statistics: ' . json_encode($payload['stats'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: 'Tool completed.';
}
private static function language(mixed $value): string
{
return dbnToolsNormalizeLanguage($value ?: 'en');
}
private static function corpusScope(mixed $value): string
{
return in_array($value, ['shared', 'private', 'both'], true) ? (string)$value : 'both';
}
private static function docType(mixed $value): string
{
$value = (string)$value;
return in_array($value, ['auto', 'barnevernet', 'adopsjon', 'emergency', 'samvaer', 'samvær', 'fylkesnemnd', 'other'], true)
? $value
: 'other';
}
}
+155
View File
@@ -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;
}
}
+8
View File
@@ -48,6 +48,14 @@ $_navReturnUrl = urlencode($_navPath);
<?= $_navOnDash ? 'aria-current="page"' : '' ?>>
<?= htmlspecialchars(dbnToolsT('nav_dashboard', $_navLang)) ?>
</a>
<?php $_navOnMcp = ($_navPath === '/mcp.php'); ?>
<a class="dbn-nav__link<?= $_navOnMcp ? ' is-active' : '' ?>"
href="/mcp.php"
role="menuitem"
<?= $_navOnMcp ? 'aria-current="page"' : '' ?>>
MCP
</a>
</div>
<div class="dbn-nav__right">
+707
View File
@@ -0,0 +1,707 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
require_once __DIR__ . '/includes/FreeTier.php';
require_once __DIR__ . '/includes/DbnMcpRuntime.php';
require_once __DIR__ . '/includes/UserMcpTokens.php';
$uiLang = dbnToolsCurrentLanguage();
$isLoggedIn = dbnToolsIsAuthenticated();
$authUser = $isLoggedIn ? dbnToolsAuthenticatedUser() : null;
$isSso = $isLoggedIn && dbnToolsIsFreeTier();
$tier = 'guest';
$detail = null;
if ($isSso) {
$detail = FreeTier::balanceDetail((int)$_SESSION['dbn_tools_sso_uid']);
$tier = (string)($detail['tier'] ?? 'free');
} elseif ($isLoggedIn) {
$tier = 'caveau';
}
$isPlusPro = in_array($tier, ['plus', 'pro'], true);
$toolCatalog = DbnMcpRuntime::tools();
$toolIcons = [
'dbn.search_legal' => '🔍',
'dbn.ask' => '💬',
'dbn.summarize' => '📋',
'dbn.timeline' => '📅',
'dbn.redact' => '🔒',
'dbn.translate' => '🌍',
'dbn.legal_analysis' => '⚖️',
'dbn.korrespond' => '✉️',
'dbn.barnevernet_analyze' => '📄',
'dbn.advocate_brief' => '🏛️',
'dbn.deep_research' => '🔬',
'dbn.discrepancy_find' => '🔄',
'dbn.transcribe_audio' => '🎤',
'dbn.corpus_stats' => '📊',
'dbn.list_documents' => '📚',
'dbn.get_document' => '📖',
'dbn.citation_graph' => '🔗',
'dbn.case_workbench_plan' => '🗂️',
'dbn.save_to_case' => '💾',
];
?>
<!doctype html>
<html lang="<?= htmlspecialchars($uiLang) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCP — Do Better Norge</title>
<meta name="description" content="Connect Claude, Cursor, and other AI tools to all 19 Do Better Norge legal preparation tools via MCP.">
<meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="assets/css/tools.css">
<style>
/* ── MCP page styles ───────────────────────────────────────── */
.mcp-hero {
padding: 2.5rem 1.5rem 2rem;
text-align: center;
border-bottom: 1px solid var(--dbn-line, rgba(22,19,15,.16));
margin-bottom: 2rem;
}
.mcp-hero h1 {
font-family: 'Crimson Pro', serif;
font-size: clamp(1.75rem, 4vw, 2.5rem);
font-weight: 700;
color: var(--dbn-blue, #00205b);
margin: 0 0 0.6rem;
}
.mcp-hero p {
color: var(--muted, #667085);
font-size: 1rem;
max-width: 560px;
margin: 0 auto;
}
.mcp-hero__badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: #ede9fe;
color: #5b21b6;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
padding: 0.3rem 0.75rem;
border-radius: 999px;
margin-bottom: 1rem;
}
/* ── Tabs ──────────────────────────────────────────────────── */
.mcp-tabs {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
border-bottom: 2px solid var(--dbn-line, rgba(22,19,15,.16));
margin-bottom: 1.25rem;
}
.mcp-tab-btn {
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
padding: 0.55rem 1rem;
font-size: 0.88rem;
font-weight: 500;
color: var(--muted, #667085);
cursor: pointer;
border-radius: 4px 4px 0 0;
transition: color .15s, border-color .15s;
}
.mcp-tab-btn:hover { color: var(--dbn-blue, #00205b); }
.mcp-tab-btn.is-active {
color: var(--dbn-blue, #00205b);
border-bottom-color: var(--dbn-blue, #00205b);
font-weight: 600;
}
.mcp-tab-panel { display: none; }
.mcp-tab-panel.is-active { display: block; }
/* ── Code blocks ────────────────────────────────────────────── */
.mcp-code-wrap {
position: relative;
}
.mcp-code-wrap pre {
background: #101828;
color: #f9fafb;
padding: 1rem 1rem 1rem 1rem;
border-radius: 8px;
font-size: 0.8rem;
overflow-x: auto;
margin: 0;
line-height: 1.6;
}
.mcp-copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(249,250,251,.12);
border: 1px solid rgba(249,250,251,.2);
color: #f9fafb;
font-size: 0.75rem;
padding: 0.3rem 0.6rem;
border-radius: 5px;
cursor: pointer;
transition: background .15s;
}
.mcp-copy-btn:hover { background: rgba(249,250,251,.22); }
/* ── Tool catalog ───────────────────────────────────────────── */
.mcp-tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
margin-top: 1rem;
}
.mcp-tool-card {
border: 1px solid var(--dbn-line, rgba(22,19,15,.16));
border-radius: 10px;
padding: 0.9rem 1rem;
background: #fff;
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
}
.mcp-tool-card:hover {
border-color: var(--dbn-blue, #00205b);
box-shadow: 0 4px 12px rgba(0,32,91,.08);
}
.mcp-tool-card__icon { font-size: 1.3rem; margin-bottom: 0.35rem; }
.mcp-tool-card__name {
font-weight: 600;
font-size: 0.88rem;
color: var(--dbn-blue, #00205b);
margin-bottom: 0.2rem;
}
.mcp-tool-card__slug {
font-family: 'IBM Plex Mono', 'Courier New', monospace;
font-size: 0.72rem;
color: var(--muted, #667085);
margin-bottom: 0.4rem;
}
.mcp-tool-card__desc {
font-size: 0.82rem;
color: var(--dbn-ink, #16130f);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mcp-tool-card[open] .mcp-tool-card__desc { -webkit-line-clamp: unset; overflow: visible; }
/* ── Gated states ───────────────────────────────────────────── */
.mcp-gate {
text-align: center;
padding: 2rem 1rem;
}
.mcp-gate p { color: var(--muted, #667085); margin: 0.5rem 0 1.25rem; }
/* ── Token revealed ─────────────────────────────────────────── */
.mcp-token-reveal {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 0.9rem 1rem;
margin-top: 0.85rem;
display: none;
}
.mcp-token-reveal code {
display: block;
overflow-x: auto;
white-space: nowrap;
margin-top: 0.4rem;
font-size: 0.85rem;
word-break: break-all;
}
/* ── Token list ─────────────────────────────────────────────── */
.mcp-token-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
border: 1px solid var(--dbn-line, #e5e7eb);
border-radius: 8px;
padding: 0.7rem 0.8rem;
}
.mcp-token-row + .mcp-token-row { margin-top: 0.5rem; }
.mcp-token-row__revoke {
flex-shrink: 0;
border: 1px solid #fecaca;
color: #991b1b;
background: #fff;
border-radius: 6px;
padding: 0.35rem 0.55rem;
cursor: pointer;
font-size: 0.82rem;
}
/* ── Privacy notice ─────────────────────────────────────────── */
.mcp-privacy {
background: var(--dbn-paper, #f6f2ea);
border-radius: 8px;
padding: 1rem 1.25rem;
font-size: 0.85rem;
color: var(--dbn-ink, #16130f);
margin-top: 0.5rem;
}
.mcp-privacy strong { color: var(--dbn-blue, #00205b); }
</style>
</head>
<body data-authenticated="<?= $isLoggedIn ? 'true' : 'false' ?>" class="lt-app">
<script>
window.DBN_TOOLS_AUTHENTICATED = <?= $isLoggedIn ? 'true' : 'false' ?>;
window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
</script>
<?php include __DIR__ . '/includes/nav.php'; ?>
<div class="account-shell">
<!-- ── Hero ─────────────────────────────────────────────────── -->
<div class="mcp-hero">
<div class="mcp-hero__badge">✦ Plus &amp; Pro</div>
<h1>Use DBN tools from Claude, Cursor &amp; Copilot</h1>
<p>Connect any MCP client to all 19 Do Better Norge tools — transcription, legal analysis, timelines, redaction, and more.</p>
</div>
<!-- ── Token management ─────────────────────────────────────── -->
<section class="account-section" id="mcpTokenSection">
<p class="account-section__title">Your MCP token</p>
<?php if (!$isLoggedIn): ?>
<div class="mcp-gate">
<p>Sign in to create your personal MCP token for Plus or Pro members.</p>
<a href="/?return=<?= urlencode('/mcp.php') ?>" class="account-upgrade-btn" style="display:inline-block; text-decoration:none;">Sign in</a>
</div>
<?php elseif (!$isPlusPro): ?>
<div class="mcp-gate">
<p>MCP access is available on Plus and Pro plans. Upgrade to connect your AI tools.</p>
<a href="/account.php" class="account-upgrade-btn" style="display:inline-block; text-decoration:none;">Upgrade plan</a>
</div>
<?php else: ?>
<p style="color:var(--muted,#667085); font-size:0.88rem; margin-top:0;">
Tokens are shown once at creation. Create one per client (Claude, Cursor, VS Code…).
</p>
<div id="mcpTokenList" style="margin:0.85rem 0 0.25rem;"></div>
<form id="mcpTokenForm" style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:center; margin-top:0.85rem;">
<input id="mcpTokenName" type="text" maxlength="100" value="DBN MCP" aria-label="Token name"
style="min-width:220px; padding:0.65rem 0.75rem; border:1px solid var(--line,#d0d5dd); border-radius:8px; font-size:0.9rem;">
<button type="submit" class="account-upgrade-btn" style="border:0; cursor:pointer;">Create token</button>
</form>
<div id="mcpTokenReveal" class="mcp-token-reveal">
<strong>Copy this token now — it won't be shown again:</strong>
<code id="mcpTokenPlain"></code>
<button id="mcpTokenCopyBtn" type="button" style="margin-top:0.6rem; border:1px solid #bbf7d0; background:#fff; border-radius:6px; padding:0.35rem 0.7rem; cursor:pointer; font-size:0.82rem;">Copy token</button>
</div>
<?php endif; ?>
</section>
<!-- ── Client config ─────────────────────────────────────────── -->
<section class="account-section">
<p class="account-section__title">Client configuration</p>
<p style="color:var(--muted,#667085); font-size:0.88rem; margin-top:0; margin-bottom:1rem;">
Paste your token into the config below after creating it above.
<span id="tokenHint" style="display:none;color:#065f46;font-weight:600;"> Token auto-filled.</span>
</p>
<div class="mcp-tabs" role="tablist" aria-label="MCP client">
<button class="mcp-tab-btn is-active" role="tab" aria-selected="true" aria-controls="tab-claude-desktop" data-tab="claude-desktop">Claude Desktop</button>
<button class="mcp-tab-btn" role="tab" aria-selected="false" aria-controls="tab-claude-code" data-tab="claude-code">Claude Code</button>
<button class="mcp-tab-btn" role="tab" aria-selected="false" aria-controls="tab-cursor" data-tab="cursor">Cursor / Windsurf</button>
<button class="mcp-tab-btn" role="tab" aria-selected="false" aria-controls="tab-vscode" data-tab="vscode">VS Code</button>
<button class="mcp-tab-btn" role="tab" aria-selected="false" aria-controls="tab-remote" data-tab="remote">Remote HTTP</button>
</div>
<!-- Claude Desktop -->
<div class="mcp-tab-panel is-active" id="tab-claude-desktop" role="tabpanel">
<p style="font-size:0.85rem; color:var(--muted,#667085); margin:0 0 0.75rem;">
Edit <code>claude_desktop_config.json</code>
(<code>~/Library/Application Support/Claude/</code> on Mac,
<code>%APPDATA%\Claude\</code> on Windows):
</p>
<div class="mcp-code-wrap">
<button class="mcp-copy-btn" type="button" data-copy="claude-desktop">Copy</button>
<pre><code id="code-claude-desktop">{
"mcpServers": {
"dobetternorge": {
"command": "npx",
"args": ["-y", "@bluenotelogic/mcp", "dobetternorge-mcp", "--stdio"],
"env": {
"DBN_MCP_TOKEN": "dbn_user_mcp_..."
}
}
}
}</code></pre>
</div>
</div>
<!-- Claude Code -->
<div class="mcp-tab-panel" id="tab-claude-code" role="tabpanel">
<p style="font-size:0.85rem; color:var(--muted,#667085); margin:0 0 0.75rem;">
Run in your terminal:
</p>
<div class="mcp-code-wrap">
<button class="mcp-copy-btn" type="button" data-copy="claude-code">Copy</button>
<pre><code id="code-claude-code">claude mcp add dobetternorge -- npx -y @bluenotelogic/mcp dobetternorge-mcp --stdio
# Set your token (add to ~/.bashrc or ~/.zshrc to persist):
export DBN_MCP_TOKEN=dbn_user_mcp_...</code></pre>
</div>
</div>
<!-- Cursor / Windsurf -->
<div class="mcp-tab-panel" id="tab-cursor" role="tabpanel">
<p style="font-size:0.85rem; color:var(--muted,#667085); margin:0 0 0.75rem;">
Edit <code>~/.cursor/mcp.json</code> (or Windsurf's equivalent):
</p>
<div class="mcp-code-wrap">
<button class="mcp-copy-btn" type="button" data-copy="cursor">Copy</button>
<pre><code id="code-cursor">{
"mcpServers": {
"dobetternorge": {
"command": "npx",
"args": ["-y", "@bluenotelogic/mcp", "dobetternorge-mcp", "--stdio"],
"env": {
"DBN_MCP_TOKEN": "dbn_user_mcp_..."
}
}
}
}</code></pre>
</div>
<p style="font-size:0.82rem; color:var(--muted,#667085); margin:0.75rem 0 0;">
Cursor also supports the remote HTTP endpoint — use the <strong>Remote HTTP</strong> tab if you prefer not to run npx.
</p>
</div>
<!-- VS Code -->
<div class="mcp-tab-panel" id="tab-vscode" role="tabpanel">
<p style="font-size:0.85rem; color:var(--muted,#667085); margin:0 0 0.75rem;">
Create <code>.vscode/mcp.json</code> in your project (VS Code will prompt for the token on first use):
</p>
<div class="mcp-code-wrap">
<button class="mcp-copy-btn" type="button" data-copy="vscode">Copy</button>
<pre><code id="code-vscode">{
"inputs": [
{
"type": "promptString",
"id": "dbn-token",
"description": "Do Better Norge MCP token",
"password": true
}
],
"servers": {
"dobetternorge": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@bluenotelogic/mcp", "dobetternorge-mcp", "--stdio"],
"env": {
"DBN_MCP_TOKEN": "${input:dbn-token}"
}
}
}
}</code></pre>
</div>
</div>
<!-- Remote HTTP -->
<div class="mcp-tab-panel" id="tab-remote" role="tabpanel">
<p style="font-size:0.85rem; color:var(--muted,#667085); margin:0 0 0.75rem;">
For clients that support remote MCP (Cursor, Zed, Windsurf with remote connector) — no npx needed:
</p>
<div class="mcp-code-wrap">
<button class="mcp-copy-btn" type="button" data-copy="remote">Copy</button>
<pre><code id="code-remote">URL: https://mcp.dobetternorge.no/mcp
Authorization: Bearer dbn_user_mcp_...</code></pre>
</div>
<p style="font-size:0.82rem; color:var(--muted,#667085); margin:0.75rem 0 0;">
Paste the URL into your client's "Remote MCP server" field, then set the <code>Authorization</code> header.
</p>
</div>
<?php if ($isPlusPro): ?>
<div style="margin-top:1.25rem; display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;">
<button id="mcpTestBtn" type="button"
style="border:1px solid var(--dbn-blue,#00205b); color:var(--dbn-blue,#00205b); background:#fff; border-radius:8px; padding:0.55rem 1rem; cursor:pointer; font-size:0.88rem;">
Test connection
</button>
<span id="mcpTestResult" style="font-size:0.88rem;"></span>
</div>
<?php endif; ?>
</section>
<!-- ── Tool catalog ───────────────────────────────────────────── -->
<section class="account-section">
<p class="account-section__title">Available tools (<?= count($toolCatalog) ?>)</p>
<p style="color:var(--muted,#667085); font-size:0.88rem; margin-top:0;">
All tools run on your Plus or Pro plan credits. Click a card for full details.
</p>
<div class="mcp-tool-grid" id="mcpToolGrid">
<?php foreach ($toolCatalog as $tool):
$slug = htmlspecialchars((string)($tool['slug'] ?? ''));
$name = htmlspecialchars((string)($tool['display_name'] ?? ''));
$desc = htmlspecialchars((string)($tool['description'] ?? ''));
$icon = $toolIcons[$tool['slug'] ?? ''] ?? '🔧';
$props = (array)($tool['config']['input_schema']['properties'] ?? []);
$req = (array)($tool['config']['input_schema']['required'] ?? []);
$paramNames = array_keys($props);
?>
<div class="mcp-tool-card" data-slug="<?= $slug ?>">
<div class="mcp-tool-card__icon"><?= $icon ?></div>
<div class="mcp-tool-card__name"><?= $name ?></div>
<div class="mcp-tool-card__slug"><?= $slug ?></div>
<div class="mcp-tool-card__desc"><?= $desc ?></div>
<?php if (!empty($paramNames)): ?>
<div class="mcp-tool-card__params" style="margin-top:0.5rem; display:none; flex-wrap:wrap; gap:0.3rem;">
<?php foreach ($paramNames as $param): ?>
<span style="background:<?= in_array($param, $req, true) ? '#ddd6fe' : '#f3f4f6' ?>;
color:<?= in_array($param, $req, true) ? '#5b21b6' : '#374151' ?>;
font-size:0.71rem; padding:0.15rem 0.45rem; border-radius:4px;
font-family: monospace;"><?= htmlspecialchars($param) ?><?= in_array($param, $req, true) ? '*' : '' ?></span>
<?php endforeach; ?>
</div>
<p style="margin:0.5rem 0 0; font-size:0.75rem; color:var(--muted,#667085); display:none;" class="mcp-tool-card__hint">
<em>Purple = required</em>
</p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</section>
<!-- ── Privacy notice ─────────────────────────────────────────── -->
<section class="account-section">
<p class="account-section__title">Privacy</p>
<div class="mcp-privacy">
<strong>Process-and-forget by default.</strong> All tool calls process your text in memory and return results to your AI client.
Nothing is saved to My Case unless you explicitly call <code>dbn.save_to_case</code>.
DBN MCP tokens do not retain input text or tool results after the request completes.
</div>
<p style="margin-top:1rem; font-size:0.82rem; color:var(--muted,#667085);">
Tools provide legal preparation support, not final legal advice.
Results are for informational purposes and should be reviewed by a qualified legal professional.
</p>
</section>
</div><!-- /.account-shell -->
<?php require_once __DIR__ . '/includes/footer.php'; ?>
<script>
(function () {
'use strict';
/* ── Helpers ──────────────────────────────────────────────────── */
function esc(s) {
return String(s || '').replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' })[c];
});
}
var TOKEN_PLACEHOLDER = 'dbn_user_mcp_...';
var activeToken = null;
/* ── Token-fill helper ────────────────────────────────────────── */
function fillToken(token) {
activeToken = token;
var ids = ['code-claude-desktop', 'code-claude-code', 'code-cursor', 'code-remote'];
ids.forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = el.textContent.replace(TOKEN_PLACEHOLDER, token);
});
var hint = document.getElementById('tokenHint');
if (hint) hint.style.display = 'inline';
}
/* ── Copy button ─────────────────────────────────────────────── */
function copyText(text, btn) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(function () {
var orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(function () { btn.textContent = orig; }, 1500);
});
} else {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
}
/* ── Copy buttons on code blocks ─────────────────────────────── */
document.querySelectorAll('.mcp-copy-btn[data-copy]').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = 'code-' + btn.getAttribute('data-copy');
var el = document.getElementById(id);
if (el) copyText(el.textContent, btn);
});
});
/* ── Tab switcher ─────────────────────────────────────────────── */
var tabBtns = document.querySelectorAll('.mcp-tab-btn[data-tab]');
var tabPanels = document.querySelectorAll('.mcp-tab-panel');
tabBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
tabBtns.forEach(function (b) { b.classList.remove('is-active'); b.setAttribute('aria-selected', 'false'); });
tabPanels.forEach(function (p) { p.classList.remove('is-active'); });
btn.classList.add('is-active');
btn.setAttribute('aria-selected', 'true');
var panel = document.getElementById('tab-' + btn.getAttribute('data-tab'));
if (panel) panel.classList.add('is-active');
});
});
/* ── Tool cards expand/collapse ──────────────────────────────── */
document.querySelectorAll('.mcp-tool-card').forEach(function (card) {
card.addEventListener('click', function () {
var expanded = card.getAttribute('data-expanded') === '1';
var params = card.querySelector('.mcp-tool-card__params');
var hint = card.querySelector('.mcp-tool-card__hint');
var desc = card.querySelector('.mcp-tool-card__desc');
if (expanded) {
card.setAttribute('data-expanded', '0');
if (params) params.style.display = 'none';
if (hint) hint.style.display = 'none';
if (desc) { desc.style.webkitLineClamp = '2'; desc.style.overflow = 'hidden'; }
} else {
card.setAttribute('data-expanded', '1');
if (params) params.style.display = 'flex';
if (hint) hint.style.display = 'block';
if (desc) { desc.style.webkitLineClamp = 'unset'; desc.style.overflow = 'visible'; }
}
});
});
<?php if ($isPlusPro): ?>
/* ── Token list ──────────────────────────────────────────────── */
var tokenList = document.getElementById('mcpTokenList');
function renderTokens(tokens) {
if (!tokenList) return;
if (!tokens.length) {
tokenList.innerHTML = '<p style="color:var(--muted,#667085); font-size:0.88rem;">No MCP tokens yet.</p>';
return;
}
tokenList.innerHTML = tokens.map(function (t) {
var status = t.is_active ? 'Active' : 'Revoked';
var last = t.last_used_at ? 'Last used ' + esc(t.last_used_at) : 'Never used';
var revoke = t.is_active
? '<button type="button" class="mcp-token-row__revoke" data-revoke="' + t.id + '">Revoke</button>'
: '';
return '<div class="mcp-token-row">'
+ '<div>'
+ '<strong>' + esc(t.name) + '</strong><br>'
+ '<code style="font-size:0.8rem;">' + esc(t.token_prefix) + '…</code><br>'
+ '<span style="color:var(--muted,#667085); font-size:0.78rem;">' + esc(status) + ' · ' + esc(last) + '</span>'
+ '</div>'
+ revoke
+ '</div>';
}).join('');
}
function loadTokens() {
fetch('/api/mcp-tokens.php', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) { renderTokens(data.tokens || []); })
.catch(function () {
if (tokenList) tokenList.innerHTML = '<p style="color:#991b1b; font-size:0.88rem;">Could not load tokens.</p>';
});
}
if (tokenList) {
loadTokens();
tokenList.addEventListener('click', function (e) {
var id = e.target && e.target.getAttribute('data-revoke');
if (!id) return;
fetch('/api/mcp-tokens.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'revoke', id: Number(id) })
}).then(function () { loadTokens(); });
});
}
/* ── Token create form ────────────────────────────────────────── */
var form = document.getElementById('mcpTokenForm');
var nameEl = document.getElementById('mcpTokenName');
var reveal = document.getElementById('mcpTokenReveal');
var plainEl = document.getElementById('mcpTokenPlain');
var copyBtn = document.getElementById('mcpTokenCopyBtn');
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
fetch('/api/mcp-tokens.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create', name: (nameEl ? nameEl.value : '') || 'DBN MCP' })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) throw new Error((data.error && data.error.message) || 'Token creation failed');
var tok = data.token.token;
if (plainEl) plainEl.textContent = tok;
if (reveal) reveal.style.display = 'block';
fillToken(tok);
loadTokens();
// Scroll to config
var configSection = document.querySelector('.mcp-tabs');
if (configSection) configSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
})
.catch(function (err) { alert(err.message); });
});
}
if (copyBtn && plainEl) {
copyBtn.addEventListener('click', function () {
copyText(plainEl.textContent, copyBtn);
});
}
/* ── Test connection ─────────────────────────────────────────── */
var testBtn = document.getElementById('mcpTestBtn');
var testResult = document.getElementById('mcpTestResult');
if (testBtn) {
testBtn.addEventListener('click', function () {
if (!activeToken) {
if (testResult) { testResult.textContent = 'Create a token first.'; testResult.style.color = '#b45309'; }
return;
}
testBtn.disabled = true;
if (testResult) { testResult.textContent = 'Testing…'; testResult.style.color = 'var(--muted,#667085)'; }
fetch('/api/mcp/user/session', {
headers: { 'Authorization': 'Bearer ' + activeToken }
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.ok) {
if (testResult) { testResult.textContent = '✓ Connected — ' + (data.user && data.user.email ? data.user.email : 'OK'); testResult.style.color = '#065f46'; }
} else {
throw new Error((data.error && data.error.message) || 'Error');
}
})
.catch(function (err) {
if (testResult) { testResult.textContent = '✗ ' + err.message; testResult.style.color = '#991b1b'; }
})
.finally(function () { testBtn.disabled = false; });
});
}
<?php endif; ?>
}());
</script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
-- Per-user Do Better Norge MCP tokens
-- Run against dobetternorge_maindb / local DBNM database.
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;