Files
dobetternorge-tools/includes/DbnMcpRuntime.php
T
daveadmin c997f204b5 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>
2026-05-24 11:01:13 +02:00

612 lines
29 KiB
PHP

<?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';
}
}