Skip to content

Commit d3b0a40

Browse files
feat(datasource): import objects from an OData datasource's $metadata, and import several entity sets from Studio and MCP (#27880)
epic:#27261#1 · marchtian · session_017ihL2c8AWH1pFbfa5XHrbg <!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi2j7ywGuG7pQDFSxH9qWMzh)_ **Fable review owed.** Developed on Opus while the Fable quota is out. This PR touches `packages/spec` (the type matrix) and is `Clause-②: yes (widening)`, so it lands only after a full Fable review and a `CONTRACT_REVIEW_TIER` contract review record PASS on the current head. Fixes #27745 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:OData 数据源可以从 `$metadata` 导入对象。EDM 类型经 spec 类型矩阵新增的 `odata` 方言映射为字段类型;PascalCase 属性生成 snake_case 字段名并用 `external.columnMap` 绑定远端名;带 `ReferentialConstraint` 且目标已导入的单值导航属性生成 lookup,其余导航属性不进草稿并在 review 里写明原因。Studio 数据源页可多选实体集一次导入(先导入被引用的目标),只有数据源允许写时才出现「允许写入」勾选,默认只读。MCP 新增 `list_remote_tables` / `import_object`,仅对带 `metadata:write` 的令牌注册,运行时按 REST 同样的两个权限拒绝。 - **为什么改**:epic #27261 P2:不写代码接入的另一半,没有导入,管理员仍要手写对象。 - **风险与回滚**:spec 改动按 PM 裁决(A 方案,修订 6512853588 / 6512860835):类型矩阵 `lookup` / `master_detail` 加入 `text` / `integer` / `bigint` 行的精确集合,Gate 2 对 SQL 联邦也变宽松(以前通过的仍通过)。远端表名首字母大写时对象名从 `_products` 变为 `products`。回滚即 revert 本 PR。 - **先看**:`packages/spec/src/data/type-compat.ts`;`external-datasource-service.ts` 的 `draftLookups`;`ui/.../external/api.ts` 的 `importRemoteTables`。 - **两分钟验证**:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/external-object-draft-odata-metadata.test.ts`,预期 13 个用例全绿:真实 OData 驱动读取录制的 Northwind `$metadata`,草稿、导入、Gate 2 校验和 lookup 读取都走通。 - **需要你做的**:Fable 复审与契约复审 PASS 后落地本 PR。 ## What changes **Type matrix (`packages/spec/src/data/type-compat.ts`).** - `SqlDialect` gains `'odata'`. Its aliases map the EDM primitives: `Edm.String` to text, `Edm.Byte` / `SByte` / `Int16` / `Int32` to integer, `Edm.Int64` to bigint, `Edm.Decimal` to decimal, `Edm.Single` / `Double` to float, `Edm.Boolean`, `Edm.Date` and `Edm.TimeOfDay` to boolean, date and time, `Edm.DateTimeOffset` to datetime, `Edm.Guid` to uuid, `Edm.Binary` / `Stream` to binary, and `Collection(...)` to array (the parameter strip leaves `collection`). - `Edm.Duration`, the geography and geometry families, and a service's own enum or complex types stay `unknown`, so a draft flags them for review. - `lookup` and `master_detail` join the exact sets of the `text`, `integer` and `bigint` rows, as they already sat under `uuid`. A reference stores the target's id, which on a federated object is the remote key a foreign-key column carries. Before this, Gate 2 refused a lookup over `CustomerID int` or a `varchar` key as a `type_mismatch` error. **This loosens Gate 2 for SQL federation too.** Nothing that passed before fails now. - Deviation from the ruling's wording, reported: the ruling said `Edm.Int64` and `Edm.Decimal` would be marked lossy. The matrix decides lossiness per canonical row, and SQL's `bigint` and `numeric` rows mark `number` exact. Making the EDM pair lossy would need new canonical rows that only OData uses, so they take the SQL verdict. That was my misstatement in the option text. - Exhaustive consumers of `SqlDialect`: only the `DIALECTS` list in `type-compat.test.ts` (updated). `ui/` holds no copy (grep for the union's members finds none). **Draft (`packages/services/service-datasource/src/external-datasource-service.ts`).** - `typeVocabulary(dialect)` reads the driver's `odata-4.0` as the `odata` vocabulary. Drafting, `refreshCatalog` and `validateObject` all go through it. - Field names: a column whose name is already a snake_case field name keeps it (the SQL path is unchanged). Otherwise it drafts as its snake_case spelling (`CategoryID` to `category_id`), and `external.columnMap` carries the remote name, which the driver binds through and Gate 2 inverts. `rename` overrides now land in `columnMap` too, so a renamed field stays bound to its column (before, it lost its column and Gate 2 reported it missing). When two columns spell one field name, the later one is numbered and named in review. - Object names go through the same rule. `Products` drafted as `_products` before, because the leading capital was prefixed before lower-casing. - `draftLookups`: a single-valued navigation (`navigationProperties` on the OData driver's introspected table, read structurally) becomes `{ type: 'lookup', reference }` on its `ReferentialConstraint`'s dependent column when all of these hold: - the constraint is a single pair; - the dependent column is in this draft; - the referenced property is the target's single key; - the column's type can back a lookup by the matrix (the verdict Gate 2 reaches); - the target is imported: an object on the same datasource bound to the target entity set, or the object itself (the override name on `importObject`). - Every other navigation is left out and named in `review` (`remoteType` `navigation → ENTITY_SET`) with the reason. A target imported later needs this table imported after it, because re-saving a scalar as a lookup is a retype the save door's destructive-change gate refuses. - The rendered source carries `columnMap`, the lookup's `reference`, review comments keyed by field, and left-out navigations as comments. **Driver (`packages/drivers/driver-odata`).** `introspectSchema()` lists each navigation's `referentialConstraints`. **Docs.** `content/docs/data-modeling/odata-datasources.mdx` §2 said nothing generates fields from `$metadata` (it landed in #27871 while this PR was open). The bullet now names the import (Studio's **Tables** tab, **Import** / **Import selected**, MCP `list_remote_tables` / `import_object`), what the draft does, the lookup rule, and the test that asserts it. **Studio (`ui/packages/app-shell/.../metadata-admin/external`).** - The tables tab selects several entity sets (row and select-all checkboxes) and "Import selected" imports them through `importRemoteTables`. Each round drafts the pending tables and saves the ones whose draft waits on no other pending table. A cycle is saved as drafted. - A batch summary lists what landed and each refusal in the server's words. - The write opt-in shows only when the datasource has `external.allowWrites`. It is off by default, and the single-table dialog has it too, so an imported object is read-only unless the author ticks it. - New `studio` rows in `en` and `zh`. **MCP.** - `@objectstack/mcp` adds `McpFederationBridge` and `registerFederationTools`: `list_remote_tables` (read-only) and `import_object` (a non-destructive write that saves a live object, not a draft, and says so). - Both register only when `grantsAuthoringTools(scopes)` holds (the token names `metadata:write`) and the bridge carries both members. - The SKILL.md names them only for that grant on a host that says it carries them (`federationTools`). - `@objectstack/runtime`'s bridge reaches the `external-datasource` service. It refuses `403 PERMISSION_DENIED` without `FEDERATION_READ_CAPABILITY` / `FEDERATION_WRITE_CAPABILITY`, asked first as the REST routes ask it. It answers `503` with no service. - `@objectstack/rest` exports the two constants. **Changeset**: `patch` on spec, driver-odata, service-datasource, rest, runtime, mcp, `@object-ui/app-shell`, `@object-ui/i18n`. ## Why Epic #27261 P2, 田騏 at 2026-10-29T21:58Z 「现在启动全部 P2」. Tier B is declarative OData (ruling 6486145005). Without an import, an administrator still writes every object by hand. Spec scope per the epic PM's amendments 6512853588 and 6512860835 (option A, both parts). ## Verification Head `14e38cf6`: the feature commit, a test-typing follow-up, a vitest alias that resolves `@objectstack/driver-odata` to its source (the test-source alias gate), a merge of `origin/main` at e320e36, and the guide edit. After `pnpm install` and builds of the touched packages: - `@objectstack/service-datasource`: 64 files, 1041 tests passed, including the new `external-object-draft-odata-metadata.test.ts` (13). It drives the real `ODataDriver.introspectSchema()` (resolved to its source by the vitest alias) over a recorded Northwind `$metadata` (`__fixtures__/northwind-metadata.xml`): EDM type mapping, snake_case fields plus `columnMap`, lookups only onto imported targets (per datasource, self-reference under an override name, composite-key source), left-out navigations named, Gate 2 `validateObject` clean on four imported objects with a negative control, and the driver reading `category_id` and finding the category by it (`Categories?$filter=CategoryID eq 1`). - `@objectstack/driver-odata`: 6 files, 439 passed. `@objectstack/spec` `type-compat.test.ts`: 27 passed. `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts current. - `@objectstack/mcp`: 46 files, 595 passed, including `mcp-federation-tools.test.ts` (registration by grant and bridge, argument forwarding, unknown-key refusal, coded refusal, SKILL.md parity). `@objectstack/runtime` `mcp-federation-bridge.test.ts` (4: the capability asked before the service lookup, 503 without a service) plus the two existing MCP domain files (156). - `ui/`: `metadata-admin/external` and `metadata-admin/previews`, 154 files, 1722 passed, including `SchemaBrowser.batchImport-27745.test.tsx` (7) against a mocked federation service through the real REST client. `@object-ui/app-shell` `type-check` exit 0. - Typecheck: service-datasource, mcp exit 0. runtime `tsc --noEmit` exit 0; its test-layer step reports one error in `organization-ownership-boot-guard.turso-remote.kernel.test.ts` (`@objectstack/driver-turso` not built in this container), a file this PR does not touch. - `eslint` on every changed file: 0 errors. The warnings are the two pre-existing `set-state-in-effect` lines. - Local gates, derived after the merge: `scripts/pm/dispatch-gates.mjs --ran` reports 122 derived, 122 run, all exit 0, 0 NOT-MEASURED. The 7 that first exited 3 (prerequisites: the teardown fixture commit, a built `spec` / `dist`) were rerun after their prerequisites and pass. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2c8AWH1pFbfa5XHrbg --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2c8AWH1pFbfa5XHrbg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d4064cc commit d3b0a40

