diff --git a/.gitignore b/.gitignore index fb2762de8..f616fc32d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,12 @@ dist/ # dependencies node_modules/ +# synced agent skills source checkout +.cache/ + +# agent skill files synced at build time from nextflow-io/agent-skills +public/.well-known/agent-skills/*/ + # logs npm-debug.log* yarn-debug.log* diff --git a/Makefile b/Makefile index 57f24c2ca..f2cf82caa 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,8 @@ publish: --acl public-read \ --exclude "$0" + bash scripts/set-s3-agent-metadata.sh + invalidate: aws cloudfront create-invalidation --distribution-id E3RPV5P71OW0UF --paths '/*' diff --git a/infra/cloudfront/README.md b/infra/cloudfront/README.md new file mode 100644 index 000000000..68ba7378c --- /dev/null +++ b/infra/cloudfront/README.md @@ -0,0 +1,20 @@ +# CloudFront agent-readiness configuration + +Production hosting uses S3 + CloudFront. Apply these after deploying static files. + +## Link response headers (RFC 8288) + +1. Create a response headers policy from `response-headers-policy.json`, or attach equivalent `Link` headers to the default cache behavior for `/` and `/index.html`. +2. Alternatively, rely on `scripts/set-s3-agent-metadata.sh` (runs during `make publish`) which sets S3 object metadata on `index.html`. + +## Markdown content negotiation + +1. Publish the CloudFront Function in `markdown-negotiation.js`. +2. Associate it with **viewer-request** on the default behavior. +3. Ensure `index.md` is deployed to S3 (copied from `public/index.md` via the Astro build). + +The function rewrites `/` and `/index.html` to `/index.md` when `Accept: text/markdown` is present. + +## Netlify previews + +PR previews use `public/_headers` for Link headers and `netlify/edge-functions/markdown-negotiate.js` for markdown negotiation. diff --git a/infra/cloudfront/markdown-negotiation.js b/infra/cloudfront/markdown-negotiation.js new file mode 100644 index 000000000..e4ec4c7ed --- /dev/null +++ b/infra/cloudfront/markdown-negotiation.js @@ -0,0 +1,18 @@ +// CloudFront Function: serve markdown when Accept: text/markdown is present. +// Associate with viewer-request on the nextflow.io CloudFront distribution. +function handler(event) { + var request = event.request; + var headers = request.headers; + var accept = headers.accept ? headers.accept.value : ""; + var uri = request.uri; + + if (accept.indexOf("text/markdown") === -1) { + return request; + } + + if (uri === "/" || uri === "/index.html") { + request.uri = "/index.md"; + } + + return request; +} diff --git a/infra/cloudfront/response-headers-policy.json b/infra/cloudfront/response-headers-policy.json new file mode 100644 index 000000000..71d276381 --- /dev/null +++ b/infra/cloudfront/response-headers-policy.json @@ -0,0 +1,23 @@ +{ + "Comment": "Link response headers for nextflow.io agent discovery (RFC 8288)", + "Name": "nextflow-io-agent-discovery-headers", + "HeadersConfig": { + "HeaderBehavior": "whitelist", + "Headers": { + "Quantity": 1, + "Items": ["Link"] + } + }, + "CustomHeadersConfig": { + "Quantity": 1, + "Items": [ + { + "Header": "Link", + "Value": "; rel=\"api-catalog\", ; rel=\"service-doc\", ; rel=\"service-desc\"", + "Override": false + } + ] + }, + "SecurityHeadersConfig": {}, + "CorsConfig": {} +} diff --git a/infra/dns-aid/README.md b/infra/dns-aid/README.md new file mode 100644 index 000000000..6d49b6950 --- /dev/null +++ b/infra/dns-aid/README.md @@ -0,0 +1,18 @@ +# DNS for AI Discovery (DNS-AID) + +Publish the records in `nextflow.io.zone` under the `_agents` namespace for `nextflow.io`. + +## Requirements + +1. Add `_index._agents.nextflow.io` and `_a2a._agents.nextflow.io` SVCB records (see zone file). +2. Enable DNSSEC on the public zone and publish DS records at your domain registrar. +3. Verify with DNS-over-HTTPS: + +```bash +curl -s 'https://cloudflare-dns.com/dns-query?name=_index._agents.nextflow.io&type=SVCB' \ + -H 'accept: application/dns-json' +``` + +## Production note + +DNS-AID records are managed outside this repository (Route 53, Cloudflare DNS, or your registrar). Apply the zone file through your DNS operations workflow after review. diff --git a/infra/dns-aid/nextflow.io.zone b/infra/dns-aid/nextflow.io.zone new file mode 100644 index 000000000..75880db93 --- /dev/null +++ b/infra/dns-aid/nextflow.io.zone @@ -0,0 +1,12 @@ +; DNS for AI Discovery (DNS-AID) records for nextflow.io +; Deploy this zone (or merge these records) with your DNS provider. +; Sign the zone with DNSSEC and publish DS records at the registrar. + +$ORIGIN nextflow.io. +$TTL 3600 + +; Index entrypoint — points agents to the site's API catalog +_index._agents.nextflow.io. IN SVCB 1 nextflow.io. alpn="h3,h2" port=443 mandatory=alpn,port + +; Agent-to-agent discovery entrypoint +_a2a._agents.nextflow.io. IN SVCB 1 nextflow.io. alpn="h3,h2" port=443 mandatory=alpn,port diff --git a/netlify.toml b/netlify.toml index 1079e0202..78164d693 100644 --- a/netlify.toml +++ b/netlify.toml @@ -5,6 +5,16 @@ [build.environment] NODE_VERSION = "20.11.0" +[[edge_functions]] + function = "markdown-negotiate" + path = "/" + cache = "manual" + +[[edge_functions]] + function = "markdown-negotiate" + path = "/index.html" + cache = "manual" + [[redirects]] from = "/vscode" to = "https://marketplace.visualstudio.com/items?itemName=nextflow.nextflow" diff --git a/netlify/edge-functions/markdown-negotiate.js b/netlify/edge-functions/markdown-negotiate.js new file mode 100644 index 000000000..7dcee7038 --- /dev/null +++ b/netlify/edge-functions/markdown-negotiate.js @@ -0,0 +1,36 @@ +const MARKDOWN_PATHS = { + "/": "/index.md", + "/index.html": "/index.md", +}; + +export default async function handler(request, context) { + const accept = request.headers.get("Accept") ?? ""; + if (!accept.includes("text/markdown")) { + return context.next(); + } + + const url = new URL(request.url); + const markdownPath = MARKDOWN_PATHS[url.pathname]; + if (!markdownPath) { + return context.next(); + } + + const markdownUrl = new URL(markdownPath, url.origin); + const markdownResponse = await context.rewrite(markdownUrl.toString()); + if (!markdownResponse.ok) { + return context.next(); + } + + const body = await markdownResponse.text(); + const tokenEstimate = Math.ceil(body.length / 4); + + return new Response(body, { + status: 200, + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "x-markdown-tokens": String(tokenEstimate), + Vary: "Accept", + "Cache-Control": "public, max-age=300, must-revalidate", + }, + }); +} diff --git a/package.json b/package.json index dd21a83a0..42adf7197 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "astro dev", "start": "astro dev", "docs": "bash build_docs.sh", + "prebuild": "node scripts/generate-agent-skills-index.mjs", "build": "astro check && astro build && bash build_docs.sh", + "generate:agent-skills-index": "node scripts/generate-agent-skills-index.mjs", "preview": "astro preview", "astro": "astro" }, diff --git a/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json new file mode 100644 index 000000000..fa3278ff4 --- /dev/null +++ b/public/.well-known/agent-skills/index.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "source": "https://github.com/nextflow-io/agent-skills", + "skills": [ + { + "name": "create-workflow", + "type": "skill-md", + "description": "INVOKE THIS SKILL IMMEDIATELY when user asks to: write/create/build a Nextflow pipeline or workflow,\ncreate any bioinformatics pipeline (RNA-seq, DNA-seq, variant calling, ChIP-seq, etc.),\nor compose/chain Nextflow modules from the Nextflow Registry. This skill handles all Nextflow workflow creation tasks.", + "url": "https://nextflow.io/.well-known/agent-skills/create-workflow/SKILL.md", + "digest": "sha256:cb038d5bc1c2b5a7e76f309cf28b8dfe889e88235d03717a9862f2b5a7434972" + }, + { + "name": "install-nextflow", + "type": "skill-md", + "description": "Install or upgrade Nextflow on the user's machine. Use when the user wants to install Nextflow, check their current Nextflow version, upgrade Nextflow, or set up the prerequisites (Java 17+) needed to run Nextflow.", + "url": "https://nextflow.io/.well-known/agent-skills/install-nextflow/SKILL.md", + "digest": "sha256:a2b8ba6464bc60dbc51c5aa47d024a9efd4e44af3c17f5e27a4ea099ffa5e4e9" + }, + { + "name": "launch-workflow", + "type": "skill-md", + "description": "Launch Nextflow pipeline executions on cloud and HPC clusters via Seqera Platform. Use when the user wants to run/launch/submit a pipeline on a cloud or cluster compute environment, configure a compute environment, push pipeline changes to GitHub before launching, or sign in to Seqera Platform.", + "url": "https://nextflow.io/.well-known/agent-skills/launch-workflow/SKILL.md", + "digest": "sha256:40ee0cca1679931ecdc284e86193b34a400d861f25d341cf3a358d4dce310cd3" + }, + { + "name": "run-module", + "type": "skill-md", + "description": "Run Nextflow Registry modules natively using `nextflow module` commands. Use when running, listing, or getting info about Nextflow modules.", + "url": "https://nextflow.io/.well-known/agent-skills/run-module/SKILL.md", + "digest": "sha256:206c2ad84616fca3290e3ed9d004404ea4b298d78cbb739efafdd65458a1f666" + } + ] +} diff --git a/public/.well-known/api-catalog b/public/.well-known/api-catalog new file mode 100644 index 000000000..7e88de1dd --- /dev/null +++ b/public/.well-known/api-catalog @@ -0,0 +1,19 @@ +{ + "linkset": [ + { + "anchor": "https://nextflow.io/", + "service-desc": [ + { + "href": "https://nextflow.io/openapi.json", + "type": "application/json" + } + ], + "service-doc": [ + { + "href": "https://docs.seqera.io/nextflow/", + "type": "text/html" + } + ] + } + ] +} diff --git a/public/.well-known/mcp/server-card.json b/public/.well-known/mcp/server-card.json new file mode 100644 index 000000000..95cb2d604 --- /dev/null +++ b/public/.well-known/mcp/server-card.json @@ -0,0 +1,11 @@ +{ + "serverInfo": { + "name": "nextflow.io", + "version": "1.0.0" + }, + "transport": { + "type": "webmcp", + "pageUrl": "https://nextflow.io/" + }, + "capabilities": ["tools"] +} diff --git a/public/.well-known/oauth-authorization-server b/public/.well-known/oauth-authorization-server new file mode 100644 index 000000000..48de5e940 --- /dev/null +++ b/public/.well-known/oauth-authorization-server @@ -0,0 +1,13 @@ +{ + "issuer": "https://nextflow.io", + "scopes_supported": [], + "agent_auth": { + "skill": "https://nextflow.io/auth.md", + "register_uri": "https://nextflow.io/agent/register", + "identity_types_supported": ["anonymous"], + "anonymous": { + "credential_types_supported": ["access_token"] + }, + "claim_uri": "https://nextflow.io/agent/claim" + } +} diff --git a/public/.well-known/oauth-protected-resource b/public/.well-known/oauth-protected-resource new file mode 100644 index 000000000..649450ac9 --- /dev/null +++ b/public/.well-known/oauth-protected-resource @@ -0,0 +1,7 @@ +{ + "resource": "https://nextflow.io", + "authorization_servers": ["https://nextflow.io"], + "scopes_supported": [], + "bearer_methods_supported": ["header"], + "resource_documentation": "https://docs.seqera.io/nextflow/" +} diff --git a/public/_headers b/public/_headers new file mode 100644 index 000000000..81e0dd695 --- /dev/null +++ b/public/_headers @@ -0,0 +1,45 @@ +/ + Link: ; rel="api-catalog", ; rel="service-doc", ; rel="service-desc" + +/index.html + Link: ; rel="api-catalog", ; rel="service-doc", ; rel="service-desc" + +/.well-known/api-catalog + Content-Type: application/linkset+json + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * + +/.well-known/oauth-authorization-server + Content-Type: application/json + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * + +/.well-known/oauth-protected-resource + Content-Type: application/json + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * + +/.well-known/mcp/server-card.json + Content-Type: application/json + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * + +/.well-known/agent-skills/index.json + Content-Type: application/json + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * + +/auth.md + Content-Type: text/markdown; charset=utf-8 + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * + +/index.md + Content-Type: text/markdown; charset=utf-8 + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * + +/openapi.json + Content-Type: application/json + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * diff --git a/public/auth.md b/public/auth.md new file mode 100644 index 000000000..a958e5ec5 --- /dev/null +++ b/public/auth.md @@ -0,0 +1,64 @@ +# auth.md + +You are an agent. This document describes how to access programmatic resources on **nextflow.io**, the official website for the Nextflow workflow framework. + +Not an agent? See the [Nextflow documentation](https://docs.seqera.io/nextflow/) for human-oriented guides. + +## Public access (no registration required) + +Most resources on nextflow.io are public and do not require authentication: + +- **Documentation** — [https://docs.seqera.io/nextflow/](https://docs.seqera.io/nextflow/) +- **RSS feed** — [https://nextflow.io/feed.xml](https://nextflow.io/feed.xml) +- **API catalog** — [https://nextflow.io/.well-known/api-catalog](https://nextflow.io/.well-known/api-catalog) +- **OpenAPI spec** — [https://nextflow.io/openapi.json](https://nextflow.io/openapi.json) +- **Markdown pages** — send `Accept: text/markdown` to any page URL, or read `https://nextflow.io/index.md` + +## Discovery + +Start with these machine-readable discovery documents: + +1. `GET /.well-known/oauth-protected-resource` — protected resource metadata (RFC 9728) +2. `GET /.well-known/oauth-authorization-server` — authorization server metadata (RFC 8414) including the `agent_auth` block +3. `GET /.well-known/api-catalog` — API catalog (RFC 9727) +4. `GET /.well-known/mcp/server-card.json` — MCP server card +5. `GET /.well-known/agent-skills/index.json` — agent skills index + +## Agent registration + +nextflow.io does not operate protected APIs that require agent credentials today. The registration endpoints below are reserved for future programmatic access: + +| Endpoint | Method | Purpose | +| --- | --- | --- | +| `https://nextflow.io/agent/register` | `POST` | Reserved agent registration endpoint | +| `https://nextflow.io/agent/claim` | `POST` | Reserved claim ceremony endpoint | + +**Supported identity type:** `anonymous` (read-only public resources only). + +**Credential types:** `access_token` (not currently issued). + +If you receive `404` or `501` from a registration endpoint, treat all public resources as unauthenticated read-only access. + +## Using credentials + +When credentials become available, send them on API requests: + +```http +GET /feed.xml HTTP/1.1 +Host: nextflow.io +Authorization: Bearer +``` + +## Errors + +| Status | Meaning | Action | +| --- | --- | --- | +| 401 | Authentication required but not provided | Re-read `/.well-known/oauth-protected-resource` | +| 404 | Endpoint not implemented | Use public read-only resources instead | +| 429 | Rate limited | Exponential backoff, then retry | + +## Related standards + +- [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) — OAuth Authorization Server Metadata +- [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) — OAuth Protected Resource Metadata +- [auth.md protocol](https://github.com/workos/auth.md) diff --git a/public/index.md b/public/index.md new file mode 100644 index 000000000..60b549683 --- /dev/null +++ b/public/index.md @@ -0,0 +1,58 @@ +# Nextflow + +> Reproducible Scientific Workflows at Scale + +Nextflow enables scalable, reproducible, and portable scientific workflows for research and production use cases. + +## Quick links + +- [Documentation](https://docs.seqera.io/nextflow/) +- [Community forum](https://community.seqera.io/tag/nextflow) +- [Training](https://training.nextflow.io/latest/) +- [Examples](/basic-pipeline.html) +- [VS Code extension](https://marketplace.visualstudio.com/items?itemName=nextflow.nextflow) +- [Seqera AI assistant](https://seqera.io/ask-ai/) + +## Features + +### Fast prototyping + +Nextflow allows you to write a computational pipeline by making it simpler to put together many different tasks. You may reuse your existing scripts and tools without learning a new language. + +### Reproducibility + +Nextflow supports [Docker](http://docker.io) and [Singularity](http://singularity.lbl.gov/) containers, with Git integration for versioned, self-contained pipelines. + +### Continuous checkpoints + +Intermediate results are automatically tracked so you can resume from the last successful step. + +### Portable + +Pipelines run on GridEngine, SLURM, LSF, PBS, Kubernetes, AWS, Google Cloud, Azure, and more without code changes. + +### Stream oriented + +Nextflow extends the Unix pipes model with a fluent DSL for complex stream interactions and functional composition. + +### Unified parallelism + +Parallelisation is defined by process input and output declarations; applications scale transparently. + +## Community resources + +- [nf-core](https://nf-co.re/) — curated community pipelines +- [Seqera Pipelines](https://seqera.io/pipelines/) — browse open-source pipelines +- [GitHub issues](https://github.com/nextflow-io/nextflow/issues) — report bugs or request features + +## Discovery + +Machine-readable resources for agents: + +- API catalog: `/.well-known/api-catalog` +- OpenAPI: `/openapi.json` +- MCP server card: `/.well-known/mcp/server-card.json` +- Agent skills: `/.well-known/agent-skills/index.json` +- Auth: `/auth.md` + +Supported by [Seqera](https://seqera.io). diff --git a/public/openapi.json b/public/openapi.json new file mode 100644 index 000000000..33083da0c --- /dev/null +++ b/public/openapi.json @@ -0,0 +1,51 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Nextflow.io Public Endpoints", + "version": "1.0.0", + "description": "Read-only public endpoints exposed by the Nextflow marketing site." + }, + "servers": [ + { + "url": "https://nextflow.io" + } + ], + "paths": { + "/feed.xml": { + "get": { + "summary": "RSS feed", + "description": "Atom/RSS feed of Nextflow blog posts and updates.", + "responses": { + "200": { + "description": "RSS feed document", + "content": { + "application/rss+xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/.well-known/api-catalog": { + "get": { + "summary": "API catalog", + "description": "RFC 9727 linkset describing public programmatic resources.", + "responses": { + "200": { + "description": "API catalog linkset", + "content": { + "application/linkset+json": { + "schema": { + "type": "object" + } + } + } + } + } + } + } + } +} diff --git a/public/robots.txt b/public/robots.txt index 34b8bde68..40e487932 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -12,5 +12,6 @@ Disallow: /misc/ Disallow: /logs/ Disallow: /tests/ Disallow: /releases/ +Content-Signal: ai-train=no, search=yes, ai-input=yes Sitemap: https://nextflow.io/sitemap-index.xml diff --git a/publish.sh b/publish.sh index deb025dcb..de586f381 100755 --- a/publish.sh +++ b/publish.sh @@ -17,4 +17,6 @@ aws s3 sync --delete output/docs s3://www2.nextflow.io/docs \ --storage-class STANDARD \ --acl public-read \ --exclude "$0" \ - "$@" \ No newline at end of file + "$@" + +bash "$(dirname "$0")/scripts/set-s3-agent-metadata.sh" \ No newline at end of file diff --git a/scripts/generate-agent-skills-index.mjs b/scripts/generate-agent-skills-index.mjs new file mode 100644 index 000000000..f8f6c96a0 --- /dev/null +++ b/scripts/generate-agent-skills-index.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Syncs skills from nextflow-io/agent-skills and generates + * /.well-known/agent-skills/index.json with SHA-256 digests. + */ +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, ".."); +const publicDir = join(repoRoot, "public"); +const siteOrigin = "https://nextflow.io"; +const agentSkillsRepo = "https://github.com/nextflow-io/agent-skills.git"; +const cacheDir = join(repoRoot, ".cache", "agent-skills"); +const skillsSourceDir = join(cacheDir, "skills"); +const skillsDestDir = join(publicDir, ".well-known", "agent-skills"); + +function syncAgentSkillsRepo() { + if (!existsSync(cacheDir)) { + execSync(`git clone --depth 1 ${agentSkillsRepo} ${cacheDir}`, { stdio: "inherit" }); + return; + } + + execSync("git fetch origin master && git reset --hard origin/master", { + cwd: cacheDir, + stdio: "inherit", + }); +} + +function parseFrontmatter(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) { + return {}; + } + + const fields = {}; + const lines = match[1].split("\n"); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const fieldMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!fieldMatch) { + i++; + continue; + } + + const key = fieldMatch[1]; + const value = fieldMatch[2]; + + if (value === "|" || value === ">") { + i++; + const block = []; + while (i < lines.length) { + const blockLine = lines[i]; + if (/^[A-Za-z0-9_-]+:\s*/.test(blockLine)) { + break; + } + if (blockLine.startsWith(" ")) { + block.push(blockLine.slice(2)); + } else if (blockLine === "") { + block.push(""); + } else { + break; + } + i++; + } + fields[key] = block.join("\n").trim(); + continue; + } + + fields[key] = value.trim(); + i++; + } + + return fields; +} + +function sha256OfBytes(bytes) { + const hex = createHash("sha256").update(bytes).digest("hex"); + return `sha256:${hex}`; +} + +function copySkills() { + rmSync(skillsDestDir, { recursive: true, force: true }); + mkdirSync(skillsDestDir, { recursive: true }); + + const skillDirs = readdirSync(skillsSourceDir).filter((entry) => + statSync(join(skillsSourceDir, entry)).isDirectory(), + ); + + const skills = []; + + for (const dirName of skillDirs.sort()) { + const sourceFile = join(skillsSourceDir, dirName, "SKILL.md"); + if (!existsSync(sourceFile)) { + continue; + } + + const destDir = join(skillsDestDir, dirName); + mkdirSync(destDir, { recursive: true }); + const destFile = join(destDir, "SKILL.md"); + cpSync(sourceFile, destFile); + + const content = readFileSync(destFile); + const frontmatter = parseFrontmatter(content.toString("utf8")); + const relativePath = `.well-known/agent-skills/${dirName}/SKILL.md`; + + skills.push({ + name: frontmatter.name ?? dirName, + type: "skill-md", + description: frontmatter.description ?? `Nextflow agent skill from nextflow-io/agent-skills (${dirName}).`, + url: `${siteOrigin}/${relativePath}`, + digest: sha256OfBytes(content), + }); + } + + return skills; +} + +syncAgentSkillsRepo(); +const skills = copySkills(); + +const index = { + $schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + source: "https://github.com/nextflow-io/agent-skills", + skills, +}; + +const outputPath = join(skillsDestDir, "index.json"); +writeFileSync(outputPath, `${JSON.stringify(index, null, 2)}\n`); +console.log(`Wrote ${outputPath} with ${skills.length} skill(s) from nextflow-io/agent-skills.`); diff --git a/scripts/set-s3-agent-metadata.sh b/scripts/set-s3-agent-metadata.sh new file mode 100755 index 000000000..a2de01489 --- /dev/null +++ b/scripts/set-s3-agent-metadata.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Set Content-Type metadata on agent-discovery objects after S3 sync. +# Invoked by publish.sh after the main sync completes. + +set -euo pipefail + +BUCKET="${NXF_S3_BUCKET:-www2.nextflow.io}" + +declare -A CONTENT_TYPES=( + [".well-known/api-catalog"]="application/linkset+json" + [".well-known/oauth-authorization-server"]="application/json" + [".well-known/oauth-protected-resource"]="application/json" + [".well-known/jwks.json"]="application/json" + [".well-known/mcp/server-card.json"]="application/json" + [".well-known/agent-skills/index.json"]="application/json" + ["auth.md"]="text/markdown; charset=utf-8" + ["index.md"]="text/markdown; charset=utf-8" + ["openapi.json"]="application/json" +) + +for key in "${!CONTENT_TYPES[@]}"; do + content_type="${CONTENT_TYPES[$key]}" + if aws s3api head-object --bucket "$BUCKET" --key "$key" >/dev/null 2>&1; then + aws s3 cp "s3://${BUCKET}/${key}" "s3://${BUCKET}/${key}" \ + --metadata-directive REPLACE \ + --content-type "$content_type" \ + --acl public-read + echo "Set Content-Type for ${key} -> ${content_type}" + else + echo "Skipping ${key} (not found in bucket)" + fi +done + +while IFS= read -r -d '' skill_file; do + key="${skill_file#output/}" + if aws s3api head-object --bucket "$BUCKET" --key "$key" >/dev/null 2>&1; then + aws s3 cp "s3://${BUCKET}/${key}" "s3://${BUCKET}/${key}" \ + --metadata-directive REPLACE \ + --content-type "text/markdown; charset=utf-8" \ + --acl public-read + echo "Set Content-Type for ${key} -> text/markdown; charset=utf-8" + fi +done < <(find output/.well-known/agent-skills -name 'SKILL.md' -print0 2>/dev/null) + +# Link header on homepage objects +LINK_HEADER='; rel="api-catalog", ; rel="service-doc", ; rel="service-desc"' + +for key in index.html; do + if aws s3api head-object --bucket "$BUCKET" --key "$key" >/dev/null 2>&1; then + aws s3 cp "s3://${BUCKET}/${key}" "s3://${BUCKET}/${key}" \ + --metadata-directive REPLACE \ + --content-type "text/html; charset=utf-8" \ + --metadata "Link=${LINK_HEADER}" \ + --acl public-read + echo "Set Link metadata for ${key}" + fi +done diff --git a/src/components/WebMcpBridge/index.tsx b/src/components/WebMcpBridge/index.tsx new file mode 100644 index 000000000..6c88d1358 --- /dev/null +++ b/src/components/WebMcpBridge/index.tsx @@ -0,0 +1,103 @@ +import { useEffect } from "react"; + +type WebMcpTool = { + name: string; + description: string; + inputSchema: Record; + execute: (input: Record) => Promise; +}; + +const tools: WebMcpTool[] = [ + { + name: "get_documentation_links", + description: "Return canonical Nextflow documentation and training URLs.", + inputSchema: { + type: "object", + properties: {}, + }, + execute: async () => ({ + documentation: "https://docs.seqera.io/nextflow/", + training: "https://training.nextflow.io/latest/", + community: "https://community.seqera.io/tag/nextflow", + examples: "https://nextflow.io/examples.html", + api_catalog: "https://nextflow.io/.well-known/api-catalog", + }), + }, + { + name: "get_examples", + description: "List Nextflow pipeline example pages available on nextflow.io.", + inputSchema: { + type: "object", + properties: {}, + }, + execute: async () => [ + { title: "Basic pipeline", url: "https://nextflow.io/basic-pipeline.html" }, + { title: "Mixing scripting languages", url: "https://nextflow.io/mixing-scripting-languages.html" }, + { title: "BLAST pipeline", url: "https://nextflow.io/blast-pipeline.html" }, + { title: "RNA-seq pipeline", url: "https://nextflow.io/rna-seq-pipeline.html" }, + { title: "Machine learning pipeline", url: "https://nextflow.io/machine-learning-pipeline.html" }, + ], + }, + { + name: "search_site", + description: "Search nextflow.io pages by keyword against known site resources.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search keyword" }, + }, + required: ["query"], + }, + execute: async ({ query }) => { + const q = String(query ?? "").toLowerCase(); + const pages = [ + { title: "Home", url: "https://nextflow.io/", keywords: ["nextflow", "workflow", "pipeline", "reproducible"] }, + { title: "Examples", url: "https://nextflow.io/examples.html", keywords: ["example", "tutorial", "pipeline"] }, + { title: "About", url: "https://nextflow.io/about-us.html", keywords: ["about", "seqera", "team"] }, + { + title: "Basic pipeline", + url: "https://nextflow.io/basic-pipeline.html", + keywords: ["basic", "hello", "world"], + }, + { + title: "Documentation", + url: "https://docs.seqera.io/nextflow/", + keywords: ["docs", "documentation", "reference", "manual"], + }, + { title: "Training", url: "https://training.nextflow.io/latest/", keywords: ["training", "course", "learn"] }, + ]; + return pages.filter( + (page) => + page.title.toLowerCase().includes(q) || + page.keywords.some((keyword) => keyword.includes(q) || q.includes(keyword)), + ); + }, + }, +]; + +export default function WebMcpBridge() { + useEffect(() => { + const modelContext = (navigator as Navigator & { modelContext?: Record }).modelContext; + if (!modelContext) { + return; + } + + const abortController = new AbortController(); + + if (typeof modelContext.provideContext === "function") { + (modelContext.provideContext as (ctx: { tools: WebMcpTool[] }) => void)({ tools }); + } else if (typeof modelContext.registerTool === "function") { + for (const tool of tools) { + (modelContext.registerTool as (tool: WebMcpTool, opts: { signal: AbortSignal }) => void)(tool, { + signal: abortController.signal, + }); + } + } + + return () => { + abortController.abort(); + }; + }, []); + + return null; +} diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 21314bf33..bedf9e8d1 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -4,6 +4,7 @@ import "../styles/focus-styles.css"; import MenuComponent from "@components/Menu/Menu"; import Footer from "@components/Footer.astro"; import CookieBanner from "@components/CookieBanner/index.tsx"; +import WebMcpBridge from "@components/WebMcpBridge/index.tsx"; import { existsSync } from "fs"; import path from "path"; @@ -47,6 +48,11 @@ if (!existsSync(path.join(path.resolve("./public"), image_path))) { + + + + + @@ -122,6 +128,7 @@ if (!existsSync(path.join(path.resolve("./public"), image_path))) { +