ont-run 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -114,6 +114,8 @@ export function diffOntology(
114
114
  const fieldReferencesChanged =
115
115
  JSON.stringify(oldFn.fieldReferences) !==
116
116
  JSON.stringify(newFn.fieldReferences);
117
+ const userContextChanged =
118
+ !!oldFn.usesUserContext !== !!newFn.usesUserContext;
117
119
 
118
120
  if (
119
121
  accessChanged ||
@@ -121,7 +123,8 @@ export function diffOntology(
121
123
  inputsChanged ||
122
124
  outputsChanged ||
123
125
  entitiesChanged ||
124
- fieldReferencesChanged
126
+ fieldReferencesChanged ||
127
+ userContextChanged
125
128
  ) {
126
129
  functions.push({
127
130
  name,
@@ -136,6 +139,8 @@ export function diffOntology(
136
139
  oldEntities: entitiesChanged ? oldFn.entities : undefined,
137
140
  newEntities: entitiesChanged ? newFn.entities : undefined,
138
141
  fieldReferencesChanged: fieldReferencesChanged || undefined,
142
+ userContextChanged: userContextChanged || undefined,
143
+ usesUserContext: userContextChanged ? newFn.usesUserContext : undefined,
139
144
  });
140
145
  }
141
146
  }
@@ -234,6 +239,9 @@ export function formatDiffForConsole(diff: OntologyDiff): string {
234
239
  if (fn.fieldReferencesChanged) {
235
240
  lines.push(` Field references: changed`);
236
241
  }
242
+ if (fn.userContextChanged) {
243
+ lines.push(` User context: ${fn.usesUserContext ? 'added' : 'removed'}`);
244
+ }
237
245
  }
238
246
  }
239
247
  }
@@ -2,7 +2,7 @@ import { createHash } from "crypto";
2
2
  import { z } from "zod";
3
3
  import { zodToJsonSchema } from "zod-to-json-schema";
4
4
  import type { OntologyConfig } from "../config/types.js";