26 files changed

Lines changed: 1765 additions & 46 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/driver-odata': patch
4+
'@objectstack/service-datasource': patch
5+
'@objectstack/rest': patch
6+
'@objectstack/runtime': patch
7+
'@objectstack/mcp': patch
8+
'@object-ui/app-shell': patch
9+
'@object-ui/i18n': patch
10+
---
11+
12+
feat(datasource): import objects from an OData datasource's `$metadata` — EDM types map to field types, the key to the id, and navigations to lookups; Studio's datasource page imports several entity sets at once, and MCP gains `list_remote_tables` / `import_object`
13+
14+
Clause-②: yes (widening)
15+
Metadata-protocol: none
16+
17+
- **Type matrix (`@objectstack/spec/data`).** `SqlDialect` gains `'odata'`, whose aliases map the EDM primitives (`Edm.String`, `Edm.Int16/32/64`, `Edm.Decimal`, `Edm.Double`, `Edm.Boolean`, `Edm.Date`, `Edm.DateTimeOffset`, `Edm.TimeOfDay`, `Edm.Guid`, `Edm.Binary`, `Edm.Stream`, `Collection(…)`) onto the matrix's canonical types. `Edm.Duration`, the geography / geometry families and a service's own enum or complex types stay unknown, so a draft flags them for review. `Edm.Int64` and `Edm.Decimal` take the same verdict as SQL `bigint` / `numeric` (exact for `number`).
18+
- **Gate 2 gets looser, for SQL federation too.** `lookup` and `master_detail` are now exact under the `text`, `integer` and `bigint` rows, as they already were under `uuid`: a reference stores the target's id, which on a federated object is the remote key a foreign-key column carries. Before, `isCompatible` refused a lookup over `CustomerID int` or `varchar` as a `type_mismatch` error, so the boot validation (`onMismatch: 'fail'`) refused any federated object that declared one. Nothing that passed before fails now.
19+
- **Draft (`@objectstack/service-datasource`).** For an OData datasource (the driver reports dialect `odata-4.0`, read as the `odata` vocabulary) `generateObjectDraft` / `importObject` / `refreshCatalog` and the validation map EDM types through that arm. A column whose name is not a snake_case field name drafts as its snake_case spelling with `external.columnMap` carrying the remote name (`CategoryID` → `category_id`); `rename` overrides now land in `columnMap` too, so a renamed field stays bound to its column. A remote table name drafts the same way (`Products` → `products`; a leading capital used to give `_products`). A single-valued navigation becomes a `lookup` on the column its `ReferentialConstraint` names when the constraint references the target's single key and the target is imported on the same datasource (or is the object itself); every other navigation is left out of the draft and named in `review` with its reason — import the target first.
20+
- **Driver (`@objectstack/driver-odata`).** `introspectSchema()` lists each navigation's `referentialConstraints`.
21+
- **Studio.** The datasource page's tables tab selects several entity sets and imports them in one go, saving lookup targets before the tables that reference them; the write opt-in is offered only on a datasource that allows writes, and an imported object stays read-only otherwise (single-table dialog included).
22+
- **MCP.** `list_remote_tables` and `import_object` reach the same `IExternalDatasourceService` calls, registered only for a token whose scopes name `metadata:write`, and refused by the runtime bridge without the capability the REST twins require (`FEDERATION_READ_CAPABILITY` / `FEDERATION_WRITE_CAPABILITY`, now exported from `@objectstack/rest`).

