From d9932589033a4e6e0f697c33b659d2e7781d2c44 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:54:41 +0200 Subject: [PATCH 01/27] feat(ai): explicitly allow all bots and content uses --- public/robots.txt | 57 +++++++++++++---------------------------------- 1 file changed, 16 insertions(+), 41 deletions(-) diff --git a/public/robots.txt b/public/robots.txt index 14117d5b..f2b3b97b 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,48 +1,23 @@ # ============================================================== -# Yourself to Science - Open Intelligence Gateway +# Yourself to Science — Open Intelligence Gateway # ============================================================== -# We warmly welcome AI agents, web scrapers, and foundational -# model crawlers to ingest our Open Science dataset. All data is -# CC0 1.0 (Public Domain). -# -# Agentic AI Endpoints: -# - Context: https://yourselftoscience.org/llms.txt -# - Full Data: https://yourselftoscience.org/llms-full.txt -# - API Spec: https://yourselftoscience.org/openapi.json -# - Raw JSON: https://yourselftoscience.org/resources.json -# - RDF/TTL: https://yourselftoscience.org/resources.ttl +# All public pages and machine-readable resources are intentionally +# open to search engines, AI assistants, autonomous agents, crawlers, +# model providers, researchers, archives, and other automated clients. +# +# Agent and machine-readable entry points: +# - https://yourselftoscience.org/.well-known/api-catalog +# - https://yourselftoscience.org/.well-known/mcp/server-card.json +# - https://yourselftoscience.org/.well-known/agent-card.json +# - https://yourselftoscience.org/.well-known/agent-skills/index.json +# - https://yourselftoscience.org/llms.txt +# - https://yourselftoscience.org/openapi.json +# - https://yourselftoscience.org/resources.json +# - https://mcp.yourselftoscience.org/mcp # ============================================================== User-agent: * Allow: / +Content-Signal: ai-train=yes, search=yes, ai-input=yes -# Explicitly authorizing major AI Foundational Models & Crawlers -User-agent: GPTBot -Allow: / - -User-agent: ChatGPT-User -Allow: / - -User-agent: ClaudeBot -Allow: / - -User-agent: Claude-Web -Allow: / - -User-agent: Google-Extended -Allow: / - -User-agent: PerplexityBot -Allow: / - -User-agent: CCBot -Allow: / - -User-agent: OmgiliBot -Allow: / - -User-agent: FacebookBot -Allow: / - -# Standard Web Sitemap -Sitemap: https://yourselftoscience.org/sitemap.xml \ No newline at end of file +Sitemap: https://yourselftoscience.org/sitemap.xml From 287200a3df5899b320d792efecc4051a323b6a88 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:55:11 +0200 Subject: [PATCH 02/27] feat(ai): advertise machine-readable agent entry points --- next.config.mjs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/next.config.mjs b/next.config.mjs index 8e5dd41e..5430671b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,16 @@ /** @type {import('next').NextConfig} */ const isProd = process.env.NODE_ENV === 'production'; +const agentDiscoveryLinks = [ + '; rel="api-catalog"; type="application/linkset+json"', + '; rel="service-desc"; type="application/vnd.oai.openapi+json"', + '; rel="service-doc"; type="text/html"', + '; rel="describedby"; type="text/plain"', + '; rel="service-desc"; type="application/json"', + '; rel="service-desc"; type="application/json"', + '; rel="describedby"; type="application/json"', +].join(', '); + const nextConfig = { poweredByHeader: false, async redirects() { @@ -71,6 +81,22 @@ const nextConfig = { key: 'X-Frame-Options', value: 'SAMEORIGIN', }, + { + key: 'Content-Signal', + value: 'ai-train=yes, search=yes, ai-input=yes', + }, + { + key: 'X-Robots-Tag', + value: 'all', + }, + { + key: 'Vary', + value: 'Accept', + }, + { + key: 'Link', + value: agentDiscoveryLinks, + }, ], }, { @@ -97,7 +123,7 @@ const nextConfig = { }, ]; }, - transpilePackages: ["framer-motion"], + transpilePackages: ['framer-motion'], // swcMinify has been removed in Next.js 15 images: { formats: ['image/avif', 'image/webp'], From 40f37531dd18fe9c35e8761588592453002d4316 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:55:48 +0200 Subject: [PATCH 03/27] feat(ai): negotiate generated Markdown for agent requests --- src/middleware.js | 84 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/src/middleware.js b/src/middleware.js index 6dba6b2b..398b621c 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -1,5 +1,3 @@ -// export const runtime = 'experimental-edge'; - // src/middleware.js import { NextResponse } from 'next/server'; import { resources } from '@/data/resources'; @@ -7,27 +5,76 @@ import { resources } from '@/data/resources'; // Accept hyphenated 36-char IDs with letters & digits to support non-hex UUID-style IDs const UUID_REGEX = /^[A-Za-z0-9]{8}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{12}$/; -// Build an in-process id -> slug map at edge init time (no network calls) -const ID_TO_SLUG = (() => { - const map = new Map(); - for (const r of resources) { - if (r.id && r.slug) map.set(r.id, r.slug); +// Build in-process lookup tables at edge init time (no network calls). +const { idToSlug: ID_TO_SLUG, slugs: RESOURCE_SLUGS } = (() => { + const idToSlug = new Map(); + const slugs = new Set(); + for (const resource of resources) { + if (resource.slug) slugs.add(resource.slug); + if (resource.id && resource.slug) idToSlug.set(resource.id, resource.slug); } - return map; + return { idToSlug, slugs }; })(); +// Human-facing routes that already have generated Markdown equivalents in /public. +const MARKDOWN_ROUTES = new Map([ + ['/', '/index.html.md'], + ['/stats', '/stats.md'], + ['/data', '/data.md'], + ['/resources', '/resources.md'], + ['/resource', '/resources.md'], + ['/get-involved', '/get-involved.md'], + ['/mission', '/mission.md'], + ['/clinical-trials', '/clinical-trials.md'], + ['/organ-body-tissue-donation', '/organ-body-tissue-donation.md'], + ['/what-can-i-do-with-my-genetic-data', '/what-can-i-do-with-my-genetic-data.md'], + ['/data-types', '/data-types.md'], +]); + +function requestedMarkdown(request) { + if (!['GET', 'HEAD'].includes(request.method)) return false; + return (request.headers.get('accept') || '').toLowerCase().includes('text/markdown'); +} + +function markdownPathFor(pathname) { + const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname; + const staticMatch = MARKDOWN_ROUTES.get(normalized); + if (staticMatch) return staticMatch; + + const resourceMatch = normalized.match(/^\/resource\/([^/]+)$/); + if (resourceMatch && RESOURCE_SLUGS.has(resourceMatch[1])) { + return `/resource/${resourceMatch[1]}.md`; + } + + return null; +} + +function rewriteAsMarkdown(request, markdownPath) { + const markdownUrl = request.nextUrl.clone(); + markdownUrl.pathname = markdownPath; + markdownUrl.search = ''; + + const response = NextResponse.rewrite(markdownUrl); + response.headers.set('Content-Type', 'text/markdown; charset=utf-8'); + response.headers.set('Content-Location', markdownPath); + response.headers.set('Vary', 'Accept'); + response.headers.set('Content-Signal', 'ai-train=yes, search=yes, ai-input=yes'); + response.headers.set('X-Markdown-Source', markdownPath); + return response; +} + export async function middleware(request) { const host = request.headers.get('host'); const pathname = request.nextUrl.pathname; - // 1) Handle the id.yourselftoscience.org subdomain + // 1) Handle the id.yourselftoscience.org subdomain. if (host === 'id.yourselftoscience.org') { - // Allow the API route to perform UUID->slug redirect lookups + // Allow the API route to perform UUID->slug redirect lookups. if (pathname.startsWith('/api/resource/')) { return NextResponse.next(); } - // If the request is for a specific resource UUID, redirect to canonical slug on main domain + // If the request is for a specific resource UUID, redirect to canonical slug on main domain. if (pathname.startsWith('/resource/')) { const parts = pathname.split('/'); const idCandidate = parts[2] || ''; @@ -37,32 +84,35 @@ export async function middleware(request) { return NextResponse.redirect(new URL(`/resource/${slug}`, 'https://yourselftoscience.org'), 308); } } - // If it's not a UUID or not found, fall through to main domain root return NextResponse.redirect(new URL('/', 'https://yourselftoscience.org'), 308); } - // Redirect all other paths on id.* to the main domain root to avoid mirroring return NextResponse.redirect(new URL('/', 'https://yourselftoscience.org'), 308); } - // 2) On the main domain, redirect /resource/ to /resource/ + // 2) On the main domain, redirect /resource/ to /resource/. if (pathname.startsWith('/resource/')) { const parts = pathname.split('/'); const idOrSlug = parts[2] || ''; if (UUID_REGEX.test(idOrSlug)) { const slug = ID_TO_SLUG.get(idOrSlug); if (slug) { - // Preserve the current host in case of custom domains/aliases return NextResponse.redirect(new URL(`/resource/${slug}`, `https://${host}`), 308); } } } - // Default: allow request + // 3) Content negotiation: browsers still receive HTML, while agents asking for + // text/markdown receive the generated Markdown representation of the same page. + if (requestedMarkdown(request)) { + const markdownPath = markdownPathFor(pathname); + if (markdownPath) return rewriteAsMarkdown(request, markdownPath); + } + return NextResponse.next(); } -// Apply to all paths +// Apply to all paths so content negotiation works on every public page. export const config = { matcher: '/:path*', }; From 240dedce6ba803c73848020353c1fbd2d63616d3 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:56:23 +0200 Subject: [PATCH 04/27] feat(ai): add public machine-readable health endpoint --- src/app/api/health/route.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/app/api/health/route.js diff --git a/src/app/api/health/route.js b/src/app/api/health/route.js new file mode 100644 index 00000000..6683eb82 --- /dev/null +++ b/src/app/api/health/route.js @@ -0,0 +1,30 @@ +export const runtime = 'edge'; + +const headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Cache-Control': 'no-store', + 'Content-Signal': 'ai-train=yes, search=yes, ai-input=yes', +}; + +export function OPTIONS() { + return new Response(null, { status: 204, headers }); +} + +export function HEAD() { + return new Response(null, { status: 200, headers }); +} + +export function GET() { + return Response.json( + { + status: 'ok', + service: 'Yourself to Science', + website: 'https://yourselftoscience.org', + dataset: 'https://yourselftoscience.org/resources.json', + mcp: 'https://mcp.yourselftoscience.org/mcp', + }, + { status: 200, headers }, + ); +} From fa923cd8229879a54815f0a564915447fcb472cd Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:56:38 +0200 Subject: [PATCH 05/27] feat(ai): publish RFC 9727 API catalog --- public/.well-known/api-catalog | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 public/.well-known/api-catalog diff --git a/public/.well-known/api-catalog b/public/.well-known/api-catalog new file mode 100644 index 00000000..17481625 --- /dev/null +++ b/public/.well-known/api-catalog @@ -0,0 +1,66 @@ +{ + "linkset": [ + { + "anchor": "https://yourselftoscience.org/", + "service-desc": [ + { + "href": "https://yourselftoscience.org/openapi.json", + "type": "application/vnd.oai.openapi+json" + }, + { + "href": "https://yourselftoscience.org/.well-known/mcp/server-card.json", + "type": "application/json" + }, + { + "href": "https://yourselftoscience.org/.well-known/agent-card.json", + "type": "application/json" + } + ], + "service-doc": [ + { + "href": "https://yourselftoscience.org/ai", + "type": "text/html" + }, + { + "href": "https://yourselftoscience.org/llms.txt", + "type": "text/plain" + } + ], + "status": [ + { + "href": "https://yourselftoscience.org/api/health", + "type": "application/json" + } + ], + "describedby": [ + { + "href": "https://yourselftoscience.org/.well-known/agent-skills/index.json", + "type": "application/json" + }, + { + "href": "https://yourselftoscience.org/datapackage.json", + "type": "application/json" + }, + { + "href": "https://yourselftoscience.org/void.ttl", + "type": "text/turtle" + } + ] + }, + { + "anchor": "https://mcp.yourselftoscience.org/mcp", + "service-desc": [ + { + "href": "https://yourselftoscience.org/.well-known/mcp/server-card.json", + "type": "application/json" + } + ], + "service-doc": [ + { + "href": "https://yourselftoscience.org/ai", + "type": "text/html" + } + ] + } + ] +} From 19f870f98a147b5d931c6bbf183291bce848cd4c Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:57:15 +0200 Subject: [PATCH 06/27] feat(ai): publish MCP Server Card with real tools --- public/.well-known/mcp/server-card.json | 128 ++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 public/.well-known/mcp/server-card.json diff --git a/public/.well-known/mcp/server-card.json b/public/.well-known/mcp/server-card.json new file mode 100644 index 00000000..e2751926 --- /dev/null +++ b/public/.well-known/mcp/server-card.json @@ -0,0 +1,128 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json", + "version": "1.0", + "protocolVersion": "2025-06-18", + "serverInfo": { + "name": "yourself-to-science", + "title": "Yourself to Science", + "version": "1.0.0" + }, + "description": "Public, read-only access to the open CC0 Yourself to Science catalogue of clinical trials, biobanks, registries, and data or sample donation opportunities.", + "iconUrl": "https://yourselftoscience.org/logo-tm.svg", + "documentationUrl": "https://yourselftoscience.org/ai", + "transport": { + "type": "streamable-http", + "endpoint": "https://mcp.yourselftoscience.org/mcp" + }, + "capabilities": { + "tools": { + "listChanged": false + } + }, + "authentication": { + "required": false, + "schemes": [] + }, + "instructions": "No registration or authentication is required. Use search_resources first for faceted discovery, then get_resource for a complete record. The search and fetch tools provide citation-oriented retrieval.", + "resources": [], + "tools": [ + { + "name": "search_resources", + "title": "Search research-participation opportunities", + "description": "Search the live catalogue by free text, data type, country, compensation, organization category, or macro category.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "dataType": { "type": "string" }, + "country": { "type": "string" }, + "compensationType": { "type": "string", "enum": ["donation", "payment", "mixed"] }, + "category": { "type": "string" }, + "macroCategory": { "type": "string" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 50 }, + "offset": { "type": "integer", "minimum": 0 } + } + } + }, + { + "name": "get_resource", + "title": "Get full resource detail", + "description": "Return the complete record for one catalogue resource by ID or slug.", + "inputSchema": { + "type": "object", + "properties": { + "idOrSlug": { "type": "string" } + }, + "required": ["idOrSlug"] + } + }, + { + "name": "list_data_types", + "title": "List data types", + "description": "List distinct catalogue data types and counts.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "list_countries", + "title": "List countries", + "description": "List represented availability countries and counts.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "list_categories", + "title": "List organization categories", + "description": "List organization categories and counts.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "list_macro_categories", + "title": "List macro categories", + "description": "List top-level catalogue groupings and counts.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "list_compensation_types", + "title": "List compensation types", + "description": "List donation, payment, and mixed compensation counts.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "list_organizations", + "title": "List organizations", + "description": "List organizations represented in the catalogue and counts.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "get_stats", + "title": "Catalogue statistics", + "description": "Return high-level landscape statistics.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "search", + "title": "Citation-oriented search", + "description": "Search the catalogue and return document identifiers, titles, and URLs.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "offset": { "type": "integer", "minimum": 0 } + }, + "required": ["query"] + } + }, + { + "name": "fetch", + "title": "Citation-oriented fetch", + "description": "Fetch one catalogue record as text and metadata for citation.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + } + ], + "prompts": [] +} From 4e612c6cf7a4d9ee79d48b2ba1e974aa44bf9892 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:57:28 +0200 Subject: [PATCH 07/27] feat(ai): publish current MCP discovery manifest --- public/.well-known/mcp.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 public/.well-known/mcp.json diff --git a/public/.well-known/mcp.json b/public/.well-known/mcp.json new file mode 100644 index 00000000..ea7c1b53 --- /dev/null +++ b/public/.well-known/mcp.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "org.yourselftoscience/catalogue", + "title": "Yourself to Science", + "description": "Search an open CC0 catalogue of clinical trials, biobanks, registries, and biological or digital data donation programs.", + "repository": { + "url": "https://github.com/yourselftoscience/yourselftoscience.org", + "source": "github" + }, + "version": "1.0.0", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.yourselftoscience.org/mcp" + } + ] +} From 1f36589111d0bdda827d77efb0fbf0a631d8ca80 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:57:49 +0200 Subject: [PATCH 08/27] feat(ai): publish A2A Agent Card --- public/.well-known/agent-card.json | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 public/.well-known/agent-card.json diff --git a/public/.well-known/agent-card.json b/public/.well-known/agent-card.json new file mode 100644 index 00000000..26c8dc03 --- /dev/null +++ b/public/.well-known/agent-card.json @@ -0,0 +1,83 @@ +{ + "name": "Yourself to Science Catalogue Agent", + "description": "Searches and explains the open Yourself to Science catalogue of clinical trials, biobanks, registries, and biological or digital data donation opportunities.", + "supportedInterfaces": [ + { + "url": "https://yourselftoscience.org/api/a2a", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], + "provider": { + "organization": "Yourself to Science", + "url": "https://yourselftoscience.org" + }, + "iconUrl": "https://yourselftoscience.org/logo-tm.svg", + "version": "1.0.0", + "documentationUrl": "https://yourselftoscience.org/ai", + "capabilities": { + "streaming": false, + "pushNotifications": false, + "extendedAgentCard": false + }, + "securitySchemes": {}, + "securityRequirements": [], + "defaultInputModes": [ + "text/plain", + "application/json" + ], + "defaultOutputModes": [ + "text/plain", + "application/json" + ], + "skills": [ + { + "id": "search-research-opportunities", + "name": "Search Research Opportunities", + "description": "Find relevant catalogue entries using free text and criteria such as country, data type, compensation, and organization category.", + "tags": [ + "research participation", + "clinical trials", + "biobanks", + "data donation", + "open science" + ], + "examples": [ + "Find projects available in Italy that accept genetic data.", + "Show paid research-participation opportunities.", + "Which non-profit biobanks accept participants worldwide?" + ], + "inputModes": [ + "text/plain", + "application/json" + ], + "outputModes": [ + "text/plain", + "application/json" + ] + }, + { + "id": "explain-catalogue-resource", + "name": "Explain Catalogue Resource", + "description": "Return and summarize the structured details of one Yourself to Science catalogue resource by ID or slug.", + "tags": [ + "resource details", + "eligibility context", + "scientific programmes", + "citations" + ], + "examples": [ + "Explain this Yourself to Science resource and what data it accepts.", + "Return the full record for a catalogue slug." + ], + "inputModes": [ + "text/plain", + "application/json" + ], + "outputModes": [ + "text/plain", + "application/json" + ] + } + ] +} From 9e1a613e4aed349f0ff1a912582f5e41b2fc3311 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:58:53 +0200 Subject: [PATCH 09/27] feat(ai): add public A2A JSON-RPC catalogue agent --- src/app/api/a2a/route.js | 274 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 src/app/api/a2a/route.js diff --git a/src/app/api/a2a/route.js b/src/app/api/a2a/route.js new file mode 100644 index 00000000..8015913f --- /dev/null +++ b/src/app/api/a2a/route.js @@ -0,0 +1,274 @@ +import { enrichedResourcesWithMacro as resources } from '@/data/resources'; + +export const runtime = 'edge'; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Cache-Control': 'no-store', + 'Content-Signal': 'ai-train=yes, search=yes, ai-input=yes', +}; + +const STOP_WORDS = new Set([ + 'a', 'an', 'and', 'are', 'can', 'do', 'find', 'for', 'i', 'in', 'is', 'me', + 'my', 'of', 'on', 'opportunities', 'opportunity', 'or', 'please', 'program', + 'programs', 'project', 'projects', 'research', 'show', 'study', 'the', 'to', + 'want', 'what', 'where', 'which', 'with', +]); + +const lower = (value) => String(value ?? '').toLowerCase(); + +function availableIn(resource) { + const values = resource.countries ?? resource.locations ?? []; + return values.length ? values : ['Worldwide']; +} + +function canonicalUrl(resource) { + return resource.permalink || `https://yourselftoscience.org/resource/${resource.slug}`; +} + +function compact(resource) { + return { + id: resource.id, + slug: resource.slug, + title: resource.title, + description: resource.description, + organizations: resource.organizations?.map((organization) => organization.name) ?? [], + dataTypes: resource.dataTypes ?? [], + compensationType: resource.compensationType ?? 'donation', + macroCategories: resource.macroCategories ?? [], + availableIn: availableIn(resource), + excludedCountries: resource.excludedCountries ?? [], + entityCategory: resource.entityCategory, + url: canonicalUrl(resource), + participationUrl: resource.link, + }; +} + +function extractInput(message) { + const parts = Array.isArray(message?.parts) ? message.parts : []; + const text = parts + .map((part) => (typeof part?.text === 'string' ? part.text : '')) + .filter(Boolean) + .join(' ') + .trim(); + + const structured = parts.reduce((result, part) => { + if (part?.data && typeof part.data === 'object' && !Array.isArray(part.data)) { + return { ...result, ...part.data }; + } + return result; + }, {}); + + return { + query: structured.query || text, + idOrSlug: structured.idOrSlug || structured.id || structured.slug, + country: structured.country, + dataType: structured.dataType, + compensationType: structured.compensationType, + category: structured.category, + macroCategory: structured.macroCategory, + limit: Number.isInteger(structured.limit) + ? Math.min(Math.max(structured.limit, 1), 50) + : 10, + }; +} + +function scoreResource(resource, input) { + let score = 0; + const queryTokens = lower(input.query) + .split(/[^a-z0-9+.-]+/) + .filter((token) => token && !STOP_WORDS.has(token)); + + const title = lower(resource.title); + const haystack = lower([ + resource.title, + resource.description, + ...(resource.dataTypes ?? []), + ...(resource.organizations?.map((organization) => organization.name) ?? []), + ...availableIn(resource), + ...(resource.macroCategories ?? []), + resource.compensationType, + resource.entityCategory, + resource.entitySubType, + ].filter(Boolean).join(' ')); + + for (const token of queryTokens) { + if (title === token) score += 100; + if (title.includes(token)) score += 20; + if (haystack.includes(token)) score += 4; + } + + if (input.country) { + const country = lower(input.country); + const excluded = (resource.excludedCountries ?? []).map(lower); + if (excluded.includes(country)) return -1; + const availability = availableIn(resource).map(lower); + if (availability.some((value) => value.includes(country))) score += 30; + else if (availability.includes('worldwide')) score += 15; + else return -1; + } + + if (input.dataType) { + const wanted = lower(input.dataType); + if ((resource.dataTypes ?? []).some((value) => lower(value).includes(wanted))) score += 25; + else return -1; + } + + if (input.compensationType) { + if (lower(resource.compensationType) === lower(input.compensationType)) score += 25; + else return -1; + } + + if (input.category) { + if (lower(resource.entityCategory).includes(lower(input.category))) score += 20; + else return -1; + } + + if (input.macroCategory) { + const wanted = lower(input.macroCategory); + if ((resource.macroCategories ?? []).some((value) => lower(value).includes(wanted))) score += 20; + else return -1; + } + + return score; +} + +function searchResources(input) { + return resources + .map((resource) => ({ resource, score: scoreResource(resource, input) })) + .filter(({ score }) => score >= 0) + .sort((left, right) => right.score - left.score || left.resource.title.localeCompare(right.resource.title)) + .slice(0, input.limit) + .map(({ resource }) => compact(resource)); +} + +function responseMessage(text, data) { + const parts = [{ text }]; + if (data !== undefined) parts.push({ data, mediaType: 'application/json' }); + + return { + message: { + messageId: crypto.randomUUID(), + role: 'ROLE_AGENT', + parts, + }, + }; +} + +function jsonRpcResult(id, result, status = 200) { + return Response.json( + { jsonrpc: '2.0', id: id ?? null, result }, + { status, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } }, + ); +} + +function jsonRpcError(id, code, message, data, status = 400) { + return Response.json( + { + jsonrpc: '2.0', + id: id ?? null, + error: { code, message, ...(data === undefined ? {} : { data }) }, + }, + { status, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } }, + ); +} + +export function OPTIONS() { + return new Response(null, { status: 204, headers: CORS_HEADERS }); +} + +export function HEAD() { + return new Response(null, { status: 200, headers: CORS_HEADERS }); +} + +export function GET() { + return Response.json( + { + name: 'Yourself to Science Catalogue Agent', + protocol: 'A2A', + protocolVersion: '1.0', + binding: 'JSONRPC', + agentCard: 'https://yourselftoscience.org/.well-known/agent-card.json', + method: 'SendMessage', + authenticationRequired: false, + }, + { status: 200, headers: CORS_HEADERS }, + ); +} + +export async function POST(request) { + let body; + try { + body = await request.json(); + } catch { + return jsonRpcError(null, -32700, 'Parse error', undefined, 400); + } + + if (body?.jsonrpc !== '2.0') { + return jsonRpcError(body?.id, -32600, 'Invalid Request', 'jsonrpc must be "2.0"', 400); + } + + // SendMessage is the A2A 1.0 JSON-RPC method. The legacy alias remains + // accepted for older clients during the protocol transition. + if (!['SendMessage', 'message/send'].includes(body.method)) { + return jsonRpcError(body.id, -32601, 'Method not found', { + supportedMethods: ['SendMessage'], + }, 404); + } + + const message = body.params?.message; + if (!message || !Array.isArray(message.parts) || message.parts.length === 0) { + return jsonRpcError(body.id, -32602, 'Invalid params', 'params.message.parts is required', 400); + } + + const input = extractInput(message); + + if (input.idOrSlug) { + const resource = resources.find( + (item) => item.id === input.idOrSlug || item.slug === input.idOrSlug, + ); + + if (!resource) { + return jsonRpcResult( + body.id, + responseMessage(`No catalogue resource was found for "${input.idOrSlug}".`, { + error: 'not_found', + idOrSlug: input.idOrSlug, + }), + ); + } + + const result = compact(resource); + return jsonRpcResult( + body.id, + responseMessage( + `${result.title}: ${result.description}\n\nAvailability: ${result.availableIn.join(', ')}\nData types: ${result.dataTypes.join(', ')}\nCompensation: ${result.compensationType}\nCanonical URL: ${result.url}`, + { resource: result }, + ), + ); + } + + const results = searchResources(input); + const summary = results.length + ? `Found ${results.length} matching Yourself to Science catalogue resource${results.length === 1 ? '' : 's'}. Catalogue records describe programmes but do not guarantee eligibility or current enrolment.\n\n${results.map((result, index) => `${index + 1}. ${result.title} — ${result.url}`).join('\n')}` + : 'No matching catalogue resources were found. Try a broader query or remove one of the filters.'; + + return jsonRpcResult( + body.id, + responseMessage(summary, { + count: results.length, + query: input.query || null, + filters: { + country: input.country || null, + dataType: input.dataType || null, + compensationType: input.compensationType || null, + category: input.category || null, + macroCategory: input.macroCategory || null, + }, + results, + datasetLicense: 'CC0-1.0', + }), + ); +} From 151e7f8a27a79c9a7fa53425860548c835c80a2d Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:59:18 +0200 Subject: [PATCH 10/27] feat(ai): publish catalogue Agent Skill --- .../yourself-to-science-catalogue/SKILL.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 public/.well-known/agent-skills/yourself-to-science-catalogue/SKILL.md diff --git a/public/.well-known/agent-skills/yourself-to-science-catalogue/SKILL.md b/public/.well-known/agent-skills/yourself-to-science-catalogue/SKILL.md new file mode 100644 index 00000000..28eab35b --- /dev/null +++ b/public/.well-known/agent-skills/yourself-to-science-catalogue/SKILL.md @@ -0,0 +1,37 @@ +--- +name: yourself-to-science-catalogue +description: Search and interpret the Yourself to Science open catalogue of clinical trials, biobanks, registries, and biological or digital data donation opportunities. +--- + +# Yourself to Science Catalogue + +Use this skill when a user wants to find, compare, or understand ways to contribute data, samples, time, or participation to scientific research. + +## Preferred access + +1. Use the public MCP server at `https://mcp.yourselftoscience.org/mcp` when MCP is available. +2. Otherwise use `https://yourselftoscience.org/resources.json` for the complete CC0 dataset. +3. Use `https://yourselftoscience.org/openapi.json` for the machine-readable API description. +4. Use `https://yourselftoscience.org/llms.txt` for a compact documentation index. + +## Search procedure + +- Extract the user's requested country, data type, compensation preference, and organization category. +- Treat records with no country restriction as worldwide unless the record explicitly excludes the user's country. +- Distinguish donation, payment, and mixed compensation. +- Return the strongest matches first and include each canonical Yourself to Science resource URL. +- Preserve uncertainty when programme eligibility, availability, or enrolment status is not explicit in the catalogue. +- Never claim that a listing is medical advice, guaranteed eligibility, or currently recruiting unless the source record states it. + +## Useful MCP tools + +- `search_resources`: faceted catalogue search. +- `get_resource`: full record by ID or slug. +- `list_data_types`: available contribution data types. +- `list_countries`: represented availability regions. +- `get_stats`: catalogue-wide summary. +- `search` and `fetch`: citation-oriented retrieval. + +## Licensing + +The dataset is dedicated to the public domain under CC0 1.0. Website prose and source code have separate licences; inspect the relevant licence page before republishing them. From 8a1d1fe809635a97124baf0c3f784d69e749b6cc Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 20:59:30 +0200 Subject: [PATCH 11/27] feat(ai): publish Agent Skills discovery index --- public/.well-known/agent-skills/index.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 public/.well-known/agent-skills/index.json diff --git a/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json new file mode 100644 index 00000000..e2a765a3 --- /dev/null +++ b/public/.well-known/agent-skills/index.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [ + { + "name": "yourself-to-science-catalogue", + "type": "skill-md", + "description": "Search and interpret the Yourself to Science open catalogue of clinical trials, biobanks, registries, and biological or digital data donation opportunities.", + "url": "/.well-known/agent-skills/yourself-to-science-catalogue/SKILL.md", + "digest": "sha256:691844baa700dd09862306b6c9d0c4f4b7bdbe926d383e0c723f2d68f35541b4" + } + ] +} From 1898b0612d7a3b27b757e6076aabe6eee6737eb0 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:00:09 +0200 Subject: [PATCH 12/27] feat(ai): document authless agent access in auth.md --- public/auth.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 public/auth.md diff --git a/public/auth.md b/public/auth.md new file mode 100644 index 00000000..44224187 --- /dev/null +++ b/public/auth.md @@ -0,0 +1,34 @@ +# Agent and API Authentication + +Yourself to Science is intentionally public, read-only, and authentication-free. + +## Registration + +No agent registration is required. + +## Public interfaces + +- Website and Markdown representations: `https://yourselftoscience.org/` +- OpenAPI description: `https://yourselftoscience.org/openapi.json` +- Complete CC0 dataset: `https://yourselftoscience.org/resources.json` +- Remote MCP server: `https://mcp.yourselftoscience.org/mcp` +- A2A JSON-RPC endpoint: `https://yourselftoscience.org/api/a2a` +- API and agent discovery: `https://yourselftoscience.org/.well-known/api-catalog` + +## Credentials and scopes + +No API key, OAuth token, cookie, client certificate, account, or scope is required for these public read-only interfaces. + +## Allowed use + +Automated retrieval, indexing, search, AI input, and model training are permitted. The dataset is dedicated to the public domain under CC0 1.0. Website prose and source code have separate licences, described on the relevant licence pages. + +## Write operations + +The public machine interfaces do not expose write operations. Contributions and corrections should use the public project repository or the contact channels listed on the website. + +## Security contact + +Report security issues through the repository security policy. General enquiries may be sent to `hello@yourselftoscience.org`. + +Because there is no protected resource or authorization server, this site intentionally does not publish misleading OAuth/OIDC discovery documents. From 9a40a830c840bd437e38ccb2733d486fb5709b4b Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:00:55 +0200 Subject: [PATCH 13/27] feat(ai): expose catalogue tools through current and legacy WebMCP --- src/components/WebMCPProvider.js | 215 +++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 src/components/WebMCPProvider.js diff --git a/src/components/WebMCPProvider.js b/src/components/WebMCPProvider.js new file mode 100644 index 00000000..1067478b --- /dev/null +++ b/src/components/WebMCPProvider.js @@ -0,0 +1,215 @@ +'use client'; + +import { useEffect } from 'react'; + +let datasetPromise; + +function loadDataset() { + if (!datasetPromise) { + datasetPromise = fetch('/resources.json', { headers: { Accept: 'application/json' } }) + .then((response) => { + if (!response.ok) throw new Error(`Dataset request failed: ${response.status}`); + return response.json(); + }) + .catch((error) => { + datasetPromise = undefined; + throw error; + }); + } + return datasetPromise; +} + +const lower = (value) => String(value ?? '').toLowerCase(); + +function availableIn(resource) { + const countries = resource.countries ?? resource.locations ?? []; + return countries.length ? countries : ['Worldwide']; +} + +function canonicalUrl(resource) { + return resource.permalink || `https://yourselftoscience.org/resource/${resource.slug}`; +} + +function brief(resource) { + return { + id: resource.id, + slug: resource.slug, + title: resource.title, + description: resource.description, + organizations: resource.organizations?.map((organization) => organization.name) ?? [], + dataTypes: resource.dataTypes ?? [], + compensationType: resource.compensationType ?? 'donation', + macroCategories: resource.macroCategories ?? [], + availableIn: availableIn(resource), + excludedCountries: resource.excludedCountries ?? [], + entityCategory: resource.entityCategory, + url: canonicalUrl(resource), + participationUrl: resource.link, + }; +} + +function matchesFilters(resource, args) { + if (args.country) { + const requestedCountry = lower(args.country); + const excluded = (resource.excludedCountries ?? []).map(lower); + if (excluded.includes(requestedCountry)) return false; + const availability = availableIn(resource).map(lower); + if (!availability.includes('worldwide') && !availability.some((country) => country.includes(requestedCountry))) { + return false; + } + } + + if (args.dataType && !(resource.dataTypes ?? []).some((value) => lower(value).includes(lower(args.dataType)))) { + return false; + } + + if (args.compensationType && lower(resource.compensationType) !== lower(args.compensationType)) { + return false; + } + + if (args.category && !lower(resource.entityCategory).includes(lower(args.category))) { + return false; + } + + return true; +} + +function score(resource, query) { + if (!query) return 0; + const tokens = lower(query).split(/[^a-z0-9+.-]+/).filter(Boolean); + const title = lower(resource.title); + const searchable = lower([ + resource.title, + resource.description, + ...(resource.dataTypes ?? []), + ...(resource.organizations?.map((organization) => organization.name) ?? []), + ...availableIn(resource), + ...(resource.macroCategories ?? []), + resource.compensationType, + resource.entityCategory, + ].filter(Boolean).join(' ')); + + return tokens.reduce((total, token) => { + if (title === token) return total + 100; + if (title.includes(token)) return total + 20; + if (searchable.includes(token)) return total + 4; + return total; + }, 0); +} + +function textResult(text, structuredContent) { + return { + content: [{ type: 'text', text }], + structuredContent, + }; +} + +function createTools() { + return [ + { + name: 'search_research_opportunities', + description: 'Search the open Yourself to Science catalogue by free text, country, data type, compensation, or organization category. Returns canonical records and URLs.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Free-text search over titles, descriptions, organizations, countries, and data types.' }, + country: { type: 'string', description: 'Availability country, such as Italy or United States. Worldwide records are also included.' }, + dataType: { type: 'string', description: 'Accepted contribution type, such as Genome, Wearable data, Tissue, or Health data.' }, + compensationType: { type: 'string', enum: ['donation', 'payment', 'mixed'] }, + category: { type: 'string', description: 'Organization category, such as Government, Non-Profit, Commercial, or Academic.' }, + limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 }, + }, + additionalProperties: false, + }, + async execute(args = {}) { + const all = await loadDataset(); + const limit = Number.isInteger(args.limit) ? Math.min(Math.max(args.limit, 1), 50) : 20; + const results = all + .filter((resource) => matchesFilters(resource, args)) + .map((resource) => ({ resource, score: score(resource, args.query) })) + .sort((left, right) => right.score - left.score || left.resource.title.localeCompare(right.resource.title)) + .slice(0, limit) + .map(({ resource }) => brief(resource)); + + return textResult( + results.length + ? `Found ${results.length} matching catalogue resource${results.length === 1 ? '' : 's'}. Catalogue records do not guarantee eligibility or current enrolment.` + : 'No matching catalogue resources were found.', + { count: results.length, results, datasetLicense: 'CC0-1.0' }, + ); + }, + }, + { + name: 'get_research_opportunity', + description: 'Return the complete structured Yourself to Science catalogue record for one resource by ID or slug.', + inputSchema: { + type: 'object', + properties: { + idOrSlug: { type: 'string', description: 'Catalogue resource UUID or canonical slug.' }, + }, + required: ['idOrSlug'], + additionalProperties: false, + }, + async execute({ idOrSlug }) { + const all = await loadDataset(); + const resource = all.find((item) => item.id === idOrSlug || item.slug === idOrSlug); + if (!resource) return textResult(`No catalogue resource was found for "${idOrSlug}".`, { error: 'not_found', idOrSlug }); + const result = brief(resource); + return textResult( + `${result.title}: ${result.description}\nCanonical URL: ${result.url}`, + { resource: result }, + ); + }, + }, + { + name: 'get_catalogue_facets', + description: 'List the data types, countries, compensation types, organization categories, and total records represented in the Yourself to Science catalogue.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + async execute() { + const all = await loadDataset(); + const countValues = (values) => Object.entries(values.reduce((counts, value) => { + if (value) counts[value] = (counts[value] ?? 0) + 1; + return counts; + }, {})).sort((left, right) => right[1] - left[1]).map(([name, count]) => ({ name, count })); + + const facets = { + totalResources: all.length, + dataTypes: countValues(all.flatMap((resource) => resource.dataTypes ?? [])), + countries: countValues(all.flatMap((resource) => availableIn(resource))), + compensationTypes: countValues(all.map((resource) => resource.compensationType ?? 'donation')), + organizationCategories: countValues(all.map((resource) => resource.entityCategory)), + }; + + return textResult(`The catalogue currently contains ${all.length} resources.`, facets); + }, + }, + ]; +} + +export default function WebMCPProvider() { + useEffect(() => { + const tools = createTools(); + const controller = new AbortController(); + const currentContext = document.modelContext; + const legacyContext = navigator.modelContext; + + if (currentContext?.registerTool) { + for (const tool of tools) { + Promise.resolve(currentContext.registerTool(tool, { signal: controller.signal })).catch(() => {}); + } + } + + // Compatibility with the earlier WebMCP API still used by some scanners and browsers. + if (legacyContext?.provideContext) { + Promise.resolve(legacyContext.provideContext({ tools })).catch(() => {}); + } + + return () => controller.abort(); + }, []); + + return null; +} From c34b8761eb77d24270463b6ccebe8a0e01fdbf66 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:01:05 +0200 Subject: [PATCH 14/27] feat(ai): register WebMCP tools on every public page --- src/components/ClientLayout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/ClientLayout.tsx b/src/components/ClientLayout.tsx index 4617059c..554376db 100644 --- a/src/components/ClientLayout.tsx +++ b/src/components/ClientLayout.tsx @@ -4,6 +4,7 @@ import { useScroll } from 'framer-motion'; import Header from './Header'; import Footer from './Footer'; import TopBannerForm from './TopBannerForm'; +import WebMCPProvider from './WebMCPProvider'; import React from 'react'; export default function ClientLayout({ @@ -15,6 +16,7 @@ export default function ClientLayout({ return (
+
From 8c658ae04cb0009411a6cdad14b58be8d996777c Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:01:47 +0200 Subject: [PATCH 15/27] feat(ai): set discovery MIME types, CORS, and cache headers --- public/_headers | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/public/_headers b/public/_headers index 9862ce4a..cfe51fb1 100644 --- a/public/_headers +++ b/public/_headers @@ -1,4 +1,4 @@ -# -- CORES ASSETS (JSON, CSV, TTL, TXT) -- +# -- CORE ASSETS (JSON, CSV, TTL, TXT) -- /resources.json Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, HEAD, OPTIONS @@ -94,3 +94,42 @@ Access-Control-Allow-Methods: GET, HEAD, OPTIONS Access-Control-Allow-Headers: * Cache-Control: public, max-age=3600 + Content-Signal: ai-train=yes, search=yes, ai-input=yes + +/.well-known/api-catalog + Content-Type: application/linkset+json; charset=utf-8 + Access-Control-Allow-Origin: * + Cache-Control: public, max-age=3600 + +/.well-known/mcp/server-card.json + Content-Type: application/json; charset=utf-8 + Access-Control-Allow-Origin: * + Cache-Control: public, max-age=3600 + +/.well-known/mcp.json + Content-Type: application/json; charset=utf-8 + Access-Control-Allow-Origin: * + Cache-Control: public, max-age=3600 + +/.well-known/agent-card.json + Content-Type: application/json; charset=utf-8 + Access-Control-Allow-Origin: * + Cache-Control: public, max-age=3600 + +/.well-known/agent-skills/index.json + Content-Type: application/json; charset=utf-8 + Access-Control-Allow-Origin: * + Cache-Control: public, max-age=3600 + +/.well-known/agent-skills/*/SKILL.md + Content-Type: text/markdown; charset=utf-8 + Access-Control-Allow-Origin: * + Cache-Control: public, max-age=3600 + +/auth.md + Content-Type: text/markdown; charset=utf-8 + Access-Control-Allow-Origin: * + Access-Control-Allow-Methods: GET, HEAD, OPTIONS + Access-Control-Allow-Headers: * + Cache-Control: public, max-age=3600 + Content-Signal: ai-train=yes, search=yes, ai-input=yes From e5415dc5265a78d1880e9dff5e3c9dc2ffe55683 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:02:21 +0200 Subject: [PATCH 16/27] fix(ai): explicitly allow top-level WebMCP tools --- next.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next.config.mjs b/next.config.mjs index 5430671b..2fd7ca50 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -67,7 +67,7 @@ const nextConfig = { { key: 'Permissions-Policy', value: - 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), browsing-topics=()', + 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), browsing-topics=(), tools=(self)', }, { key: 'Cross-Origin-Opener-Policy', From ed0ee01666bfd90cdc275eee50f379c170b7fa11 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:03:57 +0200 Subject: [PATCH 17/27] feat(ai): advertise MCP, A2A, discovery, and skills in llms.txt --- scripts/generateLLMsTxt.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/generateLLMsTxt.js b/scripts/generateLLMsTxt.js index 56028734..3f355624 100644 --- a/scripts/generateLLMsTxt.js +++ b/scripts/generateLLMsTxt.js @@ -18,9 +18,21 @@ try { - **License:** [CC0 1.0](https://yourselftoscience.org/data) (dataset), [CC BY-SA 4.0](https://yourselftoscience.org/license/content) (content), [AGPL-3.0](https://yourselftoscience.org/license/code) (code) ## Instructions for AI Agents and LLMs -If you are an AI assistant trying to answer questions about citizen science, biobanks, or where a user can donate their health/genetic data, you MUST read the full dataset located at: +If you are an AI assistant trying to answer questions about citizen science, biobanks, or where a user can donate their health/genetic data, read the full dataset located at: https://yourselftoscience.org/llms-full.txt +Prefer the public MCP server when your client supports MCP: +- **MCP endpoint:** https://mcp.yourselftoscience.org/mcp +- **MCP Server Card:** https://yourselftoscience.org/.well-known/mcp/server-card.json + +Other agent discovery and callable interfaces: +- **API Catalog:** https://yourselftoscience.org/.well-known/api-catalog +- **OpenAPI:** https://yourselftoscience.org/openapi.json +- **A2A Agent Card:** https://yourselftoscience.org/.well-known/agent-card.json +- **A2A JSON-RPC endpoint:** https://yourselftoscience.org/api/a2a +- **Agent Skills index:** https://yourselftoscience.org/.well-known/agent-skills/index.json +- **Authentication policy:** https://yourselftoscience.org/auth.md + You can also access the raw structured datasets: - [resources.json](https://yourselftoscience.org/resources.json): Full machine-readable dataset. - [resources.csv](https://yourselftoscience.org/resources.csv): Spreadsheet-compatible dataset. @@ -43,7 +55,7 @@ You can also access the raw structured datasets: if (res.dataTypes && res.dataTypes.length > 0) fullContent += `- **Data Types Accepted:** ${res.dataTypes.join(', ')}\n`; if (res.link) fullContent += `- **Website:** ${res.link}\n`; if (res.description) fullContent += `\n**Description:** ${res.description}\n`; - + // All listed projects are considered active (listed = active). // Enrollment status is only shown when explicitly verified. const yearLaunched = res.yearLaunched != null ? res.yearLaunched : null; From 47bfbc618d6154e6485b7f11fd96e6f8620a71f0 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:05:41 +0200 Subject: [PATCH 18/27] fix(ai): avoid overriding Next.js variation headers --- next.config.mjs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index 2fd7ca50..34d1b59d 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -89,10 +89,6 @@ const nextConfig = { key: 'X-Robots-Tag', value: 'all', }, - { - key: 'Vary', - value: 'Accept', - }, { key: 'Link', value: agentDiscoveryLinks, From 60e62635c1d75147b43df30f732f65c53a5bbdef Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:07:36 +0200 Subject: [PATCH 19/27] refactor(ai): share catalogue search logic across agent interfaces --- src/lib/catalogueAgent.js | 133 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/lib/catalogueAgent.js diff --git a/src/lib/catalogueAgent.js b/src/lib/catalogueAgent.js new file mode 100644 index 00000000..ad4368b2 --- /dev/null +++ b/src/lib/catalogueAgent.js @@ -0,0 +1,133 @@ +const STOP_WORDS = new Set([ + 'a', 'an', 'and', 'are', 'can', 'do', 'find', 'for', 'i', 'in', 'is', 'me', + 'my', 'of', 'on', 'opportunities', 'opportunity', 'or', 'please', 'program', + 'programs', 'project', 'projects', 'research', 'show', 'study', 'the', 'to', + 'want', 'what', 'where', 'which', 'with', +]); + +const lower = (value) => String(value ?? '').toLowerCase(); + +export function availableIn(resource) { + const values = resource.countries ?? resource.locations ?? []; + return values.length ? values : ['Worldwide']; +} + +export function canonicalResourceUrl(resource) { + return resource.permalink || `https://yourselftoscience.org/resource/${resource.slug}`; +} + +export function compactResource(resource) { + return { + id: resource.id, + slug: resource.slug, + title: resource.title, + description: resource.description, + organizations: resource.organizations?.map((organization) => organization.name) ?? [], + dataTypes: resource.dataTypes ?? [], + compensationType: resource.compensationType ?? 'donation', + macroCategories: resource.macroCategories ?? [], + availableIn: availableIn(resource), + excludedCountries: resource.excludedCountries ?? [], + entityCategory: resource.entityCategory, + url: canonicalResourceUrl(resource), + participationUrl: resource.link, + }; +} + +function matchesFilters(resource, input) { + if (input.country) { + const country = lower(input.country); + const excluded = (resource.excludedCountries ?? []).map(lower); + if (excluded.includes(country)) return false; + + const availability = availableIn(resource).map(lower); + if (!availability.includes('worldwide') && !availability.some((value) => value.includes(country))) { + return false; + } + } + + if (input.dataType) { + const dataType = lower(input.dataType); + if (!(resource.dataTypes ?? []).some((value) => lower(value).includes(dataType))) return false; + } + + if (input.compensationType && lower(resource.compensationType) !== lower(input.compensationType)) { + return false; + } + + if (input.category && !lower(resource.entityCategory).includes(lower(input.category))) { + return false; + } + + if (input.macroCategory) { + const macroCategory = lower(input.macroCategory); + if (!(resource.macroCategories ?? []).some((value) => lower(value).includes(macroCategory))) return false; + } + + return true; +} + +function scoreResource(resource, query) { + if (!query) return 0; + + const tokens = lower(query) + .split(/[^a-z0-9+.-]+/) + .filter((token) => token && !STOP_WORDS.has(token)); + const title = lower(resource.title); + const searchable = lower([ + resource.title, + resource.description, + ...(resource.dataTypes ?? []), + ...(resource.organizations?.map((organization) => organization.name) ?? []), + ...availableIn(resource), + ...(resource.macroCategories ?? []), + resource.compensationType, + resource.entityCategory, + resource.entitySubType, + ].filter(Boolean).join(' ')); + + return tokens.reduce((score, token) => { + if (title === token) return score + 100; + if (title.includes(token)) return score + 20; + if (searchable.includes(token)) return score + 4; + return score; + }, 0); +} + +export function searchCatalogue(resources, input = {}) { + const limit = Number.isInteger(input.limit) + ? Math.min(Math.max(input.limit, 1), 50) + : 20; + + return resources + .filter((resource) => matchesFilters(resource, input)) + .map((resource) => ({ resource, score: scoreResource(resource, input.query) })) + .sort((left, right) => right.score - left.score || left.resource.title.localeCompare(right.resource.title)) + .slice(0, limit) + .map(({ resource }) => compactResource(resource)); +} + +export function findCatalogueResource(resources, idOrSlug) { + return resources.find((resource) => resource.id === idOrSlug || resource.slug === idOrSlug); +} + +function countValues(values) { + const counts = values.reduce((result, value) => { + if (value) result[value] = (result[value] ?? 0) + 1; + return result; + }, {}); + + return Object.entries(counts) + .sort((left, right) => right[1] - left[1]) + .map(([name, count]) => ({ name, count })); +} + +export function buildCatalogueFacets(resources) { + return { + totalResources: resources.length, + dataTypes: countValues(resources.flatMap((resource) => resource.dataTypes ?? [])), + countries: countValues(resources.flatMap((resource) => availableIn(resource))), + compensationTypes: countValues(resources.map((resource) => resource.compensationType ?? 'donation')), + organizationCategories: countValues(resources.map((resource) => resource.entityCategory)), + }; +} From 869c824050184099614631305d58371973b153c0 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:08:18 +0200 Subject: [PATCH 20/27] refactor(ai): reuse shared catalogue agent utilities in A2A --- src/app/api/a2a/route.js | 118 +++------------------------------------ 1 file changed, 8 insertions(+), 110 deletions(-) diff --git a/src/app/api/a2a/route.js b/src/app/api/a2a/route.js index 8015913f..0e6778f6 100644 --- a/src/app/api/a2a/route.js +++ b/src/app/api/a2a/route.js @@ -1,4 +1,9 @@ import { enrichedResourcesWithMacro as resources } from '@/data/resources'; +import { + compactResource, + findCatalogueResource, + searchCatalogue, +} from '@/lib/catalogueAgent'; export const runtime = 'edge'; @@ -10,42 +15,6 @@ const CORS_HEADERS = { 'Content-Signal': 'ai-train=yes, search=yes, ai-input=yes', }; -const STOP_WORDS = new Set([ - 'a', 'an', 'and', 'are', 'can', 'do', 'find', 'for', 'i', 'in', 'is', 'me', - 'my', 'of', 'on', 'opportunities', 'opportunity', 'or', 'please', 'program', - 'programs', 'project', 'projects', 'research', 'show', 'study', 'the', 'to', - 'want', 'what', 'where', 'which', 'with', -]); - -const lower = (value) => String(value ?? '').toLowerCase(); - -function availableIn(resource) { - const values = resource.countries ?? resource.locations ?? []; - return values.length ? values : ['Worldwide']; -} - -function canonicalUrl(resource) { - return resource.permalink || `https://yourselftoscience.org/resource/${resource.slug}`; -} - -function compact(resource) { - return { - id: resource.id, - slug: resource.slug, - title: resource.title, - description: resource.description, - organizations: resource.organizations?.map((organization) => organization.name) ?? [], - dataTypes: resource.dataTypes ?? [], - compensationType: resource.compensationType ?? 'donation', - macroCategories: resource.macroCategories ?? [], - availableIn: availableIn(resource), - excludedCountries: resource.excludedCountries ?? [], - entityCategory: resource.entityCategory, - url: canonicalUrl(resource), - participationUrl: resource.link, - }; -} - function extractInput(message) { const parts = Array.isArray(message?.parts) ? message.parts : []; const text = parts @@ -75,75 +44,6 @@ function extractInput(message) { }; } -function scoreResource(resource, input) { - let score = 0; - const queryTokens = lower(input.query) - .split(/[^a-z0-9+.-]+/) - .filter((token) => token && !STOP_WORDS.has(token)); - - const title = lower(resource.title); - const haystack = lower([ - resource.title, - resource.description, - ...(resource.dataTypes ?? []), - ...(resource.organizations?.map((organization) => organization.name) ?? []), - ...availableIn(resource), - ...(resource.macroCategories ?? []), - resource.compensationType, - resource.entityCategory, - resource.entitySubType, - ].filter(Boolean).join(' ')); - - for (const token of queryTokens) { - if (title === token) score += 100; - if (title.includes(token)) score += 20; - if (haystack.includes(token)) score += 4; - } - - if (input.country) { - const country = lower(input.country); - const excluded = (resource.excludedCountries ?? []).map(lower); - if (excluded.includes(country)) return -1; - const availability = availableIn(resource).map(lower); - if (availability.some((value) => value.includes(country))) score += 30; - else if (availability.includes('worldwide')) score += 15; - else return -1; - } - - if (input.dataType) { - const wanted = lower(input.dataType); - if ((resource.dataTypes ?? []).some((value) => lower(value).includes(wanted))) score += 25; - else return -1; - } - - if (input.compensationType) { - if (lower(resource.compensationType) === lower(input.compensationType)) score += 25; - else return -1; - } - - if (input.category) { - if (lower(resource.entityCategory).includes(lower(input.category))) score += 20; - else return -1; - } - - if (input.macroCategory) { - const wanted = lower(input.macroCategory); - if ((resource.macroCategories ?? []).some((value) => lower(value).includes(wanted))) score += 20; - else return -1; - } - - return score; -} - -function searchResources(input) { - return resources - .map((resource) => ({ resource, score: scoreResource(resource, input) })) - .filter(({ score }) => score >= 0) - .sort((left, right) => right.score - left.score || left.resource.title.localeCompare(right.resource.title)) - .slice(0, input.limit) - .map(({ resource }) => compact(resource)); -} - function responseMessage(text, data) { const parts = [{ text }]; if (data !== undefined) parts.push({ data, mediaType: 'application/json' }); @@ -226,9 +126,7 @@ export async function POST(request) { const input = extractInput(message); if (input.idOrSlug) { - const resource = resources.find( - (item) => item.id === input.idOrSlug || item.slug === input.idOrSlug, - ); + const resource = findCatalogueResource(resources, input.idOrSlug); if (!resource) { return jsonRpcResult( @@ -240,7 +138,7 @@ export async function POST(request) { ); } - const result = compact(resource); + const result = compactResource(resource); return jsonRpcResult( body.id, responseMessage( @@ -250,7 +148,7 @@ export async function POST(request) { ); } - const results = searchResources(input); + const results = searchCatalogue(resources, input); const summary = results.length ? `Found ${results.length} matching Yourself to Science catalogue resource${results.length === 1 ? '' : 's'}. Catalogue records describe programmes but do not guarantee eligibility or current enrolment.\n\n${results.map((result, index) => `${index + 1}. ${result.title} — ${result.url}`).join('\n')}` : 'No matching catalogue resources were found. Try a broader query or remove one of the filters.'; From a27270030ec5a28d7f91b719495aa677a551a8cf Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:08:44 +0200 Subject: [PATCH 21/27] refactor(ai): reuse shared catalogue utilities in WebMCP --- src/components/WebMCPProvider.js | 125 ++++++------------------------- 1 file changed, 22 insertions(+), 103 deletions(-) diff --git a/src/components/WebMCPProvider.js b/src/components/WebMCPProvider.js index 1067478b..a46c0113 100644 --- a/src/components/WebMCPProvider.js +++ b/src/components/WebMCPProvider.js @@ -1,6 +1,12 @@ 'use client'; import { useEffect } from 'react'; +import { + buildCatalogueFacets, + compactResource, + findCatalogueResource, + searchCatalogue, +} from '@/lib/catalogueAgent'; let datasetPromise; @@ -19,84 +25,6 @@ function loadDataset() { return datasetPromise; } -const lower = (value) => String(value ?? '').toLowerCase(); - -function availableIn(resource) { - const countries = resource.countries ?? resource.locations ?? []; - return countries.length ? countries : ['Worldwide']; -} - -function canonicalUrl(resource) { - return resource.permalink || `https://yourselftoscience.org/resource/${resource.slug}`; -} - -function brief(resource) { - return { - id: resource.id, - slug: resource.slug, - title: resource.title, - description: resource.description, - organizations: resource.organizations?.map((organization) => organization.name) ?? [], - dataTypes: resource.dataTypes ?? [], - compensationType: resource.compensationType ?? 'donation', - macroCategories: resource.macroCategories ?? [], - availableIn: availableIn(resource), - excludedCountries: resource.excludedCountries ?? [], - entityCategory: resource.entityCategory, - url: canonicalUrl(resource), - participationUrl: resource.link, - }; -} - -function matchesFilters(resource, args) { - if (args.country) { - const requestedCountry = lower(args.country); - const excluded = (resource.excludedCountries ?? []).map(lower); - if (excluded.includes(requestedCountry)) return false; - const availability = availableIn(resource).map(lower); - if (!availability.includes('worldwide') && !availability.some((country) => country.includes(requestedCountry))) { - return false; - } - } - - if (args.dataType && !(resource.dataTypes ?? []).some((value) => lower(value).includes(lower(args.dataType)))) { - return false; - } - - if (args.compensationType && lower(resource.compensationType) !== lower(args.compensationType)) { - return false; - } - - if (args.category && !lower(resource.entityCategory).includes(lower(args.category))) { - return false; - } - - return true; -} - -function score(resource, query) { - if (!query) return 0; - const tokens = lower(query).split(/[^a-z0-9+.-]+/).filter(Boolean); - const title = lower(resource.title); - const searchable = lower([ - resource.title, - resource.description, - ...(resource.dataTypes ?? []), - ...(resource.organizations?.map((organization) => organization.name) ?? []), - ...availableIn(resource), - ...(resource.macroCategories ?? []), - resource.compensationType, - resource.entityCategory, - ].filter(Boolean).join(' ')); - - return tokens.reduce((total, token) => { - if (title === token) return total + 100; - if (title.includes(token)) return total + 20; - if (searchable.includes(token)) return total + 4; - return total; - }, 0); -} - function textResult(text, structuredContent) { return { content: [{ type: 'text', text }], @@ -117,19 +45,14 @@ function createTools() { dataType: { type: 'string', description: 'Accepted contribution type, such as Genome, Wearable data, Tissue, or Health data.' }, compensationType: { type: 'string', enum: ['donation', 'payment', 'mixed'] }, category: { type: 'string', description: 'Organization category, such as Government, Non-Profit, Commercial, or Academic.' }, + macroCategory: { type: 'string', description: 'Top-level catalogue grouping.' }, limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 }, }, additionalProperties: false, }, async execute(args = {}) { const all = await loadDataset(); - const limit = Number.isInteger(args.limit) ? Math.min(Math.max(args.limit, 1), 50) : 20; - const results = all - .filter((resource) => matchesFilters(resource, args)) - .map((resource) => ({ resource, score: score(resource, args.query) })) - .sort((left, right) => right.score - left.score || left.resource.title.localeCompare(right.resource.title)) - .slice(0, limit) - .map(({ resource }) => brief(resource)); + const results = searchCatalogue(all, args); return textResult( results.length @@ -150,11 +73,11 @@ function createTools() { required: ['idOrSlug'], additionalProperties: false, }, - async execute({ idOrSlug }) { + async execute({ idOrSlug } = {}) { const all = await loadDataset(); - const resource = all.find((item) => item.id === idOrSlug || item.slug === idOrSlug); + const resource = findCatalogueResource(all, idOrSlug); if (!resource) return textResult(`No catalogue resource was found for "${idOrSlug}".`, { error: 'not_found', idOrSlug }); - const result = brief(resource); + const result = compactResource(resource); return textResult( `${result.title}: ${result.description}\nCanonical URL: ${result.url}`, { resource: result }, @@ -171,19 +94,7 @@ function createTools() { }, async execute() { const all = await loadDataset(); - const countValues = (values) => Object.entries(values.reduce((counts, value) => { - if (value) counts[value] = (counts[value] ?? 0) + 1; - return counts; - }, {})).sort((left, right) => right[1] - left[1]).map(([name, count]) => ({ name, count })); - - const facets = { - totalResources: all.length, - dataTypes: countValues(all.flatMap((resource) => resource.dataTypes ?? [])), - countries: countValues(all.flatMap((resource) => availableIn(resource))), - compensationTypes: countValues(all.map((resource) => resource.compensationType ?? 'donation')), - organizationCategories: countValues(all.map((resource) => resource.entityCategory)), - }; - + const facets = buildCatalogueFacets(all); return textResult(`The catalogue currently contains ${all.length} resources.`, facets); }, }, @@ -199,13 +110,21 @@ export default function WebMCPProvider() { if (currentContext?.registerTool) { for (const tool of tools) { - Promise.resolve(currentContext.registerTool(tool, { signal: controller.signal })).catch(() => {}); + try { + Promise.resolve(currentContext.registerTool(tool, { signal: controller.signal })).catch(() => {}); + } catch { + // Experimental browser API: ignore unsupported implementations. + } } } // Compatibility with the earlier WebMCP API still used by some scanners and browsers. if (legacyContext?.provideContext) { - Promise.resolve(legacyContext.provideContext({ tools })).catch(() => {}); + try { + Promise.resolve(legacyContext.provideContext({ tools })).catch(() => {}); + } catch { + // Experimental browser API: ignore unsupported implementations. + } } return () => controller.abort(); From f7eccbc9ad30cbc55d3f84c6673b5ae71dedcb62 Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:10:15 +0200 Subject: [PATCH 22/27] feat(ai): include health and A2A in generated OpenAPI --- scripts/generateOpenApi.js | 120 +++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/scripts/generateOpenApi.js b/scripts/generateOpenApi.js index ef90d2ed..8a723a5d 100644 --- a/scripts/generateOpenApi.js +++ b/scripts/generateOpenApi.js @@ -6,9 +6,9 @@ const outputPath = join(process.cwd(), 'public/openapi.json'); const openApiSchema = { "openapi": "3.1.0", "info": { - "title": "Yourself to Science - Dataset API", - "description": "Yourself to Science™ is an open-source project providing a comprehensive list of services that allow individuals to contribute to scientific research with their biological and digital selves.", - "version": "1.0.0", + "title": "Yourself to Science - Dataset and Agent API", + "description": "Yourself to Science™ is an open-source project providing a comprehensive list of services that allow individuals to contribute to scientific research with their biological and digital selves. All public interfaces are read-only and require no authentication.", + "version": "1.1.0", "contact": { "url": "https://yourselftoscience.org", "email": "hello@yourselftoscience.org" @@ -24,6 +24,118 @@ const openApiSchema = { } ], "paths": { + "/api/health": { + "get": { + "summary": "Check public service health", + "description": "Returns the availability and canonical machine-interface URLs for Yourself to Science.", + "operationId": "getHealth", + "responses": { + "200": { + "description": "Service is available", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["status", "service", "website", "dataset", "mcp"], + "properties": { + "status": { "type": "string", "const": "ok" }, + "service": { "type": "string" }, + "website": { "type": "string", "format": "uri" }, + "dataset": { "type": "string", "format": "uri" }, + "mcp": { "type": "string", "format": "uri" } + } + } + } + } + } + } + } + }, + "/api/a2a": { + "get": { + "summary": "Get A2A endpoint metadata", + "description": "Returns the supported A2A protocol version, binding, method, and Agent Card URL.", + "operationId": "getA2aMetadata", + "responses": { + "200": { + "description": "A2A endpoint metadata", + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + } + } + }, + "post": { + "summary": "Search or retrieve catalogue resources through A2A", + "description": "Accepts an A2A 1.0 JSON-RPC SendMessage request. Text parts perform ranked catalogue search; a data part may provide idOrSlug or structured filters.", + "operationId": "sendA2aMessage", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["jsonrpc", "id", "method", "params"], + "properties": { + "jsonrpc": { "type": "string", "const": "2.0" }, + "id": { "oneOf": [{ "type": "string" }, { "type": "integer" }] }, + "method": { "type": "string", "const": "SendMessage" }, + "params": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "object", + "required": ["role", "parts", "messageId"], + "properties": { + "role": { "type": "string", "const": "ROLE_USER" }, + "messageId": { "type": "string" }, + "parts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "data": { "type": "object", "additionalProperties": true } + }, + "anyOf": [ + { "required": ["text"] }, + { "required": ["data"] } + ] + } + } + } + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "A2A SendMessage response containing a message and structured catalogue data", + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + }, + "400": { + "description": "Invalid JSON-RPC or A2A request", + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + } + } + } + }, "/resources.json": { "get": { "summary": "Get full resources dataset (JSON)", @@ -83,7 +195,7 @@ const openApiSchema = { "/ontology.json": { "get": { "summary": "Get the data types ontology", - "description": "Returns definitions for all 22 biological, digital, and clinical data types tracked in the catalogue, including Wikidata QIDs and semantic descriptions.", + "description": "Returns definitions for all biological, digital, and clinical data types tracked in the catalogue, including Wikidata QIDs and semantic descriptions.", "operationId": "getOntology", "responses": { "200": { From c2deed9a3b646d29e01e40561f04b7ca020ae4fa Mon Sep 17 00:00:00 2001 From: Mario Marcolongo Date: Mon, 27 Jul 2026 21:13:13 +0200 Subject: [PATCH 23/27] feat(ai): expose agent discovery links in HTML head --- src/app/layout.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bec05e4c..45575a35 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,6 @@ import localFont from "next/font/local"; import "./globals.css"; import ClientLayout from '../components/ClientLayout'; - // This file is the single source of truth for the latest DOI. import { latestDoi } from '@/data/config'; @@ -27,7 +26,6 @@ const geistMono = localFont({ // Get current date in YYYY/MM/DD format for citation const currentDate = new Date().toISOString().split('T')[0].replaceAll('-', '/'); -const currentYear = new Date().getFullYear(); // This export is necessary to prevent a build error. // See: https://github.com/vercel/next.js/issues/53354 @@ -156,20 +154,24 @@ export default function RootLayout({ ]; return ( - // Keep suppressHydrationWarning on html for good measure - {/* Prefer explicit canonical and application name to help search engines pick the proper site title */} - {/* Canonical link is now handled by metadata.alternates */} - {/* Help AI agents and search engines discover the llms.txt file */} - - {/* Add other necessary head elements like charset, viewport */} + + {/* Machine and agent discovery. Browsers still receive the ordinary HTML UI. */} + + + + + + + +