Skip to content
Draft
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
31 changes: 26 additions & 5 deletions components/mcp/tools/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@ interface ToolAnnotationsLike {
openWorldHint?: boolean;
}

type ResourcesRegistry = Map<string, ResourceRegistryEntry>;
/** A compiled parameterised route (e.g. `/widget/:id`), stored outside the base Map. */
interface ParamRouteEntry {
pattern: string;
entry: ResourceRegistryEntry;
}
type ResourcesRegistry = Map<string, ResourceRegistryEntry> & { paramRoutes?: ParamRouteEntry[] };
type RequestTargetCtor = new () => Record<string, unknown> & {
conditions?: unknown[];
operator?: string;
Expand Down Expand Up @@ -887,21 +892,23 @@ function buildApplicationTools(resources: ResourcesRegistry): void {
const claimedSuffixes = new Set<string>();
let toolsRegistered = 0;
let resourcesConsidered = 0;
for (const [path, entry] of resources) {

const considerEntry = (path: string, entry: ResourceRegistryEntry | undefined): void => {
if (!entry) return;
resourcesConsidered++;
if (!shouldEnumerate(entry)) continue;
if (!shouldEnumerate(entry)) return;
const ResourceClass = entry.Resource;
// @hidden type-level: suppress the Resource from MCP tool listing entirely.
// Data remains accessible via direct query/RBAC; only descriptive surfaces drop it.
if (ResourceClass?.hidden === true) {
harperLogger.trace(`MCP application: '/${path}' suppressed from tool listing (@hidden)`);
continue;
return;
}
const verbs = detectVerbs(ResourceClass);
const hasVerbs = verbs.get || verbs.search || verbs.create || verbs.updatePut || verbs.updatePatch || verbs.delete;
const hasCustomTools = Array.isArray(ResourceClass?.mcpTools) && ResourceClass.mcpTools.length > 0;
const hasCustomPrompts = Array.isArray(ResourceClass?.mcpPrompts) && ResourceClass.mcpPrompts.length > 0;
if (!hasVerbs && !hasCustomTools && !hasCustomPrompts) continue;
if (!hasVerbs && !hasCustomTools && !hasCustomPrompts) return;
const databaseName = ResourceClass?.databaseName;
const tableName = ResourceClass?.tableName;
const suffix = uniqueSuffix(path, databaseName, claimedSuffixes);
Expand All @@ -920,7 +927,21 @@ function buildApplicationTools(resources: ResourcesRegistry): void {
}
toolsRegistered += registerCustomMcpTools(ResourceClass, path);
registerCustomMcpPrompts(ResourceClass, path);
};

for (const [path, entry] of resources) considerEntry(path, entry);

// Parameterised routes (e.g. `/widget/:id`) live OUTSIDE the base Map: Resources.setParamRoute
// stores them in `paramRoutes` and returns before the Map insert, so the loop above never sees
// them. Without this, a custom resource declaring `static path = '/widget/:id'` produces ZERO MCP
// tools — even though it appears in the OpenAPI document, which already iterates `paramRoutes`.
// Enumerate them so the tool surface matches the REST surface. The common single-`:id` case binds
// via `target.id` in the verb handlers; richer multi-segment/named-wildcard binding rides on the
// per-method request contract (RFC 0001, Pillar 2).
for (const route of resources.paramRoutes ?? []) {
considerEntry(route.pattern, route.entry);
}

harperLogger.info(
`MCP application profile: considered ${resourcesConsidered} resource(s), registered ${toolsRegistered} tool(s)`
);
Expand Down
347 changes: 347 additions & 0 deletions docs/rfcs/0001-typed-discoverable-resources.md

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm curious about the intended future of the PR. Is 0001-typed-discoverable-resources.md a transient component of the PR? We have intentionally been putting proposals in GH issues (and several much larger than this) rather than PRs to avoid transient information in the repo. However, implementation plans as a transient artifacts of a PR in progress is something we have occasionally done.

It was purely because PRs have threads, separate files, and syntax highlighting. All of which feel very helpful for the conversation. I don't think the RFC would actually land in the repo, since over time it'll become inaccurate. The 0001 was Claude being optimistic.

Large diffs are not rendered by default.

330 changes: 330 additions & 0 deletions docs/rfcs/spikes/0001/canonical-track.spike.ts

Large diffs are not rendered by default.

183 changes: 183 additions & 0 deletions docs/rfcs/spikes/0001/defineTable-real.check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* RFC 0001 — canonical-track contract, enforced against the REAL `defineTable` implementation.
*
* canonical-track.spike.ts proved the type mechanics on a self-contained stub; this file asserts
* the shipped module (`resources/defineTable.ts`, via the built package types) satisfies the same
* contract: the `$record`/`$insert`/`$upsert`/`$patch`/`$query` projections, getter-style field
* flags, and the negative cases. It imports the build output so this strict spike tsconfig checks
* only the declaration surface (skipLibCheck), not the whole repo graph.
*
* Verify (after `npm run build`):
* npx tsc --noEmit --project docs/rfcs/spikes/0001/tsconfig.json
* A green run IS the proof; `@ts-expect-error` lines prove the negative cases.
*/

/* eslint-disable @typescript-eslint/no-unused-vars */

import { defineTable, types } from '../../../../dist/index.js';

const { id, int, string, date } = types;

// NOTE: defineTable registers eagerly, so this module is type-check-only (never executed) —
// exactly like the spike, whose runtime lived in the registration test.
const Track = defineTable('Track', {
id: id.primaryKey,
name: string.indexed,
duration: int.nullable,
status: types.enum(['draft', 'published']).indexed,
createdTime: date.createdTime,
});

// `Track` stays the central type; variants are derived off it as members.
type Track = InstanceType<typeof Track>;
type TrackRecord = (typeof Track)['$record'];
type InsertTrack = (typeof Track)['$insert'];
type UpsertTrack = (typeof Track)['$upsert'];
type PatchTrack = (typeof Track)['$patch'];
type TrackQuery = (typeof Track)['$query'];

// ── Type-level assertions (green tsc == proof) ──────────────────────────────
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type Expect<T extends true> = T;

// The read-only variant: every field readonly, server-managed present.
type _record = Expect<
Equal<
TrackRecord,
{
readonly id: string;
readonly name: string;
readonly status: 'draft' | 'published';
readonly createdTime: Date;
readonly duration?: number;
}
>
>;
type _insert = Expect<
Equal<
InsertTrack,
{ name: string; status: 'draft' | 'published'; id?: string; duration?: number } // PK optional, server field gone
>
>;
type _upsert = Expect<
Equal<
UpsertTrack,
{ id: string; name: string; status: 'draft' | 'published'; duration?: number } // PK required
>
>;
type _patch = Expect<
Equal<PatchTrack, { name?: string; status?: 'draft' | 'published'; duration?: number }> // all writable optional, no PK
>;
type _query = Expect<
Equal<TrackQuery, { name?: string; status?: 'draft' | 'published' }> // indexed fields only (id/duration excluded)
>;

// Relations: to-one resolves to the related read record (readonly), to-many to an array; both
// are dropped from every write projection. A to-one relation's FK must be DECLARED in the shape
// (like GraphQL), which makes it typed, queryable, and writable — the relation itself is not.
const Album = defineTable('Album', {
id: id.primaryKey,
title: string,
tracks: types.hasMany(() => Track, { to: 'albumId' }),
});
type AlbumRecord = (typeof Album)['$record'];
type _album_record = Expect<
Equal<AlbumRecord, { readonly id: string; readonly title: string; readonly tracks: TrackRecord[] }>
>;
type _album_insert = Expect<Equal<(typeof Album)['$insert'], { title: string; id?: string }>>;

const Song = defineTable('Song', {
id: id.primaryKey,
title: string,
albumId: id.indexed, // declared FK — typed and writable
album: types.relation(() => Album, { from: 'albumId' }),
});
type _song_record = Expect<
Equal<
(typeof Song)['$record'],
{ readonly id: string; readonly title: string; readonly albumId: string; readonly album: AlbumRecord }
>
>;
// the declared FK is insertable/patchable; the relation projection is not
type _song_insert = Expect<Equal<(typeof Song)['$insert'], { title: string; albumId: string; id?: string }>>;
type _song_query = Expect<Equal<(typeof Song)['$query'], { albumId?: string }>>;

// ── Mutual pair (Book <-> Author): relationOf/hasManyOf break the const-initializer cycle ──
//
// Using plain `relation`/`hasMany` on BOTH sides of a mutual pair does NOT compile — TS's
// const-initializer inference is eager, so `typeof Book` and `typeof Author` would each depend on
// the other and both collapse to `any` (TS7022: "'Book' implicitly has type 'any' because it does
// not have a type annotation and is referenced directly or indirectly in its own initializer").
// Verified empirically: swap `hasManyOf<BookRecord>` below for plain `hasMany` and tsc fails with
// exactly that error on both `Book` and `Author`.
//
// The fix: declare the related record shape by hand on whichever side closes the cycle, and use
// `hasManyOf<R>`/`relationOf<R>` there — its `target` param is typed `() => any`, so no inference
// is pulled from the argument and the cycle never forms. The OTHER side keeps plain
// `relation`/`hasMany` with full inference (Book's `author` field below is fully inferred).
interface BookRecord {
readonly id: string;
readonly title: string;
readonly authorId: string;
}

const Book = defineTable('Book', {
id: id.primaryKey,
title: string,
authorId: id.indexed, // declared FK, required for the to-one relation
author: types.relation(() => Author, { from: 'authorId' }), // plain relation — fully inferred
});
const Author = defineTable('Author', {
id: id.primaryKey,
name: string,
books: types.hasManyOf<BookRecord>(() => Book, { to: 'authorId' }), // escape hatch closes the cycle
});

declare const book: (typeof Book)['$record'];
declare const author: (typeof Author)['$record'];
const authorName: string = book.author.name; // Book -> Author: fully inferred, typo-checked
const firstBookTitle: string = author.books[0].title; // Author -> Book: typed via the explicit BookRecord

// The escape hatch doesn't weaken the write-side guarantees: relations stay read-only projections.
// @ts-expect-error relations are not writable, even through the escape hatch
const bad_author_insert: (typeof Author)['$insert'] = { name: 'x', books: [] };

// Verbs are typed by the projections (await-friendly MaybePromise results).
async function verbs() {
const ro = await Track.get('DtMF');
if (ro) {
ro.name.toUpperCase(); // read ok
ro.createdTime.getFullYear(); // server field present on reads
}
await Track.post({ name: 'Intro', status: 'draft' }); // id generated, createdTime server-set
await Track.put({ id: 'DtMF', name: 'Intro', status: 'published' }); // PK required for replace
await Track.patch('DtMF', { status: 'published' }); // partial
const live = await Track.update('DtMF', { duration: 75 }); // live instance
live.name = 'Renamed'; // writable field — mutable
}

// Explicit-typed forms, to show the projections stand alone too:
const toInsert: InsertTrack = { name: 'New Album', status: 'draft', duration: 50 };
const toReplace: UpsertTrack = { id: 'DtMF', name: 'New Album', status: 'draft' };
const toPatch: PatchTrack = { duration: 75 };
const filter: TrackQuery = { name: 'Intro' };

// ── Negative cases — each MUST error, or tsc fails ──────────────────────────
declare const ro: TrackRecord;
declare const live: Track;
// @ts-expect-error the read-only variant cannot be mutated
ro.name = 'nope';
// @ts-expect-error server-managed field is readonly even on the live instance
live.createdTime = new Date();
// @ts-expect-error server-managed field is not insertable
const bad_insert: InsertTrack = { name: 'x', status: 'draft', createdTime: new Date() };
// @ts-expect-error upsert requires the primary key
const bad_upsert: UpsertTrack = { name: 'x', status: 'draft' };
// @ts-expect-error 'archived' is not in the enum
const bad_enum: PatchTrack = { status: 'archived' };
// @ts-expect-error duration is not indexed, so it is not a query key
const bad_query: TrackQuery = { duration: 50 };
// @ts-expect-error relations are not writable
const bad_album: (typeof Album)['$insert'] = { title: 'x', tracks: [] };

export { Track, Album, Book, Author, toInsert, toReplace, toPatch, filter, verbs, authorName, firstBookTitle };
Loading