‎content/docs/data-modeling/odata-datasources.mdx‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,24 @@ What each piece does on this driver:
138138
- **`id` is the entity key.** A single-property key is read as text (`'4711'`). A
139139
composite key is the body of its key predicate (`'OrderID=1,ProductID=7'`), which is
140140
also what `GET /data/<object>/<id>` takes.
141-
- **Declare each field by hand.** Nothing generates fields from `$metadata` today.
142-
Navigation properties are never followed, so a related entity set is an object of its
143-
own.
141+
- **Or import the objects from `$metadata`.** Once the datasource is saved, Studio's
142+
datasource page lists its entity sets on the **Tables** tab. **Import** saves one,
143+
and **Import selected** saves several, referenced entity sets first. An MCP client
144+
whose token carries `metadata:write` reaches the same import through
145+
`list_remote_tables` and `import_object`. The draft maps each EDM type to a field
146+
type, makes the key the `id`, and writes `columnMap` for every property whose name is
147+
not a snake_case field name (`CategoryID` becomes `category_id`). EDM types with no
148+
field type, such as `Edm.Duration` or a service's own enum, are listed for review. An
149+
imported object is read-only unless the datasource allows writes and you tick
150+
**Allow writes to this object**.
151+
- **Navigation properties are never followed**, so a related entity set is an object of
152+
its own. The import turns a single-valued navigation into a `lookup` on its foreign-key
153+
property when its `ReferentialConstraint` is one property pair onto the target's key
154+
and the target is already imported on the same datasource. Every other navigation is
155+
left out and named in the review, so import the target first. The lookup reads the
156+
related record by key, like any other lookup. These rules are what
157+
`packages/services/service-datasource/src/__tests__/external-object-draft-odata-metadata.test.ts`
158+
asserts, over a recorded Northwind `$metadata`.
144159
- **The service document is read lazily.** `connect()` asks the service nothing, and
145160
`$metadata` is read once, at the first request. A wrong `serviceRoot` or an unknown
146161
entity set shows up on the first read of the object, not at boot.
@@ -266,7 +281,7 @@ as a sharing scope. The full mapping is in
266281

