Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mcp-agent-ref-fields.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/rights.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -43,7 +48,7 @@ export const rightsRecordSchema: z.ZodType<RightsRecord> = 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() }),
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
62 changes: 62 additions & 0 deletions packages/mcp/src/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> }).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<string, { enum?: string[]; description?: string }> } } } })
.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({
Expand Down
31 changes: 29 additions & 2 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
Expand All @@ -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
Expand Down Expand Up @@ -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"'),
Expand Down
Loading