5
- import { getFieldFromMetadata } from "../config/categorical.js";
5
+ import { getFieldFromMetadata, getUserContextFields } from "../config/categorical.js";
6
6
  import type {
7
7
  OntologySnapshot,
8
8
  FunctionShape,
@@ -112,6 +112,10 @@ export function extractOntology(config: OntologyConfig): OntologySnapshot {
112
112
  // Extract field references
113
113
  const fieldReferences = extractFieldReferences(fn.inputs);
114
114
 
115
+ // Check if function uses userContext
116
+ const userContextFields = getUserContextFields(fn.inputs);
117
+ const usesUserContext = userContextFields.length > 0;
118
+
115
119
  functions[name] = {
116
120
  description: fn.description,
117
121
  // Sort access groups for consistent hashing
@@ -122,6 +126,7 @@ export function extractOntology(config: OntologyConfig): OntologySnapshot {
122
126
  outputsSchema,
123
127
  fieldReferences:
124
128
  fieldReferences.length > 0 ? fieldReferences : undefined,
129
+ usesUserContext: usesUserContext || undefined,
125
130
  };
126
131
  }
127
132
 
@@ -24,6 +24,8 @@ export interface FunctionShape {
24
24
  outputsSchema?: Record<string, unknown>;
25
25
  /** Fields that reference other functions for their options */
26
26
  fieldReferences?: FieldReference[];
27
+ /** Whether this function uses userContext() for row-level access control */
28
+ usesUserContext?: boolean;
27
29
  }
28
30
 
29
31
  /**
@@ -70,6 +72,8 @@ export interface FunctionChange {
70
72
  oldEntities?: string[];
71
73
  newEntities?: string[];
72
74
  fieldReferencesChanged?: boolean;
75
+ userContextChanged?: boolean;
76
+ usesUserContext?: boolean;
73
77
  }
74
78
 
75
79
  /**
@@ -1,15 +1,27 @@
1
1
  import { createMiddleware } from "hono/factory";
2
2
  import type { Context, Next } from "hono";
3
3
  import type { ContentfulStatusCode } from "hono/utils/http-status";
4
- import type { OntologyConfig, ResolverContext, EnvironmentConfig } from "../../config/types.js";
4
+ import type { OntologyConfig, ResolverContext, EnvironmentConfig, AuthResult } from "../../config/types.js";
5
5
  import { createLogger } from "../resolver.js";
6
6
 
7
+ /**
8
+ * Normalize auth function result to AuthResult format.
9
+ * Supports both legacy string[] format and new AuthResult object.
10
+ */
11
+ export function normalizeAuthResult(result: string[] | AuthResult): AuthResult {
12
+ if (Array.isArray(result)) {
13
+ return { groups: result };
14
+ }
15
+ return result;
16
+ }
17
+
7
18
  /**
8
19
  * Context variables added by middleware
9
20
  */
10
21
  export interface OntologyVariables {
11
22
  resolverContext: ResolverContext;
12
23
  accessGroups: string[];
24
+ authResult: AuthResult;
13
25
  }
14
26
 
15
27
  /**
@@ -20,9 +32,11 @@ export function createAuthMiddleware(config: OntologyConfig) {
20
32
  return createMiddleware<{ Variables: OntologyVariables }>(
21
33
  async (c: Context<{ Variables: OntologyVariables }>, next: Next) => {
22
34
  try {
23
- // Call user's auth function
24
- const accessGroups = await config.auth(c.req.raw);
25
- c.set("accessGroups", accessGroups);
35
+ // Call user's auth function and normalize result
36
+ const rawResult = await config.auth(c.req.raw);
37
+ const authResult = normalizeAuthResult(rawResult);
38
+ c.set("authResult", authResult);
39
+ c.set("accessGroups", authResult.groups);
26
40
  await next();
27
41
  } catch (error) {
28
42
  return c.json(
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import type { OntologyConfig, FunctionDefinition } from "../../config/types.js";
3
3
  import { loadResolver } from "../resolver.js";
4
+ import { getUserContextFields } from "../../config/categorical.js";
4
5
  import {
5
6
  createAccessControlMiddleware,
6
7
  type OntologyVariables,
@@ -19,6 +20,9 @@ export function createApiRoutes(
19
20
  for (const [name, fn] of Object.entries(config.functions)) {
20
21
  const path = `/${name}`;
21
22
 
23
+ // Pre-compute userContext fields for this function
24
+ const userContextFields = getUserContextFields(fn.inputs);
25
+
22
26
  router.post(
23
27
  path,
24
28
  // Access control for this specific function
@@ -26,11 +30,21 @@ export function createApiRoutes(
26
30
  // Handler
27
31
  async (c) => {
28
32
  const resolverContext = c.get("resolverContext");
33
+ const authResult = c.get("authResult");
29
34
 
30
35
  // Parse and validate input
31
36
  let args: unknown;
32
37
  try {
33
- const body = await c.req.json();
38
+ let body = await c.req.json();
39
+
40
+ // Inject user context if function requires it
41
+ if (userContextFields.length > 0 && authResult.user) {
42
+ body = { ...body };
43
+ for (const field of userContextFields) {
44
+ (body as Record<string, unknown>)[field] = authResult.user;
45
+ }
46
+ }
47
+
34
48
  const parsed = fn.inputs.safeParse(body);
35
49
 
36
50
  if (!parsed.success) {
@@ -45,8 +59,17 @@ export function createApiRoutes(
45
59
 
46
60
  args = parsed.data;
47
61
  } catch {
48
- // No body or invalid JSON - try with empty object
49
- const parsed = fn.inputs.safeParse({});
62
+ // No body or invalid JSON - try with empty object (with user context if needed)
63
+ let emptyBody: Record<string, unknown> = {};
64
+
65
+ // Inject user context even for empty body
66
+ if (userContextFields.length > 0 && authResult.user) {
67
+ for (const field of userContextFields) {
68
+ emptyBody[field] = authResult.user;
69
+ }
70
+ }
71
+
72
+ const parsed = fn.inputs.safeParse(emptyBody);
50
73
  if (!parsed.success) {
51
74
  return c.json(
52
75
  {
@@ -7,17 +7,33 @@ import {
7
7
  import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
8
8
  import { Hono } from "hono";
9
9
  import { cors } from "hono/cors";
10
- import type { OntologyConfig, ResolverContext, EnvironmentConfig } from "../../config/types.js";
10
+ import type { OntologyConfig, ResolverContext, EnvironmentConfig, AuthResult } from "../../config/types.js";
11
11
  import { generateMcpTools, filterToolsByAccess, createToolExecutor } from "./tools.js";
12
12
  import { createLogger } from "../resolver.js";
13
13
  import { serve } from "../../runtime/index.js";
14
14
 
15
15
  /**
16
- * Extract access groups from AuthInfo
16
+ * Normalize auth function result to AuthResult format.
17
17
  */
18
- function getAccessGroups(authInfo?: AuthInfo): string[] {
19
- if (!authInfo?.extra?.accessGroups) return [];
20
- return authInfo.extra.accessGroups as string[];
18
+ function normalizeAuthResult(result: string[] | AuthResult): AuthResult {
19
+ if (Array.isArray(result)) {
20
+ return { groups: result };
21
+ }
22
+ return result;
23
+ }
24
+
25
+ /**
26
+ * Extract AuthResult from AuthInfo
27
+ */
28
+ function getAuthResult(authInfo?: AuthInfo): AuthResult {
29
+ if (!authInfo?.extra?.authResult) {
30
+ // Fallback to legacy format
31
+ if (authInfo?.extra?.accessGroups) {
32
+ return { groups: authInfo.extra.accessGroups as string[] };
33
+ }
34
+ return { groups: [] };
35
+ }
36
+ return authInfo.extra.authResult as AuthResult;
21
37
  }
22
38
 
23
39
  export interface McpServerOptions {
@@ -68,8 +84,8 @@ export function createMcpServer(options: McpServerOptions): Server {
68
84
 
69
85
  // Handle list tools request - filter by per-request access groups
70
86
  server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => {
71
- const accessGroups = getAccessGroups(extra.authInfo);
72
- const accessibleTools = filterToolsByAccess(allTools, accessGroups);
87
+ const authResult = getAuthResult(extra.authInfo);
88
+ const accessibleTools = filterToolsByAccess(allTools, authResult.groups);
73
89
 
74
90
  return {
75
91
  tools: accessibleTools.map((tool) => ({
@@ -83,10 +99,10 @@ export function createMcpServer(options: McpServerOptions): Server {
83
99
  // Handle call tool request - validate access per-request
84
100
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
85
101
  const { name, arguments: args } = request.params;
86
- const accessGroups = getAccessGroups(extra.authInfo);
102
+ const authResult = getAuthResult(extra.authInfo);
87
103
 
88
104
  try {
89
- const result = await executeToolWithAccess(name, args || {}, accessGroups);
105
+ const result = await executeToolWithAccess(name, args || {}, authResult);
90
106
 
91
107
  return {
92
108
  content: [
@@ -146,15 +162,18 @@ export async function startMcpServer(options: McpServerOptions): Promise<{ port:
146
162
  app.all("/mcp", async (c) => {
147
163
  try {
148
164
  // Authenticate the request using the config's auth function
149
- const accessGroups = await config.auth(c.req.raw);
165
+ const rawResult = await config.auth(c.req.raw);
166
+ const authResult = normalizeAuthResult(rawResult);
150
167
 
151
- // Create AuthInfo object with access groups in extra field
168
+ // Create AuthInfo object with full auth result in extra field
152
169
  const authInfo: AuthInfo = {
153
170
  token: c.req.header("Authorization") || "",
154
171
  clientId: "ontology-client",
155
172
  scopes: [],
156
173
  extra: {
157
- accessGroups,
174
+ authResult,
175
+ // Keep accessGroups for backwards compatibility
176
+ accessGroups: authResult.groups,
158
177
  },
159
178
  };
160
179
 
@@ -5,8 +5,9 @@ import type {
5
5
  FunctionDefinition,
6
6
  ResolverContext,
7
7
  EnvironmentConfig,
8
+ AuthResult,
8
9
  } from "../../config/types.js";
9
- import { getFieldFromMetadata } from "../../config/categorical.js";
10
+ import { getFieldFromMetadata, getUserContextFields, hasUserContextMetadata } from "../../config/categorical.js";
10
11
  import { loadResolver, type Logger } from "../resolver.js";
11
12
 
12
13
  /**
@@ -32,6 +33,48 @@ export interface McpTool {
32
33
  fieldReferences?: McpFieldReference[];
33
34
  }
34
35
 
36
+ /**
37
+ * Strip userContext fields from a JSON Schema object.
38
+ * These fields are injected at runtime and should not be exposed to callers.
39
+ */
40
+ function stripUserContextFromJsonSchema(
41
+ jsonSchema: Record<string, unknown>,
42
+ zodSchema: z.ZodType<unknown>
43
+ ): Record<string, unknown> {
44
+ // Only strip from object schemas
45
+ if (jsonSchema.type !== "object" || !jsonSchema.properties) {
46
+ return jsonSchema;
47
+ }
48
+
49
+ // Get userContext field names from the Zod schema
50
+ const userContextFields = getUserContextFields(zodSchema);
51
+ if (userContextFields.length === 0) {
52
+ return jsonSchema;
53
+ }
54
+
55
+ // Create a new schema without userContext fields
56
+ const properties = { ...(jsonSchema.properties as Record<string, unknown>) };
57
+ const required = jsonSchema.required
58
+ ? [...(jsonSchema.required as string[])]
59
+ : undefined;
60
+
61
+ for (const field of userContextFields) {
62
+ delete properties[field];
63
+ if (required) {
64
+ const idx = required.indexOf(field);
65
+ if (idx !== -1) {
66
+ required.splice(idx, 1);
67
+ }
68
+ }
69
+ }
70
+
71
+ return {
72
+ ...jsonSchema,
73
+ properties,
74
+ required: required && required.length > 0 ? required : undefined,
75
+ };
76
+ }
77
+
35
78
  /**
36
79
  * Recursively extract field references from a Zod schema
37
80
  */
@@ -101,6 +144,8 @@ export function generateMcpTools(config: OntologyConfig): McpTool[] {
101
144
  }) as Record<string, unknown>;
102
145
  // Remove $schema key if present
103
146
  delete inputSchema.$schema;
147
+ // Strip userContext fields - these are injected at runtime
148
+ inputSchema = stripUserContextFromJsonSchema(inputSchema, fn.inputs);
104
149
  } catch {
105
150
  inputSchema = { type: "object", properties: {} };
106
151
  }
@@ -149,7 +194,7 @@ export function filterToolsByAccess(
149
194
  }
150
195
 
151
196
  /**
152
- * Create a tool executor function that accepts per-request access groups
197
+ * Create a tool executor function that accepts per-request auth result
153
198
  */
154
199
  export function createToolExecutor(
155
200
  config: OntologyConfig,
@@ -158,7 +203,13 @@ export function createToolExecutor(
158
203
  envConfig: EnvironmentConfig,
159
204
  logger: Logger
160
205
  ) {
161
- return async (toolName: string, args: unknown, accessGroups: string[]): Promise<unknown> => {
206
+ // Pre-compute userContext fields for each function
207
+ const userContextFieldsCache = new Map<string, string[]>();
208
+ for (const [name, fn] of Object.entries(config.functions)) {
209
+ userContextFieldsCache.set(name, getUserContextFields(fn.inputs));
210
+ }
211
+
212
+ return async (toolName: string, args: unknown, authResult: AuthResult): Promise<unknown> => {
162
213
  const fn = config.functions[toolName];
163
214
 
164
215
  if (!fn) {
@@ -167,7 +218,7 @@ export function createToolExecutor(
167
218
 
168
219
  // Check access using per-request access groups
169
220
  const hasAccess = fn.access.some((group) =>
170
- accessGroups.includes(group)
221
+ authResult.groups.includes(group)
171
222
  );
172
223
 
173
224
  if (!hasAccess) {
@@ -176,8 +227,18 @@ export function createToolExecutor(
176
227
  );
177
228
  }
178
229
 
230
+ // Inject user context if function requires it
231
+ const userContextFields = userContextFieldsCache.get(toolName) || [];
232
+ let argsWithContext = args;
233
+ if (userContextFields.length > 0 && authResult.user) {
234
+ argsWithContext = { ...(args as Record<string, unknown>) };
235
+ for (const field of userContextFields) {
236
+ (argsWithContext as Record<string, unknown>)[field] = authResult.user;
237
+ }
238
+ }
239
+
179
240
  // Validate input
180
- const parsed = fn.inputs.safeParse(args);
241
+ const parsed = fn.inputs.safeParse(argsWithContext);
181
242
  if (!parsed.success) {
182
243
  throw new Error(
183
244
  `Invalid input for tool "${toolName}": ${parsed.error.message}`
@@ -189,7 +250,7 @@ export function createToolExecutor(
189
250
  env,
190
251
  envConfig,
191
252
  logger,
192
- accessGroups,
253
+ accessGroups: authResult.groups,
193
254
  };
194
255
 
195
256
  // Load and execute resolver
@@ -7,6 +7,7 @@ import {
7
7
  formatDiffForConsole,
8
8
  lockfileExists,
9
9
  } from "../lockfile/index.js";
10
+ import { validateUserContextRequirements } from "../config/schema.js";
10
11
  import { createApiApp } from "./api/index.js";
11
12
  import { startMcpServer } from "./mcp/index.js";
12
13
  import { serve, type ServerHandle } from "../runtime/index.js";
@@ -70,6 +71,14 @@ export async function startOnt(options: StartOntOptions = {}): Promise<StartOntR
70
71
  consola.info("Loading ontology config...");
71
72
  const { config, configDir } = await loadConfig();
72
73
 
74
+ // Validate userContext requirements
75
+ try {
76
+ await validateUserContextRequirements(config);
77
+ } catch (error) {
78
+ consola.error("User context validation failed:");
79
+ throw error;
80
+ }
81
+
73
82
  // Check lockfile
74
83
  consola.info("Checking lockfile...");
75
84
  const { ontology, hash } = computeOntologyHash(config);