267282
- **OData v2.** Only the v4 dialect ships.
268283
- **`$expand` and navigation properties.** Bind each related entity set as an object of
269-
its own.
284+
its own; an imported foreign key becomes a `lookup` onto it.
270285
- **Actions and functions** of the service. `execute` is refused.
271286
- **Transactions and `$batch`.** Each write is one request.
272287
- **More than 1000 matching rows** in one read (the cap above).

‎packages/drivers/driver-odata/src/odata-driver.test.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ describe('keys', () => {
225225
});
226226

227227
describe('introspection', () => {
228-
it('lists entity sets as tables, with EDM types, the key and single-valued navigations as lookup candidates', async () => {
228+
it('lists entity sets as tables, with EDM types, the key, and single-valued navigations with their referential constraints as lookup candidates', async () => {
229229
const { driver } = driverFor([]);
230230
const schema = await driver.introspectSchema();
231231
expect(schema.dialect).toBe('odata-4.0');
@@ -234,8 +234,17 @@ describe('introspection', () => {
234234
expect(people.columns.find((c) => c.name === 'UserName')).toEqual({ name: 'UserName', type: 'Edm.String', nullable: false, primaryKey: true });
235235
expect(people.columns.find((c) => c.name === 'Emails')?.type).toBe('Collection(Edm.String)');
236236
expect(people.navigationProperties).toEqual([
237-
{ name: 'BestFriend', targetEntityType: 'Microsoft.OData.SampleService.Models.TripPin.Person', targetEntitySet: 'People', nullable: true },
238-
{ name: 'Photo', targetEntityType: 'Microsoft.OData.SampleService.Models.TripPin.Photo', targetEntitySet: 'Photos', nullable: true },
237+
{ name: 'BestFriend', targetEntityType: 'Microsoft.OData.SampleService.Models.TripPin.Person', targetEntitySet: 'People', nullable: true, referentialConstraints: [] },
238+
{ name: 'Photo', targetEntityType: 'Microsoft.OData.SampleService.Models.TripPin.Photo', targetEntitySet: 'Photos', nullable: true, referentialConstraints: [] },
239+
]);
240+
expect(schema.tables.Flights!.navigationProperties).toEqual([
241+
{
242+
name: 'Airline',
243+
targetEntityType: 'Microsoft.OData.SampleService.Models.TripPin.Airline',
244+
targetEntitySet: 'Airlines',
245+
nullable: false,
246+
referentialConstraints: [{ property: 'AirlineCode', referencedProperty: 'AirlineCode' }],
247+
},
239248
]);
240249
expect(schema.tables.Flights!.columns.filter((c) => c.primaryKey).map((c) => c.name)).toEqual(['PlanItemId']);
241250
});

‎packages/drivers/driver-odata/src/odata-driver.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,20 @@ export interface ODataDriverRuntime {
7777
export interface ODataIntrospectedTable extends IntrospectedTable {
7878
/** The qualified entity type. */
7979
entityType: string;
80-
/** Single-valued navigation properties: candidates for a lookup field, never followed by reads. */
81-
navigationProperties: Array<{ name: string; targetEntitySet?: string; targetEntityType: string; nullable: boolean }>;
80+
/**
81+
* Single-valued navigation properties: candidates for a lookup field, never
82+
* followed by reads. A lookup needs a column holding the target's key, which
83+
* only a `ReferentialConstraint` names (the dependent property on this type →
84+
* the principal property on the target); a navigation without one has no
85+
* column to back a lookup.
86+
*/
87+
navigationProperties: Array<{
88+
name: string;
89+
targetEntitySet?: string;
90+
targetEntityType: string;
91+
nullable: boolean;
92+
referentialConstraints: Array<{ property: string; referencedProperty: string }>;
93+
}>;
8294
}
8395

8496
export interface ODataIntrospectedSchema extends IntrospectedSchema {
@@ -245,6 +257,7 @@ export class ODataDriver implements IDataDriver {
245257
targetEntityType: nav.type,
246258
...(set.navigationBindings[nav.name] ? { targetEntitySet: set.navigationBindings[nav.name] } : {}),
247259
nullable: nav.nullable,
260+
referentialConstraints: nav.referentialConstraints.map((rc) => ({ ...rc })),
248261
})),
249262
};
250263
}

‎packages/mcp/src/index.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ export type {
2525
McpAuthoringToolRegistry,
2626
McpAuthoringInjection,
2727
} from './types.js';
28-
export { registerObjectTools, registerActionTools, grantsAuthoringTools } from './mcp-http-tools.js';
28+
export {
29+
registerObjectTools,
30+
registerActionTools,
31+
registerFederationTools,
32+
grantsAuthoringTools,
33+
} from './mcp-http-tools.js';
2934
export type {
3035
McpDataBridge,
3136
McpObjectSummary,
@@ -34,6 +39,7 @@ export type {
3439
McpActionSummary,
3540
McpActionParamSummary,
3641
RegisterActionToolsOptions,
42+
McpFederationBridge,
3743
} from './mcp-http-tools.js';
3844
// The portable `SKILL.md` distributable (ADR-0036 Amendment C) — NOT the
3945
// `skill` metadata type; that one is projected onto MCP prompts just below.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27745] The federation pair, `list_remote_tables` and `import_object`: an
5+
* agent lists a federated datasource's entity sets and imports one through the
6+
* same `IExternalDatasourceService` calls Studio's datasource page makes.
7+
*
8+
* Pinned here: the pair registers only for a token whose scopes name
9+
* `metadata:write` (the authoring family's gate, since an import lands object
10+
* metadata) and only on a bridge that carries both members; each call reaches
11+
* the bridge with exactly the caller's arguments; a refusal the bridge throws
12+
* comes back as the coded tool error.
13+
*/
14+
15+
import { describe, it, expect, beforeEach } from 'vitest';
16+
import { MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE } from '@objectstack/spec/ai';
17+
import { MCPServerRuntime } from './mcp-server-runtime.js';
18+
import type { McpDataBridge, McpFederationBridge } from './mcp-http-tools.js';
19+
import { renderSkillMarkdown } from './skill-md.js';
20+
21+
const FEDERATION_TOOLS = ['import_object', 'list_remote_tables'];
22+
23+
function makeBridge(withFederation = true): McpDataBridge & Partial<McpFederationBridge> & { calls: unknown[] } {
24+
const calls: unknown[] = [];
25+
const data: McpDataBridge = {
26+
async listObjects() { return []; },
27+
async describeObject() { return null; },
28+
async query() { return { records: [] }; },
29+
async get() { return null; },
30+
async create() { return {}; },
31+
async update() { return {}; },
32+
async remove() { return {}; },
33+
};
34+
if (!withFederation) return { ...data, calls };
35+
return {
36+
...data,
37+
calls,
38+
async listRemoteTables(datasource, opts) {
39+
calls.push(['listRemoteTables', datasource, opts]);
40+
if (datasource === 'locked') {
41+
throw Object.assign(new Error('Introspecting an external datasource requires the `manage_platform_settings` capability.'), {
42+
code: 'PERMISSION_DENIED',
43+
status: 403,
44+
});
45+
}
46+
return [{ name: 'Products', columnCount: 6 }, { name: 'Categories', columnCount: 4 }];
47+
},
48+
async importObject(datasource, remoteName, opts) {
49+
calls.push(['importObject', datasource, remoteName, opts]);
50+
return {
51+
name: 'products',
52+
definition: { name: 'products', datasource, external: { remoteName } },
53+
review: [],
54+
};
55+
},
56+
};
57+
}
58+
59+
async function rpc(runtime: MCPServerRuntime, bridge: unknown, grantedScopes: string[] | undefined, body: unknown) {
60+
const req = new Request('http://localhost/api/v1/mcp', {
61+
method: 'POST',
62+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
63+
body: JSON.stringify(body),
64+
});
65+
const res = await runtime.handleHttpRequest(req, {
66+
bridge: bridge as McpDataBridge,
67+
parsedBody: body,
68+
...(grantedScopes ? { toolOptions: { grantedScopes } } : {}),
69+
});
70+
return (await res.json()) as any;
71+
}
72+
73+
async function toolNames(runtime: MCPServerRuntime, bridge: unknown, grantedScopes?: string[]): Promise<string[]> {
74+
const json = await rpc(runtime, bridge, grantedScopes, { jsonrpc: '2.0', id: 1, method: 'tools/list' });
75+
return (json.result?.tools ?? []).map((t: { name: string }) => t.name);
76+
}
77+
78+
async function callTool(runtime: MCPServerRuntime, bridge: unknown, name: string, args: Record<string, unknown>) {
79+
const json = await rpc(runtime, bridge, [MCP_OAUTH_SCOPE_METADATA_WRITE], {
80+
jsonrpc: '2.0',
81+
id: 2,
82+
method: 'tools/call',
83+
params: { name, arguments: args },
84+
});
85+
return json.result as { content: Array<{ text: string }>; isError?: boolean };
86+
}
87+
88+
describe('the federation tools register behind metadata:write, on a bridge that carries them', () => {
89+
let runtime: MCPServerRuntime;
90+
beforeEach(() => {
91+
runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' });
92+
});
93+
94+
it('registers both for a token naming metadata:write', async () => {
95+
const names = await toolNames(runtime, makeBridge(), [MCP_OAUTH_SCOPE_METADATA_WRITE]);
96+
expect(names.filter((n) => FEDERATION_TOOLS.includes(n)).sort()).toEqual(FEDERATION_TOOLS);
97+
});
98+
99+
it('registers neither for a key or a session (no scope list), nor for a token without metadata:write', async () => {
100+
for (const scopes of [undefined, [MCP_OAUTH_SCOPE_DATA_READ]]) {
101+
const names = await toolNames(runtime, makeBridge(), scopes);
102+
expect(names.filter((n) => FEDERATION_TOOLS.includes(n)), String(scopes)).toEqual([]);
103+
}
104+
});
105+
106+
it('registers neither on a bridge without the federation members', async () => {
107+
const names = await toolNames(runtime, makeBridge(false), [MCP_OAUTH_SCOPE_METADATA_WRITE]);
108+
expect(names.filter((n) => FEDERATION_TOOLS.includes(n))).toEqual([]);
109+
});
110+
111+
it('annotates the listing read-only and the import as a non-destructive write', async () => {
112+
const json = await rpc(runtime, makeBridge(), [MCP_OAUTH_SCOPE_METADATA_WRITE], { jsonrpc: '2.0', id: 1, method: 'tools/list' });
113+
const byName = new Map((json.result.tools as Array<{ name: string; annotations?: Record<string, unknown> }>).map((t) => [t.name, t]));
114+
expect(byName.get('list_remote_tables')?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false });
115+
expect(byName.get('import_object')?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
116+
});
117+
});
118+
119+
describe('the federation tools reach the bridge with the caller\'s arguments', () => {
120+
let runtime: MCPServerRuntime;
121+
beforeEach(() => {
122+
runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' });
123+
});
124+
125+
it('list_remote_tables answers the bridge\'s tables with their count', async () => {
126+
const bridge = makeBridge();
127+
const result = await callTool(runtime, bridge, 'list_remote_tables', { datasource: 'erp' });
128+
expect(result.isError).toBeFalsy();
129+
expect(JSON.parse(result.content[0]!.text)).toEqual({
130+
tables: [{ name: 'Products', columnCount: 6 }, { name: 'Categories', columnCount: 4 }],
131+
totalCount: 2,
132+
});
133+
expect(bridge.calls).toEqual([['listRemoteTables', 'erp', {}]]);
134+
});
135+
136+
it('import_object forwards the name override and the write opt-in, and nothing it was not sent', async () => {
137+
const bridge = makeBridge();
138+
await callTool(runtime, bridge, 'import_object', { datasource: 'erp', remoteName: 'Products' });
139+
await callTool(runtime, bridge, 'import_object', {
140+
datasource: 'erp',
141+
remoteName: 'Products',
142+
name: 'erp_products',
143+
writable: true,
144+
});
145+
expect(bridge.calls).toEqual([
146+
['importObject', 'erp', 'Products', {}],
147+
['importObject', 'erp', 'Products', { name: 'erp_products', writable: true }],
148+
]);
149+
});
150+
151+
it('refuses an unknown argument by name instead of dropping it', async () => {
152+
const bridge = makeBridge();
153+
const result = await callTool(runtime, bridge, 'import_object', { datasource: 'erp', entitySet: 'Products' });
154+
expect(result.isError).toBe(true);
155+
expect(result.content[0]!.text).toContain('remoteName');
156+
expect(bridge.calls).toEqual([]);
157+
});
158+
159+
it('answers a refusal the bridge throws as the coded tool error', async () => {
160+
const result = await callTool(runtime, makeBridge(), 'list_remote_tables', { datasource: 'locked' });
161+
expect(result.isError).toBe(true);
162+
expect(JSON.parse(result.content[0]!.text).error).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
163+
});
164+
});
165+
166+
describe('the generated SKILL.md teaches the pair exactly where it registers', () => {
167+
it('names both for a metadata:write grant on a host that carries them, and neither otherwise', () => {
168+
const granted = renderSkillMarkdown({ grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE], federationTools: true });
169+
for (const name of FEDERATION_TOOLS) expect(granted).toContain(`**${name}(`);
170+
for (const options of [
171+
{ federationTools: true },
172+
{ grantedScopes: [MCP_OAUTH_SCOPE_DATA_READ], federationTools: true },
173+
{ grantedScopes: [MCP_OAUTH_SCOPE_METADATA_WRITE] },
174+
]) {
175+
const skill = renderSkillMarkdown(options);
176+
for (const name of FEDERATION_TOOLS) expect(skill).not.toContain(name);
177+
}
178+
});
179+
});

0 commit comments

Comments
 (0)