Developer guide to building, hosting and registering a repository connector for AccessPoint: protocol, .NET SDK, templates, identity modes, testing and checklist.
Last updated: October 20, 2026 by Steve
Building a Repository Connector
A connector exposes one records repository to AccessPoint so that coordinators can browse, search, capture and collect its records inside every Documents workspace (request, assignment, task, assessment, incident, complaint). Each captured document records its provenance: source path, version and SHA-256.
This guide is for the developer building a connector for a records system AccessPoint does not ship one for (or extending a shipped one), and for the engineer who hosts, registers and supports it. You can build on the .NET SDK or in any language against the OpenAPI specification. It is not a copy of the wire specification: the OpenAPI document is the authoritative contract for every field.
Where to get things
Every release publishes the connector framework beside the app packages, in a connectors/ folder:
https://get.realizer.io/public/accesspoint/latest/connectors/is the promoted release.https://get.realizer.io/public/accesspoint/{version}/connectors/pins a version.https://get.realizer.io/public/accesspoint/dev/connectors/is the newest build, for evaluation only.
| Path | What it is |
|---|---|
nuget/Realizer.AccessPoint.Connector.Protocol.{version}.nupkg, nuget/Realizer.AccessPoint.Connector.Sdk.{version}.nupkg |
The protocol models and the connector SDK. Push both to your organization's private NuGet feed, or download them into a folder and add it as a package source (dotnet nuget add source <folder>). |
AccessPoint.Connector.FileShare.zip, AccessPoint.Connector.OnBase.zip, AccessPoint.Connector.OpenTextCS.zip, AccessPoint.Connector.Laserfiche.zip, AccessPoint.Connector.Axon.zip |
The ready-to-run connectors, each a dotnet publish output. |
AccessPoint.Connector.Tools.zip |
The accesspoint-connector command-line tool, which runs the conformance self-test against any connector. |
*.zip.sha256 |
A SHA-256 digest beside each zip. Compare it with sha256sum <zip> or Get-FileHash <zip> before deploying. |
docs/ |
The protocol specification (accesspoint-connector-protocol-v1.yaml, OpenAPI 3.1), BuildingAConnector.md (the connector author's guide) and one guide per publisher connector. |
The connector version is the API version, so an API upgrade is the cue to redeploy connectors from the same path. Connectors are covered by the flat-rate license; there is no per-connector fee.
What a connector is, and is not
Three facts shape everything below.
- The AccessPoint API is the only client. The browser never calls a connector. Every browse, search and capture runs from the browser to the API to the connector. The API pulls the bytes server-side, streams them into the case's blob storage, verifies the hash and runs the same post-upload pipeline an uploaded file gets. A connector needs no CORS, no user-facing UI and no inbound reach to AccessPoint, because the API always dials it.
- A connector is read-only. The protocol has no write-back. A capture is always a frozen snapshot copy into AccessPoint storage, never a live reference.
- Two kinds, one interface. The built-in kinds (Azure Blob container, MCP search/fetch server, SQL view, CMIS 1.1 repository) run inside the API and are configured, not coded. Customer code never runs inside the API process. If a built-in kind covers your source, configure it in Settings > Foundations > Repository connectors. Everything else is a Remote connector, a separate HTTPS service that speaks the protocol, which is what this guide is about.
On every call the connector receives the tenant, a correlation id, the attributed user, the case reference and the purpose. That is enough for the source system's own logs to record who looked at what, for which case and why. Use it.
Prerequisites
| Need | Detail |
|---|---|
| .NET (SDK path) | The SDK targets net8.0 and net10.0, so a connector can run on the .NET 8 LTS App Service and Functions stacks or on .NET 10. Any language works against the OpenAPI specification, and the self-test CLI still runs against it. |
| The packages | Realizer.AccessPoint.Connector.Sdk, which brings Realizer.AccessPoint.Connector.Protocol with it. |
| A deployment to test against | A dev tenant where you hold the configuration.manage permission, so you can register the connector and run Test connection. The subscription must include the connectors module: a deployment without it answers 403 module-not-licensed to registration, Test connection and every browse, search or capture. |
| Customer-side values | The customer's Entra tenant id, the audience the API requests a token for, and the object id of the AccessPoint API's managed identity. See Configure authentication. |
| A host | App Service, Azure Functions, a Container App, or an on-premises host reached through an App Service Hybrid Connection. |
| Reach to the source | A credential the customer is happy to give a service: a managed identity, a service account or an API client. Keep secrets in Key Vault references or a managed identity, never in the manifest, in item metadata or in problem details. |
The protocol at a glance
The protocol is stateless JSON over HTTPS: camelCase, enums as strings, nulls omitted. Every request carries Authorization: Bearer <token> and the X-AP-* attribution headers. A connector must authenticate the caller before it reads any attribution header, because the headers are trusted only when the token proves the caller is the AccessPoint API.
Endpoints
Routes are relative to the connector's registered base URL.
| Route | Purpose | Required |
|---|---|---|
GET manifest |
What the connector is and can do. Cheap and deterministic; the API caches it for an hour. | Yes |
GET health |
{ status: ok \| degraded \| down, sourceReachable, detail, checkedAt }. Answer within 10 seconds. |
Yes |
GET roots |
Top-level containers as an item page. | When browse |
GET containers/{id}/children?cursor&pageSize&filter |
One level, cursor-paged; filter means name contains. |
When browse |
GET containers/{id}/descendants?cursor&pageSize&modifiedFrom&modifiedTo |
The whole subtree, paged. Collection jobs prefer it to walking level by level. | Optional (descendants) |
GET items/{id} |
One item with its metadata. | Yes |
GET / HEAD items/{id}/content |
Streamed bytes with Content-Length, Content-Type, ETag, Last-Modified, Accept-Ranges: bytes, Content-Disposition and the optional X-AP-Sha256 header. A Range header gets a 206. HEAD lets the API size an item before capture. |
When fetch |
POST search |
The neutral query, or a native pass-through. | Optional (search) |
POST checkAccess |
{ itemIds[] } returns { allowed[] }: which of these the attributed user may read. Called immediately before a capture. |
Required in Application with user check mode |
GET items/{id}/versions |
Version history, newest first. | Optional (versions) |
GET containers/{id}/delta?token&cursor&pageSize |
Changes since a token. Reserved for re-collect; the API does not call it today. | Optional (delta) |
Headers
On every request from the API:
| Header | Meaning |
|---|---|
X-AP-Protocol-Version |
Always sent; 1 today. A missing or unknown version must be refused with 400 unsupported_protocol_version. |
X-AP-Tenant |
The Entra tenant id the call is made for. |
X-AP-Request-Id |
A correlation id. Echo it on the response and log it. |
X-AP-User-Id, X-AP-User-Upn |
The attributed user's Entra object id and UPN (the UPN falls back to the e-mail address). |
X-AP-User-Groups |
Comma-separated Entra group ids of the attributed user. Sent only when the manifest sets wantsGroups. |
X-AP-Case |
{EntityType}:{Number}, for example Request:A-2026-0142 (the id when the case has no number). |
X-AP-Purpose |
browse, search, capture, collect or test. |
Accept-Language |
The caller's UI language. Use it to pick localized names. |
System calls (the health sweep and Test connection) carry no user and no case. On every response the SDK sets Cache-Control: no-store, X-AP-Protocol-Version: 1 and the echoed X-AP-Request-Id. A content response may add X-AP-Sha256: sha256:<hex> when the connector knows the full-content digest before streaming.
Wire models
| Model | What it is |
|---|---|
| Manifest | id (stable, reverse-DNS), names (per locale, en required), descriptions, version (informational semver), protocolVersions (must contain 1), icon (a Fluent UI icon name or a PNG data URI of at most 16 KB), identityMode, wantsGroups, capabilities, metadataSchema, limits { maxPageSize up to 200, maxContentBytes }. |
| Item | id (opaque, up to 1024 characters), kind (container or item), name (up to 255, with extension), path (display and provenance, up to 2048), mimeType, size, created, modified, author, webUrl (opens the item in the source), version, hash (sha256: followed by 64 lowercase hex characters), metadata (keyed per the manifest's schema), hasChildren, canFetch. |
| Item page | { items[], nextCursor }. nextCursor is null on the last page. |
| Search request | { query, mode, containerId?, from?, to?, filters?, cursor?, pageSize? }. |
| Check access | { itemIds[] } (up to 500) returns { allowed[] }. Unknown ids are simply absent. |
| Health | { status, sourceReachable, detail, checkedAt }. |
| Problem | RFC 9457 problem details plus code, requestId and retryAfterSeconds. |
Capability flags
Absent or false means the API never calls the corresponding route.
| Flag | Enables |
|---|---|
browse (default true) |
roots and children. A search-only source sets it false and returns an empty page from roots rather than an error, so the picker shows an empty browse and the Search tab. |
descendants |
The subtree listing collection jobs prefer. |
search { modes[], filters[] } |
POST search. null means no search at all. |
fetch (default true) |
items/{id}/content. False for a metadata-only source. |
checkAccess |
POST checkAccess. Mandatory when identityMode is ApplicationWithUserCheck. |
versions, delta |
The optional history routes. |
metadataSchema declares the keys an item's metadata may carry: key, type (string, number, boolean, date, dateTime, string[]), per-locale labels and facetable. It drives the picker's details flyout and facets. Declare only what you implement.
Paging cursors
Every listing takes an opaque cursor and a pageSize (1 to 200, default 50; the API clamps it to the manifest's maxPageSize) and returns nextCursor to be handed back unchanged. Never use an offset. The easiest robust cursor is the id of the last item returned, in a stable sort order, so paging survives restarts and load balancing.
Collection jobs depend on this. The API persists its walk position between flushes and re-lists a page after a restart, skipping items it already has. A cursor that drifts would double-capture or skip records. The conformance test lists page two when a cursor came back and fails a connector whose second page repeats an id from the first.
Search modes and filters
The API composes a neutral query (free text, a date window with from and to, an optional container scope, and connector-declared filters) or passes a native query through verbatim.
| Mode | query is |
Declare when |
|---|---|---|
freeText |
Full text over the source's own index, or name-contains when it has none. | You have an index, or name-contains is acceptable. This mode also feeds the picker's Suggested documents and the Ask AccessPoint repository supplement; the former also needs the date filter. |
structured |
Name-contains, plus the filters you declare. date means you honour from and to; others such as author, type and extension are yours to define. |
The source has structured fields worth exposing. |
native |
The source's own query language, passed through. Validate it; never let it become a write. | Power users know the source's syntax. |
containerId restricts results to that container and its descendants. Results are an item page like any listing.
Health and the conformance sequence
GET health is probed every five minutes by the API's health sweep. The result maps to the status administrators see: 401 or 403 is Unauthorized, a protocol refusal is Incompatible, a transport failure, timeout, 5xx or down is Unreachable, and degraded is Degraded. Health is advisory: the tile is greyed with a tooltip but stays clickable. Answer honestly and quickly, and put the reason in detail. Never put a secret there, because administrators see the text.
Test connection in Settings runs the same sequence as the SDK's self-test, each step reported as Pass, Fail or Skip with detail and duration:
- manifest: fetched and well-formed (
id,names.en,protocolVersions,limits, a valid icon). A Fail stops the run. - protocolVersion:
protocolVersionscontains 1, and a request sent with version 999 is refused400 unsupported_protocol_version. Built-ins skip the wire half. - identityMode: a valid mode.
Delegatedfails;ApplicationWithUserCheckwithoutcheckAccessfails. - roots: every root validates. Zero roots passes ("nothing to browse for this caller").
- children: the first root's children (page size 10). Page two is fetched when a cursor came back and must not repeat an id. The SDK self-test also checks that
pageSize=201is refused (a warning otherwise). - search: when declared, a short query in
freeText, elsestructured. Skipped when onlynativeis declared. - item: a fetchable item resolves to the same id. An unknown id must answer
404 not_found(a warning otherwise). - content: the first 64 KB through a
Rangerequest. The SDK self-test warns whenAccept-Ranges: bytesis missing or the range was ignored. The hash is verified only when the whole item fit, and a mismatch fails the step. - checkAccess: when declared, the answer may only contain ids that were asked about.
- health:
statusisok,degradedordown.
Error shapes and codes
Every non-2xx response is application/problem+json with type (urn:accesspoint:connector-problem:<code>), title, status, detail, instance, code, requestId and retryAfterSeconds. The API dispatches on code first and the HTTP status second, and relays the code to the client unchanged.
code |
Status | When |
|---|---|---|
not_found |
404 | The container or item does not exist, or is invisible to the connector's identity. |
access_denied |
403 | The attributed user (or the connector's identity) may not read the item. A capture skips it as access denied. |
source_unavailable |
503 | The source cannot be reached or returned a server error. Set retryAfterSeconds when you know. |
too_large |
413 | The item exceeds maxContentBytes or the connector's own cap. |
unsupported |
501 | The capability is not implemented. The SDK answers this for every undeclared optional route, and 405 for a wrong method. |
rate_limited |
429 | The source or connector is throttling. Retry-After is set from retryAfterSeconds. |
invalid_query |
400 | A malformed query, filter, cursor or paging parameter. |
unauthorized |
401 | No token, an invalid token, or a principal that is not an allowed caller. Shows as health Unauthorized. |
unsupported_protocol_version |
400 | The version header is missing or names a version the connector does not speak. |
internal_error |
500 | An unexpected failure inside the connector. |
On its own side the API adds connector_unavailable (502, a transport failure or an open circuit), connector_disabled (409), connector_not_visible (403), connectors_disabled (403) and invalid_anchor (400). These never reach a connector.
What the API does around your connector
Design for these facts.
- Timeouts per call: browse, search and manifest 30 seconds; content 10 minutes per item; health 10 seconds. JSON responses are capped at 4 MB.
- Circuit breaker: five consecutive transport failures (timeouts, connection errors, 5xx) open the circuit for 60 seconds. Every call then fails fast with
connector_unavailableand the tile greys. A 4xx problem is an answer and never counts. Test connection, edit and delete reset it. - Retries: idempotent GETs may be retried. The two POST routes (
searchandcheckAccess) are never retried. Keep every GET idempotent and free of side effects. - Manifest caching: list views use the stored manifest whatever its age. It is refetched when older than an hour, falling back to the stored copy when the connector is down. Registration, Test connection and Refresh manifest force a fetch.
- Outbound allow-list: the API only calls URLs under a registered base URL (scheme, host and port exact, path prefix) and never follows redirects. A connector cannot bounce the API elsewhere, and a
webUrlis never fetched by the API; it is handed to the user's browser. - Rate limit: treat 60 requests per minute per user as the ceiling your source must tolerate from one coordinator.
- Capture path:
GET items/{id}to resolve and de-duplicate, then, in user-check mode,POST checkAccess, thenGET items/{id}/contentstreamed into blob storage with SHA-256 computed on the way, thenGET items/{id}again to record provenance. That second call is why a connector that only learns the hash while streaming should surface it on the next item read.
Building a connector with the SDK
Reference the packages
<PackageReference Include="Realizer.AccessPoint.Connector.Sdk" Version="x.y.z" />
x.y.z is the AccessPoint API version the packages were stamped with. The SDK brings the protocol models, the request dispatcher, Entra bearer validation, the hosting helpers, the conformance self-test and the MCP facade. It uses the ASP.NET Core shared framework.
Implement IRepositorySource
IRepositorySource is the one interface a connector author implements; the dispatcher maps every protocol route onto it. Every method receives a call context with the protocol version, tenant, request id, user id, user UPN, user groups, case reference, purpose, accept-language (with a PreferredLocale() helper), the authenticated caller principal and whether a user is attributed.
| Member | Route | Required |
|---|---|---|
GetManifestAsync |
GET manifest |
Yes |
GetHealthAsync |
GET health |
Yes |
GetRootsAsync |
GET roots |
Yes (return an empty page for a search-only source) |
GetChildrenAsync(containerId, cursor, pageSize, filter) |
GET containers/{id}/children |
Yes |
GetItemAsync(itemId) |
GET items/{id} |
Yes |
OpenContentAsync(itemId, range) |
GET / HEAD items/{id}/content |
Yes |
GetDescendantsAsync(...) |
GET containers/{id}/descendants |
Optional (the default answers 501 unsupported) |
SearchAsync(request) |
POST search |
Optional |
CheckAccessAsync(itemIds) |
POST checkAccess |
Required in user-check mode |
GetVersionsAsync, GetDeltaAsync |
History routes | Optional |
pageSize arrives already validated and defaulted. Rules that make a source behave well inside AccessPoint:
- Return
nullfor an unknown id fromGetItemAsyncandOpenContentAsync, which becomes404 not_found. ThrowConnectorExceptionfor anything the caller must distinguish:AccessDenied(),TooLarge(),SourceUnavailable(detail, inner, retryAfterSeconds),RateLimited(seconds),InvalidQuery(),NotFound()andUnsupported(). Common .NET exceptions are mapped for you. - Ids are opaque and stable. A captured document keeps the id as provenance for the life of the case. Never change what an id means.
- Order stably and page by cursor.
- Attribute everything. Log the request id, user UPN, case reference and purpose at Information level so the source's administrators can answer "who looked at this and why". In user-check mode, trim by the attributed user and fail closed: no attributed user, an unresolvable principal or an unreadable ACL means the item is excluded, not included.
- Localize from the manifest.
names,descriptionsandmetadataSchema[].labelsare per-locale dictionaries.enis required and is the fallback. - Register the source as a singleton or scoped service. The dispatcher resolves it per request.
The manifest
new ConnectorManifest
{
Id = "contoso.records", // reverse-DNS, never changed once documents exist
Names = { ["en"] = "Records system", ["fr"] = "Système de gestion des documents" },
Version = "1.0.0",
ProtocolVersions = [1],
Icon = "Database", // Fluent UI icon name, or a PNG data URI of 16 KB or less
IdentityMode = IdentityModes.Application, // or ApplicationWithUserCheck (+ CheckAccess = true, WantsGroups as needed)
Capabilities = new ConnectorCapabilities
{
Browse = true, Fetch = true,
Descendants = true, // only if GetDescendantsAsync is real
Search = new SearchCapability { Modes = [SearchModes.FreeText, SearchModes.Structured], Filters = ["date", "type"] },
CheckAccess = false,
},
MetadataSchema = [ new MetadataField { Key = "type", Type = "string", Facetable = true, Labels = { ["en"] = "Record type" } } ],
Limits = new ConnectorLimits { MaxPageSize = 200, MaxContentBytes = 2L * 1024 * 1024 * 1024 },
}
Keep the manifest cheap and deterministic: the API caches it for an hour and Test connection fetches it first. A connector may derive capabilities from its source, but the manifest must not depend on who is asking.
Content streaming
OpenContentAsync returns a content result with the stream, the Length of the full content when known (needed for Content-Length and suffix ranges), ContentType (application/octet-stream when unknown; the API sniffs the bytes regardless), ETag, LastModified, FileName (emitted as an inline Content-Disposition), Sha256 (emitted as X-AP-Sha256 when known up front) and AppliedRange.
- Stream; never buffer. The dispatcher copies the stream to the response in 80 KB blocks and disposes the result afterwards.
- Ranges are handled for you. Return the full stream and ignore the
rangeargument. The dispatcher servesbytes=start-end,bytes=start-andbytes=-suffixitself, seeking when the stream allows and reading past the offset otherwise, and answers416for an unsatisfiable range. SetAppliedRangeonly when the source already served the partial bytes. - Hashes are optional but valuable.
hashon the item andX-AP-Sha256on the content let the API verify the captured bytes. A mismatch is flagged on the document, never blocking, and Test connection fails the content step when the whole item was read. If hashing on listing is too expensive, compute the digest while a full fetch streams and surface it on the nextGET items/{id}. The file share, OnBase and OpenText connectors all do this with a small in-memory hash cache. - Size caps. Answer
413 too_largeabove yourmaxContentBytes, and list the item anyway so the user learns why it was skipped.
Errors
ConnectorException carries a Code, a Status (the code's default unless overridden), a Detail shown to administrators and an optional RetryAfterSeconds. The dispatcher also maps common .NET exceptions:
| Exception | Problem |
|---|---|
NotSupportedException, NotImplementedException |
501 unsupported |
FileNotFoundException, DirectoryNotFoundException, KeyNotFoundException |
404 not_found |
UnauthorizedAccessException, SecurityException |
403 access_denied |
FormatException, ArgumentException |
400 invalid_query |
TimeoutException, OperationCanceledException (source side) |
503 source_unavailable |
HttpRequestException, SocketException, IOException |
503 source_unavailable |
| Anything else | 500 internal_error (the exception is logged; its message is not sent) |
A failure after the response has started aborts the connection rather than appending JSON to a half-written body. Never put secrets, internal paths or stack traces in Detail.
The dispatcher
The request dispatcher is the host-agnostic router. For every request it sets the response headers, authenticates before reading anything else, enforces X-AP-Protocol-Version, parses the route, builds the call context, resolves your IRepositorySource, dispatches, and writes any failure as problem+json. It reads JSON bodies up to 1 MB by default, refuses non-JSON bodies, validates pageSize, parses modifiedFrom and modifiedTo as ISO 8601, serves HEAD for content, and validates emitted items. Item validation is log-only, so a connector under development can see what it gets wrong without being refused.
Dispatcher options come from the Connector configuration section: TrustHostAuthentication (default false; with no SDK authentication registered every request is refused 401, so the dispatcher fails closed), SupportedProtocolVersions (default [1]), MaxPageSize (up to 200), DefaultPageSize (50), MaxRequestBodyBytes, ValidateItems and AllowHead.
Minimal-API hosts map it with one call:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAccessPointConnector(builder.Configuration); // "Connector" section: auth + dispatcher options
builder.Services.AddRepositorySource<MySource>();
builder.Services.AddAccessPointMcpFacade(); // optional
var app = builder.Build();
app.MapAccessPointConnector(); // or MapAccessPointConnector("connector") under a prefix
app.MapAccessPointMcpFacade("/mcp"); // optional
app.Run();
MapAccessPointConnector maps one catch-all endpoint (GET, HEAD, POST), marked anonymous for the host's authorization middleware because the dispatcher authenticates itself. An Azure Functions host does the same with one catch-all HTTP trigger that forwards the request context to the dispatcher.
Entra authentication
The API authenticates with an Entra access token from the customer tenant, issued to the API's managed identity, for the connector's audience. The SDK validates three things and refuses to start if any is unconfigured:
- Issuer. The customer tenant, in both the v2 (
https://login.microsoftonline.com/{tid}/v2.0) and v1 (https://sts.windows.net/{tid}/) forms. Signing keys come from the tenant's OpenID Connect metadata. - Audience.
Connector:AudienceplusConnector:AdditionalAudiences. List both the App ID URI and the app registration's client id: a v1 access token carries the App ID URI asaud, a v2 token carries the client id, and which one Entra issues depends on the registration'saccessTokenAcceptedVersion. - Caller. The token's
oidmust be inConnector:AllowedCallerObjectIds(the AccessPoint API's managed identity), or itsazporappidinConnector:AllowedCallerAppIds(an app registration used for local testing). An empty allow-list refuses to start, because otherwise any principal in the tenant could call you.
"Connector": {
"TenantId": "<customer tenant id>",
"Audience": "api://accesspoint-connectors.contoso.onmicrosoft.com",
"AdditionalAudiences": [ "<client id of that app registration>" ],
"AllowedCallerObjectIds": [ "<object id of the AccessPoint API's managed identity>" ],
"AllowedCallerAppIds": [],
"InstanceBaseUrl": "https://login.microsoftonline.com",
"Authentication": "Sdk"
}
Each list accepts a JSON array or a comma-separated string, so the same keys work as App Service application settings (Connector__AllowedCallerObjectIds). For sovereign clouds set InstanceBaseUrl. MetadataAddress, RequireHttpsMetadata and ClockSkew (2 minutes) are also available.
App Service Authentication instead. On App Service or Functions you may let the platform validate. Enable Authentication with the Microsoft provider, set the allowed token audiences to your audience, restrict allowed client applications to the API's managed identity, and set Connector:Authentication to Host. The SDK then validates nothing itself. Never set Host without such a gate, because the dispatcher would accept anonymous calls.
Self-test and the command-line tool
ConnectorSelfTest runs the ten conformance steps against any base URL and returns a report (Steps, Manifest, Passed, ToText()). It never throws for connector failures; they become Fail steps. Its options let you stamp a tenant, a user id, UPN and groups (needed to exercise a user-check connector), a case reference, the head size (64 KB) and how far below the roots to look for a fetchable item. Pass an in-process test-server client to run it without a network.
The same sequence ships as the accesspoint-connector command-line tool (AccessPoint.Connector.Tools.zip):
accesspoint-connector selftest <baseUrl> [--token <bearer>] [--tenant <tid>] [--user-id <oid>] [--user-upn <upn>] [--groups a,b] [--json]
accesspoint-connector manifest <baseUrl> [--token <bearer>]
accesspoint-connector token --tenant <tid> --client-id <appId> --client-secret <secret> --scope <audience>/.default
Exit code 0 means pass, 1 means fail, 2 means a usage error. token mints a client-credentials bearer for local testing. Register that app's client id in Connector:AllowedCallerAppIds (or its service principal's object id in AllowedCallerObjectIds) so the connector accepts it. The zip runs as dotnet AccessPoint.Connector.Tools.dll ....
The MCP facade
AddAccessPointMcpFacade() and MapAccessPointMcpFacade("/mcp") publish the same IRepositorySource as an MCP server with exactly two read-only tools: search (free text returns { results: [{ id, title, url }] }) and fetch (an id returns { id, title, text, url, metadata }). This is the convention Microsoft 365 Copilot federated connectors and other AI-client connectors share, served over Streamable HTTP in stateless mode.
search picks freeText, else structured, else native from your manifest. fetch returns the content as text for text, JSON, XML and YAML records (up to 1 MB by default) and the record's metadata rendered as text for anything else. There is no PDF text extraction; capture the record through AccessPoint for that. The facade requires an authenticated caller on the connector's own bearer scheme by default; turn that off only behind a gateway that authenticates for you. Facade calls arrive without protocol headers, so the call context is built from the caller's oid and preferred_username claims.
Publishing the facade is optional and independent of AccessPoint. Conversely, AccessPoint's MCP built-in consumes any search/fetch server as a search-only source (captures become HTML records), so an MCP server you already have can be registered without writing a connector.
Unit tests
Host the dispatcher in an in-process test server over an in-memory repository source, with injected token-validation parameters, so your tests run without a network or an Entra tenant. Run the self-test against that same host to prove conformance in your build pipeline.
Templates
Two templates ship with the SDK sources. Copy the folder, rename the project and the namespace, replace the template repository source, and keep the shape: one IRepositorySource, one Program.cs. Both run and pass the self-test out of the box against a tiny in-memory tree, which is the fastest way to see the protocol working end to end before you touch a real source.
| Template | Host | Shape |
|---|---|---|
| App Service | ASP.NET Core minimal API on App Service (Linux or Windows) or a Container App | Program.cs is the few lines shown above plus an optional Mcp:Enabled switch. appsettings.json carries the Connector section. |
| Functions | Azure Functions, isolated worker, ASP.NET Core integration | Program.cs adds the connector and the source. One catch-all HTTP trigger (anonymous on purpose, because the dispatcher authenticates) forwards the request context to the dispatcher. host.json sets an empty route prefix so the base URL is the Function App root (/manifest, not /api/manifest). local.settings.json holds local values and is not published. |
Steps that apply to either:
- Fill the
Connectorsection: tenant id, audience, the app's client id and the API's managed-identity object id. - Implement the source.
- Run it (
dotnet runorfunc start) and runaccesspoint-connector selftest https://localhost:<port>/ --token <bearer>with a token fromaccesspoint-connector token .... - Deploy with HTTPS and a managed identity for the source.
- Register the URL in Settings > Foundations > Repository connectors > Add connector > Remote and run Test connection.
For an on-premises Windows service there is no template. The network file share connector is the reference: it runs the same binary as a console app or a Windows service, and each connector ships a Dockerfile for a Linux container where the identity mode allows it.
The shipped connectors as worked examples
The five publisher connectors are complete, tested implementations. Read the one closest to your source before writing your own. Each zip has a guide beside it under docs/ in the release folder. Every one accepts the same four Connector:* authentication keys, exposes the Mcp:Enabled facade switch, caps content at 2 GB by default (Axon Evidence: 50 GB) and computes the SHA-256 while a full fetch streams, surfacing it on the next item read.
Network file share: the reference connector
It shows a hierarchical source with a natural folder tree, Application with user check against a real ACL model, and the on-premises hosting story.
- Ids are a base64url encoding of the root id and relative path: opaque, URL-safe, free of
/, and stable as long as a root id is never renamed after documents have been captured. - Ordering and cursors: siblings by name (case-insensitive), a subtree depth-first. Every cursor is the id of the last item returned.
- User check: it resolves the attributed UPN to an Active Directory SID and token groups, adds the well-known Everyone and Authenticated Users SIDs, evaluates the NTFS ACL with Deny always winning, caches the principal for five minutes, and fails closed on a non-Windows host. That is why the Dockerfile supports Application mode only.
- Hashing: hashing on listing is off by default because it is expensive on a large share. The hash is computed while a full fetch streams and reported on the next item read.
- Search:
structured(name contains, plusextension,nameanddatefilters) andfreeText(every term must appear in the file name). There is no content index, and the guide says so rather than pretending. webUrl: none by default. AccessPoint keeps a source link only when it is an absolute http or https address, so afile://URL would be dropped. A connector for a web-based source should return an httpswebUrl.- Hosting: a domain-joined Windows host with SMB reach beside the Hybrid Connection Manager (Hybrid Connections tunnel TCP, not drive mounts), with Kestrel bound to a certificate whose SAN is the registered hostname.
Hyland OnBase
It shows a source with no folder tree and a vendor REST API behind an OAuth client: how to synthesize browse, map a keyword model to the neutral query, and be explicit about what is unverified.
- Browse is synthesized. Roots are Document Type Groups (when the API server exposes them) or Document Types. A type lists its documents newest first, paged inside a bounded query.
pathis group, type and name; keyword values ride inmetadataand the manifest declares them from a cached keyword-type catalogue. - Search:
structured(document type, keyword equals or contains, document-date range),freeText(keyword mapping, or the Full-Text module when enabled) andnative(a validated query body passed through). - Identity mode: Application. The connector runs as one OnBase service user. What that user's groups can see is what coordinators see, and every call logs the attributed user, case and purpose.
checkAccessis not declared, so Test connection skips it. - Versions only when revisions are enabled in the connector's configuration.
- Honesty about vendor facts: the connector's guide lists the API-server behaviors to verify against the customer's installation (grant types, endpoint paths, full-text availability). Do the same for your vendor.
OpenText Content Server and GCdocs
It shows true impersonation, where the source itself trims by user, plus a native query language and a multi-mode authenticator.
- Impersonation modes: token exchange (OTDS OAuth, recommended for GCdocs) or ticket-for-user make the manifest declare ApplicationWithUserCheck and run every call (browse, search, versions, content) as the attributed user, so Content Server's own permissions do the trimming. A mode of none declares Application with one service identity. A call without user attribution under an impersonating mode is refused or returns nothing, which is failing closed. A setting chooses whether the UPN or the user id is the Content Server login.
checkAccessruns under the user's credential, falling back to a per-node read where the batch endpoint is absent. Credentials are cached ten minutes per principal and re-acquired once on a401.- Ids are node ids (the root defaults to the Enterprise Workspace). They are numeric, so provenance survives renames and the
%2Fproblem described under Ids never arises. - Paths come from the node's ancestors, trimmed at the first configured root.
- Search:
structured(name words, type, date range, container),freeText(full text) andnative(your ownwhereclause, validated, with the container scope appended). - Ranges are forwarded to Content Server once as a probe. If it answers
200, the connector stops forwarding and the SDK serves ranges by reading past the offset. Content is always the current version.
Laserfiche
It shows a cloud vendor API behind a signed client-credentials assertion, a native folder tree with OData paging, a search-task lifecycle whose results expire, an export-then-download content path, and how to stay under a per-account rate limit.
- Two deployments, one source: Laserfiche Cloud (a service principal; the token request carries a JWT signed with the access key) or the self-hosted API server (password grant). Every route is a configuration key because the self-hosted prefix is unverified.
- Ids are a base64url encoding of repository id and entry id. Numeric entry ids survive renames and moves, and the repository segment disambiguates multi-repository accounts. A shortcut is listed under its target's id so a document is captured once.
- Cursors carry the server's paging state: a folder cursor names the next link being consumed plus an offset inside it, so a name filter can span server pages. A search cursor names the search task and the skip. An expired search task is re-issued with the same command.
- Content is export-then-download: an export request returns a link the connector streams, sending the bearer only to the API's own host, probing
Rangeonce, and re-exporting once when the link has expired. - A shared rate budget keeps every AccessPoint user together under the cloud service's per-account limit, answering
429 rate_limitedwith a retry-after rather than burning billed calls. - Search composes the vendor's own syntax and refuses values containing quotes or braces instead of guessing escaping rules.
descendantsis declared only when recursive search is switched on, because that behavior is unverified.
Axon Evidence
It shows a flat, immutable evidence store behind an OAuth API client, large media objects, and a download path that leaves the vendor's host.
- Browse is synthesized from evidence facts: roots such as Cases, Categories, Recorded on (year, then month) and Owners, each a bounded search paged by the vendor's own cursor with a capped offset fallback. A single-file evidence item is its file; a multi-file one is a container.
descendantsexpands evidence into files across protocol pages, while the roots refuse collection (501) because they would walk the whole agency. - Ids are base64url encodings of the root, the evidence id, or the evidence and file ids, with no
/. - Content is streamed and never buffered under a 50 GB default cap, with at most one redirect to an allow-listed download host and the bearer never forwarded off the agency host. SHA-256 is computed while streaming, and the vendor's declared digest is mapped when present.
- Rate limits: a
429with a shortRetry-Afteris waited out once, a longer one surfaces asrate_limited, and a connector-side sliding window keeps one deployment inside the vendor's per-agency limit. - Identity mode: Application only. An API client has no user context, so every call logs the attributed user, case and purpose; the vendor's own audit names the API client.
- Every unverified vendor fact (paths, query parameter names, cursor shape, token lifetime, redirect and range behavior) is a configuration key, and the connector's guide has a table to verify them against the customer's system. One pattern lesson worth carrying over: configuration binders append to a pre-populated list, so list options that ship with defaults should default empty and expose an effective view, or a configuration file that repeats the defaults doubles them.
Identity modes and how the user reaches the connector
The manifest's identityMode states how the connector treats the person behind a call. AccessPoint applies its own gates first (the feature toggle, the connector's visibility rule, exactly one case anchor, and the case-level rule identical to an upload) and only then honours the connector's mode. A user therefore never reaches a connector for a case they could not upload to.
| Mode | Source credential | Who sees what | Connector obligations |
|---|---|---|---|
| Application | A service identity (managed identity, service account, API client). | Anyone who passed AccessPoint's gates sees whatever the service identity can see. Right for sources with no per-user model, or where the office owns the records. | Log the attribution (user, case, purpose, request id) so the source's logs can name the person and case. wantsGroups has no effect. |
| Application with user check | The same service identity. | The connector trims every listing and search to what the attributed user may read, and the API calls POST checkAccess with the ids it is about to fetch immediately before each capture. A refused id is skipped as access denied, never captured. |
Implement checkAccess (Test connection fails the mode without it). Set wantsGroups to receive the user's Entra group ids in X-AP-User-Groups when your source authorizes by group. Fail closed. |
| Delegated | The user's own token. | Reserved and not supported. | Test connection fails it. |
In both supported modes the bearer token is the API's managed identity, and the user travels only as attribution headers. The API never forwards a user's token to a connector, and there is no delegated flow to configure on either side. A connector that must act as the user does its own principal mapping from the UPN or object id: the file share connector resolves the UPN to an AD SID, and the OpenText connector exchanges the UPN for a token or ticket under its own OAuth client. If your source cannot impersonate from a UPN, choose Application and let AccessPoint's case-level gate be the gate. Do not fake per-user trimming.
Two consequences to design for. A per-user view must never be cached across users, so key every cache by the attributed principal. And because system calls arrive with no user, a user-check connector should answer them sensibly: a health probe needs no user, and a roots listing with no user may legitimately be empty, which the conformance test treats as a pass.
Ids: the transport rule
An item id is opaque to AccessPoint: any content, up to 1024 characters, reinterpreted by nobody and stored verbatim on every captured document as provenance. Two rules govern how it travels.
API to connector: ids are percent-encoded in the path, exactly once. The SDK's dispatcher prefers the request's raw target over the server-decoded path and unescapes exactly once, so an id containing % or / round-trips on Kestrel. Where only the decoded path is available (an in-process test server, some proxies) it recovers the id by peeling a known tail (children, descendants, delta, content, versions) off the end and undoing %2F. If you implement the protocol without the SDK, decode once and be careful with %2F.
Browser to API: ids travel in the query string. The API's own proxy routes take container and item ids as query-string values, because the Azure App Service front end normalizes %2F in path segments back to real slashes before the request reaches the application. The same normalization can affect a connector sitting behind an App Service or Functions front end.
Therefore, prefer ids without /: base64url, numeric node ids or GUIDs. Encode whatever your source needs (drive and path, node id and version) into a URL-safe form, as the file share connector does. Never derive meaning from an id's shape on the way back in beyond decoding it.
Hosting, registering and testing against a dev tenant
Choose a host
| Host | When | Start from |
|---|---|---|
| Beside AccessPoint: App Service (Linux or Windows) or a Container App in the customer's subscription | The source has an HTTPS endpoint the App Service can reach and you want full control of the pipeline. | The App Service template. The publisher zips are dotnet publish output you zip-deploy; the Dockerfiles build container images. |
| Azure Functions (isolated worker) | Pay-per-use, or you prefer App Service Authentication to SDK validation. | The Functions template. Very large content streams favor the longer timeouts of Premium or Dedicated plans; browse and search are fine on Flex Consumption. |
| On premises: a Windows service or a Linux container next to the source | The source has no public endpoint (a file share, an on-premises OnBase or Content Server). | The file share connector. AccessPoint reaches you through an App Service Hybrid Connection. |
| Any language | You are not on .NET. | Implement the OpenAPI specification directly. The CLI self-test and Test connection work the same. |
Give an Azure-hosted connector its own managed identity for the source and keep source secrets as Key Vault references. A connector never needs inbound reach to AccessPoint or to the publisher.
On premises through Hybrid Connections. An App Service cannot see the customer's network, but a Hybrid Connection gives it a TCP path to one host and port inside it. The customer deploys (or redeploys in upgrade mode) with deployHybridConnections=true and one host:port entry per connector host, and installs the Hybrid Connection Manager on a machine that can reach the connector (outbound 443 to *.servicebus.windows.net only). The connector is registered as https://<that hostname>:<port>/ with Reached through a Hybrid Connection on. The hostname must be one the manager's machine resolves, never localhost, and the one your TLS certificate's SAN covers, because the API's own TLS session rides inside the tunnel. http:// is accepted only inside a Hybrid Connection, and AccessPoint warns about it. Only TCP is tunnelled, so a file share connector must itself have SMB access.
Configure authentication
| Value | Where the administrator finds it |
|---|---|
Connector:TenantId |
The customer's Entra tenant id. |
Connector:Audience |
The tenant's default audience shown at the top of Settings > Foundations > Repository connectors: the App ID URI of the AccessPoint Connectors app registration, api://accesspoint-connectors.<tenant> by convention. Alternatively, the App ID URI of a registration dedicated to your connector, entered on its registration. AccessPoint does not create this app registration; the customer's administrator creates it in the Entra admin center, with an Application ID URI, and enters the URI in the panel. |
Connector:AdditionalAudiences |
That app registration's client id (v2 tokens carry it as aud). |
Connector:AllowedCallerObjectIds |
The object id shown as AccessPoint API identity in the same panel (with a copy button): the API's managed identity in a same-tenant installation, or the Microsoft 365 application's service principal in a split-tenant one. |
In a split-tenant installation, Connector:TenantId is the Microsoft 365 tenant, and your connector's app registration must exist in that tenant. With no audience configured on either the registration or the tenant default, the API calls the connector without an Authorization header and the connector's 401 shows as health Unauthorized. That is the most common first-registration failure.
Run locally and self-test
- Run the connector (
dotnet runorfunc start). - Mint a bearer. Create an app registration in the dev tenant, list its client id in
Connector:AllowedCallerAppIds, and runaccesspoint-connector token --tenant ... --client-id ... --client-secret ... --scope <audience>/.default. - Run
accesspoint-connector selftest https://localhost:<port>/ --token <bearer> --user-upn someone@dev.example. Pass a UPN the source knows when testing a user-check connector, or every step trims to nothing. - Read the report. Fail steps carry the problem code and detail. Warnings inside a Pass (a
200for a ranged request,pageSize=201accepted) are contract gaps worth fixing before they become a customer's Test connection question.
Deploy
Publish (dotnet publish -c Release) and zip-deploy to App Service or Functions, build the container image, or install the Windows service (sc.exe create ...). Configure the Connector section as application settings (Connector__TenantId and so on). Bind HTTPS with a certificate whose SAN is the hostname you will register. Verify with the CLI against the deployed URL before involving the administrator.
Register in AccessPoint and run Test connection
In the dev tenant, go to Settings > Foundations > Repository connectors > Add connector > Remote and enter the name, description, Base URL, Entra audience (empty means the tenant default), Reached through a Hybrid Connection, the visibility rule (a permission code, default document.manage, and/or roles) and Enabled. Saving fetches the manifest at once, and an unreachable manifest is refused with the connector's reason. Then press Test connection and read the ten steps. What a failure means:
| Step | Fail: look at |
|---|---|
manifest with 401 |
The connector refused the API's token: the audience (are both forms listed?) or the caller allow-list (the right managed-identity object id?). |
| manifest with a timeout or connection error | Wrong URL, a firewall, or the Hybrid Connection Manager not connected. |
| protocolVersion | The connector does not speak version 1, or does not refuse version 999. |
| identityMode | Delegated, or user check without checkAccess. |
| children | Cursor paging repeats ids. |
| item | An unknown id is not answered 404 not_found. |
| content | A declared hash that does not match the bytes. Fix the connector; captures would be flagged. |
| health | The source behind the connector is unreachable. |
Then use it. Open a request's Documents tab, choose your connector from the add-documents menu, browse, search, capture one record, and check Source details on the captured document (path, item id, version, hash Verified). Collect this folder on a container exercises descendants (or the level-by-level walk) and your cursors under load. The Azure Blob built-in is a useful reference fixture: if a capture from your connector misbehaves and one from a Blob connector on the same case does not, the difference is on your side.
Living with it
The health sweep probes every enabled connector every five minutes. Check health now and Refresh manifest run either on demand. The circuit breaker opens after five consecutive transport failures, and Test connection resets it. A connector that any document, open collection job or bound assignment repository references cannot be deleted, because provenance must keep resolving. It can only be disabled, which hides the tile and keeps the registration. See Connectors for the administrator's side.
Versioning and compatibility
- Protocol version. The API sends
X-AP-Protocol-Version: 1on every call and supports the current version and the one before it. The manifest'sprotocolVersionslists every version the connector accepts and must contain 1. A connector that refuses the API's version reads as Incompatible in the health pill. When a protocol v2 ships, a connector may list[1, 2]and serve both for a release cycle. - Package version. The two SDK packages are stamped with the AccessPoint API version that built them and published under
connectors/nuget/at thedev/,{version}/andlatest/paths. An API upgrade is the cue to rebuild against the matching packages, though a connector built on an older SDK keeps working as long as it speaks a protocol version the API supports. The SDK targetsnet8.0andnet10.0. - Manifest
versionis informational (semver recommended) and shown to administrators. Bump it when behavior changes. - What must never change once documents exist: the manifest
id, the meaning of any item id (including root ids and any prefix that participates in them), and the semantics ofmetadataSchemakeys. Provenance rows, the Captured from facet and de-duplication on connector, item id and version all depend on them. Adding capabilities, filters or metadata keys is safe, because the API re-reads the manifest hourly and on Test connection. - Cursors should survive a connector restart and a load-balanced pair of hosts. A collection job may resume against a different instance.
- Limits can be tightened in the manifest (
maxPageSize,maxContentBytes), never loosened beyond the protocol's maxima.
Checklist
- Manifest: stable reverse-DNS
id,names.en,protocolVersionscontains 1, a valididentityMode, capabilities andmetadataSchemathat match the implementation,limitswithin the protocol maxima, and an icon that is a Fluent name or a PNG data URI of 16 KB or less. - Ids are opaque, up to 1024 characters, URL-safe, free of
/where you can help it, never reinterpreted, and root ids are never renamed. - Listings are in a stable order. Cursors are opaque and restart-safe. No offset paging. A
pageSizeabove 200 is refused. nullfor unknown ids (which becomes 404).ConnectorExceptionfor access, size and source problems. No secrets or internal paths indetail.- Content streams with length, content type, ETag and file name. Ranges are honoured (by you or the SDK). The hash is surfaced when known, on the next item read if computed while streaming.
- Search is declared only for modes you implement. Native queries are validated and read-only. The
datefilter is honoured when declared. - User-check mode: listings and search trimmed per attributed user,
checkAccessimplemented,wantsGroupsset when you need groups, per-user caches keyed by principal, and it fails closed. - Every call logs the request id, user, case and purpose at Information level.
Connector:AllowedCallerObjectIdsis pinned to the API's managed identity and both audience forms are listed, or App Service Authentication is configured andConnector:AuthenticationisHost.- HTTPS with a certificate whose SAN is the registered hostname.
http://only inside a Hybrid Connection. accesspoint-connector selftestpasses locally and against the deployed URL. Test connection passes on a dev tenant. A capture shows Verified in Source details. A folder collection completes.- Health answers within 10 seconds and never leaks secrets in
detail. - An operator runbook exists on the pattern of the publisher connectors' guides: prerequisites, service account, certificate, configuration keys, registration values, the vendor facts to verify, and troubleshooting.
Related Pages
- Connectors — registering, testing and looking after connectors in Settings.
- Request Documents — how staff browse, search and capture from a connector.
- Deployment — deploying and upgrading AccessPoint, including the optional Hybrid Connections parameters.
- Technical Architecture — how AccessPoint is built and where connectors fit.
- Repository connectors — the capability overview.