From 35665eca3a71a5c47600c18defeef4318307e8a2 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Mon, 12 Oct 2026 22:20:29 +0800 Subject: [PATCH] feat(mcp): give agents description, tags, author, relevance and rehostPolicy search_references projected only id/title/license/links, so an agent could not judge a match or tell whether it may store a result's media. Add the source description (capped at 280), the first 8 tags, the author, the cross-source relevance and rights.rehostPolicy, with each policy value explained in the output schema and tool description. sourceScore stays out: it is per-source and not comparable. Core exports REHOST_POLICIES and uses it in its own schema. --- .changeset/mcp-agent-ref-fields.md | 6 +++ README.md | 2 +- packages/core/README.md | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/rights.ts | 9 +++- packages/mcp/README.md | 2 +- packages/mcp/src/__tests__/mcp.test.ts | 62 ++++++++++++++++++++++++++ packages/mcp/src/index.ts | 31 ++++++++++++- 8 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 .changeset/mcp-agent-ref-fields.md diff --git a/.changeset/mcp-agent-ref-fields.md b/.changeset/mcp-agent-ref-fields.md new file mode 100644 index 0000000..3e9b248 --- /dev/null +++ b/.changeset/mcp-agent-ref-fields.md @@ -0,0 +1,6 @@ +--- +'@refkit/core': minor +'@refkit/mcp': minor +--- + +`search_references` results now carry what an agent needs to judge a match and handle the file: the source's `description` (capped at 280 characters), its first 8 `tags`, the `author`, the 0..1 cross-source `relevance`, and `rights.rehostPolicy`. The output schema and the tool description explain each `rehostPolicy` value, so agents know whether they may download, cache or only hotlink a result's media. The per-source `sourceScore` stays out because it is not comparable across sources. Core exports the policy values as `REHOST_POLICIES`, which its own schema now uses as well. diff --git a/README.md b/README.md index 5b32d82..082e188 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,7 @@ A provider's `search` returns `EmittedReference[]` — only what the **source** - **Zero network in `core`** — no `fetch` call, no hard-coded endpoint. Hosts inject `ProviderContext.fetch`. - **Core stamps its own fields** — `id`, `source`, `canonicalUrl`, `verifiedAt`, `relevance` and the `limit` come from core, never from a provider (`EmittedReference` → `Reference`). - **Facts, not labels** — the use-gate, the merge's conflict resolution, attribution and the license boost all read license **facts** via `factsOf(rights)`: `LicenseId` is an open string, and a source whose terms are narrower than the label it declares ships its own `rights.facts` row. An id with no known facts resolves to the `unknown` row, which grants nothing. -- **No re-hosting** — keep `canonicalUrl` + thumbnails only; never store originals. `rights.rehostPolicy` records what the source permits; it is host-facing metadata that core validates and passes through but never acts on — enforcing it is the host binding's job. +- **No re-hosting** — keep `canonicalUrl` + thumbnails only; never store originals. `rights.rehostPolicy` records what the source permits (`REHOST_POLICIES`); core validates and passes it through but never acts on it. Enforcing it is the host binding's job, and `@refkit/mcp` hands it to agents on every result with what each value allows. - **strict-deny** — when rights can't be determined, deny / needs-review (never fail-open). Unknown, NonCommercial and "no known copyright restrictions" never map to a commercially usable license; NoDerivatives allows verbatim commercial reuse (with attribution) but never derivative/AI use. ## Agent usage diff --git a/packages/core/README.md b/packages/core/README.md index 901bc43..cca8738 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -203,7 +203,7 @@ The hook compares `Reference` objects only. Core still never fetches, decodes, o - **Zero network** — no `fetch` call, no hard-coded endpoint in this package. Hosts inject `ProviderContext.fetch`. - **Substrate-agnostic** — no import of any host or orchestration framework. - **Only `zod`** as a non-relative dependency. -- **No re-hosting** — keep `canonicalUrl` + thumbnails only; never store originals. `rights.rehostPolicy` records what the source permits and is validated on every record, but core never acts on it: it is metadata for the host binding to honour. +- **No re-hosting** — keep `canonicalUrl` + thumbnails only; never store originals. `rights.rehostPolicy` records what the source permits (`REHOST_POLICIES`) and is validated on every record, but core never acts on it: it is metadata for the host binding to honour, and `@refkit/mcp` passes it to agents on every result. - **Core stamps its own fields** — `id`, `source`, `canonicalUrl`, `verifiedAt`, `relevance` and the per-provider `limit` come from core, never from a provider. - **strict-deny** — when rights can't be determined, deny / `needs-review` (never fail-open). Unknown, NonCommercial, NoDerivatives, and "no known copyright restrictions" never map to a usable license. Indeterminate facts (all three axes `'unknown'`) can never be allowed, and a cross-source conflict whose claims are incomparable resolves to `unknown`. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2926d57..59c53ac 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,7 +7,7 @@ export { } from './license' export type { LicenseId, KnownLicenseId, LicenseFacts, Tri } from './license' export type { RehostPolicy, RightsRecord } from './rights' -export { rightsRecordSchema, licenseFactsSchema, factsOf } from './rights' +export { REHOST_POLICIES, rightsRecordSchema, licenseFactsSchema, factsOf } from './rights' export { buildAttribution } from './attribution' export type { Attribution, AttributionInput } from './attribution' export type { diff --git a/packages/core/src/rights.ts b/packages/core/src/rights.ts index 97e1d90..2a3c23c 100644 --- a/packages/core/src/rights.ts +++ b/packages/core/src/rights.ts @@ -1,7 +1,12 @@ import { z } from 'zod' import { CC_VERSIONED_FAMILIES, factsFor, type LicenseFacts, type LicenseId, type Tri } from './license' -export type RehostPolicy = 'hotlink-required' | 'cache-allowed' | 'thumbnail-only' | 'no-store' +/** What the source lets a host do with the media file. Hosts (and, through @refkit/mcp, + * agents) read it before storing anything: hotlink-required = link to the source, never + * copy the file; cache-allowed = may download and cache it; thumbnail-only = may store the + * thumbnail but not the full file; no-store = store nothing, not even the thumbnail. */ +export const REHOST_POLICIES = ['hotlink-required', 'cache-allowed', 'thumbnail-only', 'no-store'] as const +export type RehostPolicy = typeof REHOST_POLICIES[number] // What a satellite emits per result. Permissions are derived from `license` via // factsFor() unless the record ships its own `facts` (required for ids outside @@ -43,7 +48,7 @@ export const rightsRecordSchema: z.ZodType = z.object({ facts: licenseFactsSchema.optional(), licenseVersion: z.string().optional(), author: z.string().optional(), - rehostPolicy: z.enum(['hotlink-required', 'cache-allowed', 'thumbnail-only', 'no-store']), + rehostPolicy: z.enum(REHOST_POLICIES), jurisdiction: z.string().optional(), editorialOnly: z.boolean().optional(), raw: z.object({ sourceTerms: z.string(), sourceUrl: z.string() }), diff --git a/packages/mcp/README.md b/packages/mcp/README.md index bcfab71..db60ec2 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -72,7 +72,7 @@ Input: `{ query, modalities?, sources?, controls?, providerOptions?, limit?, cur } ``` -Output: `{ references: [{ id, title?, modality, kind?, provider, canonicalUrl, license, thumbnail?, excerpt?, useVerdict?, useExplanation?, attribution? }], nextCursor?, meta? }`. When `intent` (or `gateFor`) is set, each result carries `useVerdict { decision, reason, confidence }`, a plain `useExplanation`, and — if the license requires it — a ready-to-use `attribution` credit line. When `explain: true`, `meta` includes per-provider `fulfilled` / `failed` / `skipped` status, applied/ignored control details, warnings, and gate/threshold-drop counts. The `meta` shape comes from core's own `searchMetaSchema` — this server keeps no copy of the vocabulary. +Output: `{ references: [{ id, title?, description?, tags?, modality, kind?, provider, canonicalUrl, license, author?, rehostPolicy, relevance, thumbnail?, excerpt?, useVerdict?, useExplanation?, attribution? }], nextCursor?, meta? }`. `description` is capped at 280 characters and `tags` at the first 8, to keep agent context small. `relevance` is the 0..1 cross-source score after fusion and rerank; the per-source `sourceScore` is left out because it is not comparable across sources. `rehostPolicy` tells the agent what it may do with the media before storing anything: `hotlink-required` (link only), `cache-allowed`, `thumbnail-only` or `no-store`. When `intent` (or `gateFor`) is set, each result carries `useVerdict { decision, reason, confidence }`, a plain `useExplanation`, and — if the license requires it — a ready-to-use `attribution` credit line. When `explain: true`, `meta` includes per-provider `fulfilled` / `failed` / `skipped` status, applied/ignored control details, warnings, and gate/threshold-drop counts. The `meta` shape comes from core's own `searchMetaSchema` — this server keeps no copy of the vocabulary. > Results are references with a license id + source link — **not rights clearance, not legal advice**. `unknown` / `needs-review` results require the caller to verify the source's terms. diff --git a/packages/mcp/src/__tests__/mcp.test.ts b/packages/mcp/src/__tests__/mcp.test.ts index d0ae7c4..d60b4b6 100644 --- a/packages/mcp/src/__tests__/mcp.test.ts +++ b/packages/mcp/src/__tests__/mcp.test.ts @@ -249,6 +249,68 @@ describe('@refkit/mcp', () => { await client.close() }) + it('projects description, tags, author, relevance and rehostPolicy for agents (not the per-source sourceScore)', async () => { + const long = `Lighthouse on a rocky cape at dusk, ${'with long exposure waves '.repeat(20)}`.trim() + const tags = ['lighthouse', 'coast', 'dusk', 'sea', 'rocks', 'waves', 'beacon', 'cape', 'night', 'tower'] + const provider = defineProvider({ + id: 'p', + modalities: ['image'], + search: async () => [ + { + modality: 'image' as const, + title: 'Cape light', + description: long, + tags, + sourceScore: 42, + sourceUrl: 'https://p/1', + rights: { license: 'CC-BY-4.0', author: 'Ann', rehostPolicy: 'hotlink-required' as const, raw: { sourceTerms: 'terms', sourceUrl: 'https://p/1' } }, + }, + { + modality: 'image' as const, + title: 'Harbour light', + description: 'Short caption.', + sourceUrl: 'https://p/2', + rights: { license: 'CC0-1.0', rehostPolicy: 'cache-allowed' as const, raw: { sourceTerms: 'terms', sourceUrl: 'https://p/2' } }, + }, + ], + }) + const server = createRefkitMcpServer(createRefkit({ providers: [provider], resilience: false })) + const [clientT, serverT] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test', version: '1.0.0' }) + await Promise.all([client.connect(clientT), server.connect(serverT)]) + const res = await client.callTool({ name: 'search_references', arguments: { query: 'lighthouse', modalities: ['image'] } }) + const refs = (res.structuredContent as { references: Array> }).references + const cape = refs.find(r => r.title === 'Cape light')! + const harbour = refs.find(r => r.title === 'Harbour light')! + expect(typeof cape.description).toBe('string') + expect((cape.description as string).length).toBeLessThanOrEqual(280) + expect(cape.description as string).toMatch(/^Lighthouse on a rocky cape at dusk, with long exposure waves/) + expect(cape.description as string).toMatch(/…$/) + expect(harbour.description).toBe('Short caption.') + expect(cape.tags).toEqual(tags.slice(0, 8)) + expect(harbour.tags).toBeUndefined() + expect(cape.author).toBe('Ann') + expect(cape.rehostPolicy).toBe('hotlink-required') + expect(harbour.rehostPolicy).toBe('cache-allowed') + expect(typeof cape.relevance).toBe('number') + expect(cape.relevance as number).toBeGreaterThan(0) + expect(cape.relevance as number).toBeLessThanOrEqual(1) + expect(cape).not.toHaveProperty('sourceScore') + await client.close() + }) + + it('tells agents what rehostPolicy means, in the tool description and the output schema', async () => { + const client = await connectedClient() + const { tools } = await client.listTools() + const search = tools.find(t => t.name === 'search_references')! + expect(search.description).toMatch(/rehostPolicy/) + const items = (search.outputSchema as unknown as { properties: { references: { items: { properties: Record } } } }) + .properties.references.items.properties + expect(items.rehostPolicy.enum).toEqual(['hotlink-required', 'cache-allowed', 'thumbnail-only', 'no-store']) + for (const value of ['hotlink-required', 'cache-allowed', 'thumbnail-only', 'no-store']) expect(items.rehostPolicy.description).toContain(value) + await client.close() + }) + it('reranks by default and passes rerank:false through as raw fusion order', async () => { const titles = ['Something else', 'A lion'] const provider = defineProvider({ diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 09f628f..5b5518e 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' -import { LICENSE_IDS, INTENTS, evaluateUse, buildAttribution, ccVersionFor, buildSearchControlsSchema, searchMetaSchema } from '@refkit/core' +import { LICENSE_IDS, INTENTS, REHOST_POLICIES, evaluateUse, buildAttribution, ccVersionFor, buildSearchControlsSchema, searchMetaSchema } from '@refkit/core' import type { RefkitClient, Reference, Verdict, Attribution, SearchControls, SearchInput, ProviderOptionsById, RightsRecord, Modality } from '@refkit/core' // Legacy media.kind control values — kept in the dynamic enum because they stay @@ -23,18 +23,35 @@ const VERSION: string = (() => { } })() +// Agent context is the scarce resource: descriptions are capped and tags trimmed to the +// leading few (sources list the most specific subjects first). +const AGENT_DESCRIPTION_MAX = 280 +const AGENT_TAGS_MAX = 8 + +function capText(text: string | undefined, max: number): string | undefined { + if (text === undefined || text.length <= max) return text + return `${text.slice(0, max - 1).trimEnd()}…` +} + // Concise, agent-facing projection of a Reference (no raw provider dump). When an // intent is supplied the use-gate verdict + attribution ride along, so the agent // sees *whether it may use* each result — not just a bare license id it ignores. +// `sourceScore` stays out on purpose: it is in each source's own scale and only orders +// results within one source; `relevance` is the cross-source score an agent can compare. function toAgentRef(r: Reference, assessment?: { verdict: Verdict; attribution: Attribution }) { const base = { id: r.id, title: r.title, + description: capText(r.description, AGENT_DESCRIPTION_MAX), + tags: r.tags && r.tags.length > 0 ? r.tags.slice(0, AGENT_TAGS_MAX) : undefined, modality: r.modality, kind: r.kind, provider: r.source.providerId, canonicalUrl: r.canonicalUrl, license: r.rights.license, + author: r.rights.author, + rehostPolicy: r.rights.rehostPolicy, + relevance: Math.round(r.relevance * 1000) / 1000, thumbnail: r.thumbnail?.url, excerpt: r.text?.excerpt, } @@ -53,11 +70,20 @@ function toAgentRef(r: Reference, assessment?: { verdict: Verdict; attribution: const agentRefSchema = z.object({ id: z.string(), title: z.string().optional(), + description: z.string().optional().describe(`the source's own caption or description, capped at ${AGENT_DESCRIPTION_MAX} characters`), + tags: z.array(z.string()).optional().describe(`the source's subjects or tags, first ${AGENT_TAGS_MAX}`), modality: z.string(), kind: z.string().optional().describe('fine-grained resource kind, e.g. photo / texture / ebook'), provider: z.string(), canonicalUrl: z.string(), license: z.string(), + author: z.string().optional().describe('creator as declared by the source'), + rehostPolicy: z.enum(REHOST_POLICIES).describe( + 'what the source lets you do with the media file — respect it before downloading or storing anything: ' + + 'hotlink-required = link to canonicalUrl, never copy the file; cache-allowed = you may download and cache it; ' + + 'thumbnail-only = you may store the thumbnail but not the full file; no-store = store nothing, not even the thumbnail', + ), + relevance: z.number().describe('0..1 cross-source relevance after fusion/rerank; compare results by this'), thumbnail: z.string().optional(), excerpt: z.string().optional(), useVerdict: z @@ -94,7 +120,8 @@ export function createRefkitMcpServer(refkit: RefkitClient): McpServer { 'Search license-normalized reference material (image / video / audio / text) across the configured sources. ' + 'Every result carries a license id + canonical source link. Pass `intent` to annotate each result with a ' + 'use-verdict (may I use this, is attribution required) WITHOUT filtering; pass `gateFor` to instead return ' + - 'only results whose license allows that intent. Results are references, not rights clearance — not legal advice.' + + 'only results whose license allows that intent. Results are references, not rights clearance — not legal advice. ' + + 'Before downloading or storing a result\'s media, follow its `rehostPolicy` (some sources only allow hotlinking or thumbnails).' + '\n\nConfigured sources:\n' + sourceList, inputSchema: { query: z.string().describe('what to search for, e.g. "cyberpunk alley at night"'),